├── .gitignore ├── tsconfig.json ├── package.json ├── README.md ├── yarn.lock └── src └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | out/ 3 | node_modules/ 4 | config.json 5 | sessionkey -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "esModuleInterop": true, 5 | "module": "CommonJS", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "outDir": "dist", 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "typeRoots": ["./src/types", "./node_modules/@types"], 14 | "types": ["node"], 15 | "forceConsistentCasingInFileNames": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "resolveJsonModule": true 19 | }, 20 | "include": ["./src/**/*"], 21 | "exclude": ["./node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "lastfm-typed": "^2.0.0", 4 | "reconnecting-websocket": "^4.4.0", 5 | "typescript": "^4.5.3", 6 | "ws": "^8.3.0" 7 | }, 8 | "devDependencies": { 9 | "@types/node": "^16.11.12", 10 | "@types/ws": "^8.2.2" 11 | }, 12 | "name": "shinbatsu", 13 | "version": "2.0.1", 14 | "description": "gosumemory extension to scrobble osu! beatmaps to Last.fm", 15 | "main": "dist/index.js", 16 | "repository": "https://github.com/dreamingkills/shinbatsu", 17 | "author": "@aeoneko ", 18 | "license": "MIT", 19 | "scripts": { 20 | "start": "yarn node ./dist" 21 | }, 22 | "pkg": { 23 | "assets": "sessionkey", 24 | "outputPath": "out" 25 | }, 26 | "bin": "./dist/index.js" 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shinbatsu 2 | 3 | scrobble your played osu! beatmaps to last.fm! 4 | 5 | ## Features 6 | 7 | - updates your last.fm "now playing" when you play a beatmap or watch a replay 8 | - scrobbles the song upon beatmap or replay completion 9 | - automatically removes `(TV Size)`, etc. from song titles 10 | - tries to retrieve album from last.fm data 11 | 12 | ## Installation 13 | 14 | you will need [gosumemory](https://github.com/l3lackShark/gosumemory) to run this script. 15 | 16 | 1. download the [latest shinbatsu release](https://github.com/dreamingkills/shinbatsu/releases/latest) 17 | 2. move the executable to a folder only occupied by the executable file 18 | 3. launch osu! and `gosumemory` 19 | 4. run the executable and follow the instructions on screen 20 | 21 | enjoy! 22 | 23 | ## Contact 24 | 25 | you can find me on [twitter](https://twitter.com/aeoneko) or on discord at `ae#2222`. 26 | 27 | ## FAQ 28 | 29 | Q: **can i use this in a gosumemory frontend?**
30 | A: not currently, but it's being worked on. 31 | 32 | Q: **help! it's scrobbling with incorrect metadata!**
33 | A: shinbatsu does its best to determine metadata from both osu! and last.fm. you can correct your scrobbles with [last.fm pro](https://www.last.fm/pro) or open an issue if it's particularly bad. 34 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/node@*", "@types/node@^16.11.12": 6 | version "16.11.12" 7 | resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.12.tgz#ac7fb693ac587ee182c3780c26eb65546a1a3c10" 8 | integrity sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw== 9 | 10 | "@types/ws@^8.2.2": 11 | version "8.2.2" 12 | resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.2.2.tgz#7c5be4decb19500ae6b3d563043cd407bf366c21" 13 | integrity sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg== 14 | dependencies: 15 | "@types/node" "*" 16 | 17 | cross-fetch@^3.1.4: 18 | version "3.1.4" 19 | resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" 20 | integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== 21 | dependencies: 22 | node-fetch "2.6.1" 23 | 24 | lastfm-typed@^2.0.0: 25 | version "2.0.0" 26 | resolved "https://registry.yarnpkg.com/lastfm-typed/-/lastfm-typed-2.0.0.tgz#33fbcfb9c1922547c2fb8990a5137898c30201f0" 27 | integrity sha512-zjWX48GQxzwMLYTVzFuD5wp5E3Ya6v73jXtMEQgnDs0mv8i0p/eGclk3Jw3a8MYZKQ05k++TMzQvY30Sa1GkOQ== 28 | dependencies: 29 | cross-fetch "^3.1.4" 30 | typed-emitter "^1.3.1" 31 | 32 | node-fetch@2.6.1: 33 | version "2.6.1" 34 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" 35 | integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== 36 | 37 | reconnecting-websocket@^4.4.0: 38 | version "4.4.0" 39 | resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783" 40 | integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng== 41 | 42 | typed-emitter@^1.3.1: 43 | version "1.4.0" 44 | resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-1.4.0.tgz#38c6bf1224e764906bb20cb0b458fa914100607c" 45 | integrity sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg== 46 | 47 | typescript@^4.5.3: 48 | version "4.5.3" 49 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.3.tgz#afaa858e68c7103317d89eb90c5d8906268d353c" 50 | integrity sha512-eVYaEHALSt+s9LbvgEv4Ef+Tdq7hBiIZgii12xXJnukryt3pMgJf6aKhoCZ3FWQsu6sydEnkg11fYXLzhLBjeQ== 51 | 52 | ws@^8.3.0: 53 | version "8.3.0" 54 | resolved "https://registry.yarnpkg.com/ws/-/ws-8.3.0.tgz#7185e252c8973a60d57170175ff55fdbd116070d" 55 | integrity sha512-Gs5EZtpqZzLvmIM59w4igITU57lrtYVFneaa434VROv4thzJyV6UjIL3D42lslWlI+D4KzLYnxSwtfuiO79sNw== 56 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import rws from "reconnecting-websocket"; 2 | import ws from "ws"; 3 | import lastfm from "lastfm-typed"; 4 | import fs from "fs/promises"; 5 | import path from "path"; 6 | 7 | const apiKey = "e5310d1dbb33807f7217a58772bf4025"; 8 | const lfm = new lastfm(apiKey, { 9 | apiSecret: "09d64e9671521726fea70820e9c791a1", 10 | userAgent: "dreamingkills/shinbatsu", 11 | }); 12 | let sessionKey: string; 13 | 14 | const debug = false; 15 | let scrobbling = false; 16 | 17 | const socket = new rws("ws://127.0.0.1:24050/ws", [], { 18 | WebSocket: ws, 19 | connectionTimeout: 1000, 20 | maxRetries: 10, 21 | }); 22 | 23 | socket.onopen = async () => { 24 | try { 25 | const rawKey = await fs.readFile(path.resolve(process.cwd(), "sessionkey")); 26 | const key = rawKey.toString(); 27 | 28 | if (key && key.length === 32) { 29 | sessionKey = key; 30 | scrobbling = true; 31 | 32 | return; 33 | } 34 | } catch (e) { 35 | d("Did not find an existing session key!"); 36 | } 37 | 38 | const token = await lfm.auth.getToken(); 39 | const url = `https://www.last.fm/api/auth?api_key=${apiKey}&token=${token}`; 40 | 41 | console.log( 42 | `\nTo start using \u001b[35mShinbatsu\u001b[0m, please authorize with the following link!\n${url}\n` 43 | ); 44 | 45 | const interval = setInterval(async () => { 46 | try { 47 | const session = await lfm.auth.getSession(token); 48 | sessionKey = session.key; 49 | 50 | await fs.writeFile(path.resolve(process.cwd(), "sessionkey"), sessionKey); 51 | 52 | scrobbling = true; 53 | clearInterval(interval); 54 | return; 55 | } catch (e) { 56 | d(e); 57 | } 58 | }, 5000); 59 | }; 60 | 61 | let gameState: number | undefined; 62 | socket.onmessage = async (event) => { 63 | const previousState = gameState; 64 | 65 | if (!scrobbling) return; 66 | const data = JSON.parse(event.data); 67 | 68 | // runs only on the first pass 69 | if (gameState === undefined) { 70 | console.log( 71 | `\u001b[35mShinbatsu\u001b[0m is listening!\nSongs will be scrobbled \u001b[4mupon beatmap completion\u001b[0m.\n` 72 | ); 73 | 74 | gameState = data.menu.state; 75 | return; 76 | } 77 | 78 | // if the current game state is the same as the previous, there's no point in running the code again 79 | if (gameState === data.menu.state) return; 80 | gameState = data.menu.state; 81 | 82 | // 2 = Playing 83 | // 7 = Results Screen 84 | // 14 = Multiplayer Results Screen 85 | // see full list at https://github.com/Piotrekol/ProcessMemoryDataFinder/blob/master/OsuMemoryDataProvider/OsuMemoryStatus.cs 86 | if (![2, 7, 14].includes(gameState!)) return; 87 | 88 | let artist = data.menu.bm.metadata.artist; 89 | let track = data.menu.bm.metadata.title.replace( 90 | /(\(tv size\)|\(spee?d up ver\.?\)|\(cut ver\.?\))/gi, 91 | `` 92 | ); 93 | let album: string | undefined; 94 | 95 | d(`Attempting to find Last.fm data for ${f(artist, track, album)}...`); 96 | 97 | try { 98 | const lfmTrack = await lfm.track.getInfo({ artist, track }); 99 | 100 | album = lfmTrack.album?.title; 101 | d("Successfully found track data."); 102 | } catch (e) { 103 | d("Failed to find track data."); 104 | } 105 | 106 | if (gameState === 2) { 107 | d(`Attempting to set now playing to ${f(artist, track, album)}...`); 108 | 109 | // duration is necessary in order to clear the Now Playing in a timely manner 110 | let duration = Math.floor(data.menu.bm.time.mp3 / 1000); 111 | const isDT = data.menu.mods.str.includes("DT"); 112 | const isHT = data.menu.mods.str.includes("HT"); 113 | 114 | if (isDT) duration = duration / 1.5; 115 | if (isHT) duration = duration * 1.33; 116 | 117 | const params: { album?: string; duration: number } = { duration }; 118 | 119 | // lastfm-typed will break if 'album' is set to undefined 120 | // temporary workaround until patch 121 | if (album) params.album = album; 122 | 123 | await lfm.track.updateNowPlaying(artist, track, sessionKey, params); 124 | 125 | d(`Successfully updated now playing.`); 126 | return; 127 | } else if ([7, 14].includes(gameState!)) { 128 | // only scrobble if the user was playing 129 | // without this, it would scrobble when viewing scores w/o playing 130 | if (previousState !== 2) return; 131 | 132 | d(`Attempting to scrobble ${f(artist, track, album)}...`); 133 | 134 | const scrobble: { 135 | artist: string; 136 | track: string; 137 | album?: string; 138 | timestamp: number; 139 | } = { 140 | artist, 141 | track, 142 | timestamp: Math.floor(Date.now() / 1000), 143 | }; 144 | 145 | // lastfm-typed will break if 'album' is set to undefined 146 | // temporary workaround until patch 147 | if (album) scrobble.album = album; 148 | 149 | try { 150 | await lfm.track.scrobble(sessionKey, [scrobble]); 151 | 152 | console.log(`Successfully scrobbled ${f(artist, track, album)}!`); 153 | } catch (e) { 154 | console.log(`Failed to scrobble ${f(artist, track, album)} :(`); 155 | 156 | d(e); 157 | } 158 | } 159 | }; 160 | 161 | function f(artist: string, track: string, album: string | undefined): string { 162 | return `\u001b[35m${artist}\u001b[0m - ${track}${album ? ` (${album})` : ""}`; 163 | } 164 | 165 | function d(msg: any) { 166 | if (debug) console.log(msg); 167 | } 168 | --------------------------------------------------------------------------------