diff --git a/db/migrations/6_songs.sql b/db/migrations/6_songs.sql new file mode 100644 index 0000000..a3cae4e --- /dev/null +++ b/db/migrations/6_songs.sql @@ -0,0 +1,13 @@ +-- +migrate up +CREATE TABLE songs ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + author_id INTEGER NOT NULL, + author_name TEXT NOT NULL, + size INTEGER NOT NULL, -- in bytes + download TEXT NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0 +); + +-- +migrate down +DROP TABLE songs; \ No newline at end of file diff --git a/shard.lock b/shard.lock index e16e1e6..6048a4b 100644 --- a/shard.lock +++ b/shard.lock @@ -1,5 +1,9 @@ version: 2.0 shards: + crystagiri: + git: https://github.com/madeindjs/crystagiri.git + version: 0.3.5 + db: git: https://github.com/crystal-lang/crystal-db.git version: 0.6.0 diff --git a/shard.yml b/shard.yml index 7a3fc8a..5b04a56 100644 --- a/shard.yml +++ b/shard.yml @@ -20,6 +20,8 @@ dependencies: toml: github: crystal-community/toml.cr branch: master + crystagiri: + github: madeindjs/crystagiri crystal: 1.6.2 diff --git a/src/crystal-gauntlet.cr b/src/crystal-gauntlet.cr index 29cd383..3afc59b 100644 --- a/src/crystal-gauntlet.cr +++ b/src/crystal-gauntlet.cr @@ -11,6 +11,7 @@ require "./lib/format" require "./lib/accounts" require "./lib/gjp" require "./lib/clean" +require "./lib/songs" Dotenv.load diff --git a/src/endpoints/levels/downloadLevel.cr b/src/endpoints/levels/downloadLevel.cr index dbabc14..4397aad 100644 --- a/src/endpoints/levels/downloadLevel.cr +++ b/src/endpoints/levels/downloadLevel.cr @@ -69,10 +69,11 @@ CrystalGauntlet.endpoints["/downloadGJLevel22.php"] = ->(body : String): String 9 => difficulty ? difficulty.to_star_difficulty : 0, # 0=N/A 10=EASY 20=NORMAL 30=HARD 40=HARDER 50=INSANE 50=AUTO 50=DEMON 10 => downloads, 11 => 1, - 12 => song_id < 50 ? song_id : 0, + 12 => !Songs.is_custom_song(song_id) ? song_id : 0, 13 => game_version, 14 => likes, 17 => difficulty && difficulty.demon?, + # 0 for n/a, 10 for easy, 20, for medium, ... 43 => (demon_difficulty || DemonDifficulty::Hard).to_demon_difficulty, 25 => difficulty && difficulty.auto?, 18 => stars || 0, @@ -84,7 +85,7 @@ CrystalGauntlet.endpoints["/downloadGJLevel22.php"] = ->(body : String): String 31 => two_player, 28 => "1", 29 => "1", - 35 => song_id >= 50 ? song_id : 0, + 35 => Songs.is_custom_song(song_id) ? song_id : 0, 36 => extra_data, 37 => coins, 38 => rated_coins, @@ -93,7 +94,6 @@ CrystalGauntlet.endpoints["/downloadGJLevel22.php"] = ->(body : String): String 47 => 2, 40 => has_ldm, 27 => xor_pass, - # 0 for n/a, 10 for easy, 20, for medium, ... }) if params.has_key?("extras") diff --git a/src/endpoints/levels/getLevels.cr b/src/endpoints/levels/getLevels.cr index 29abd56..65a436a 100644 --- a/src/endpoints/levels/getLevels.cr +++ b/src/endpoints/levels/getLevels.cr @@ -131,7 +131,7 @@ CrystalGauntlet.endpoints["/getGJLevels21.php"] = ->(body : String): String { # todo: search query where_str = "where (#{queryParams.join(") and (")})" - query_base = "from levels join users on levels.user_id = users.id #{where_str} order by #{order}" + query_base = "from levels join users on levels.user_id = users.id left join songs on levels.song_id = songs.id #{where_str} order by #{order}" puts query_base @@ -143,7 +143,7 @@ CrystalGauntlet.endpoints["/getGJLevels21.php"] = ->(body : String): String { hash_data = [] of Tuple(Int32, Int32, Bool) - DATABASE.query "select levels.id, levels.name, levels.user_id, levels.description, levels.original, levels.game_version, levels.requested_stars, levels.version, levels.song_id, levels.length, levels.objects, levels.coins, levels.has_ldm, levels.two_player, levels.downloads, levels.likes, levels.difficulty, levels.community_difficulty, levels.demon_difficulty, levels.stars, levels.featured, levels.epic, levels.rated_coins, users.username, users.udid, users.account_id, users.registered #{query_base} limit #{levels_per_page} offset #{page_offset}" do |rs| + DATABASE.query "select levels.id, levels.name, levels.user_id, levels.description, levels.original, levels.game_version, levels.requested_stars, levels.version, levels.song_id, levels.length, levels.objects, levels.coins, levels.has_ldm, levels.two_player, levels.downloads, levels.likes, levels.difficulty, levels.community_difficulty, levels.demon_difficulty, levels.stars, levels.featured, levels.epic, levels.rated_coins, users.username, users.udid, users.account_id, users.registered, songs.name, songs.author_id, songs.author_name, songs.size, songs.disabled, songs.download #{query_base} limit #{levels_per_page} offset #{page_offset}" do |rs| rs.each do id = rs.read(Int32) name = rs.read(String) @@ -178,8 +178,15 @@ CrystalGauntlet.endpoints["/getGJLevels21.php"] = ->(body : String): String { user_account_id = rs.read(Int32 | Nil) user_registered = rs.read(Bool) + song_name = rs.read(String | Nil) + song_author_id = rs.read(Int32 | Nil) + song_author_name = rs.read(String | Nil) + song_size = rs.read(Int32 | Nil) + song_disabled = rs.read(Int32 | Nil) + song_download = rs.read(String | Nil) + # https://github.com/Cvolton/GMDprivateServer/blob/master/incl/levels/getGJLevels.php#L266 - results << CrystalGauntlet::Format.fmt_hash({ + results << Format.fmt_hash({ 1 => id, 2 => name, 5 => version, @@ -187,10 +194,11 @@ CrystalGauntlet.endpoints["/getGJLevels21.php"] = ->(body : String): String { 8 => 10, 9 => difficulty ? difficulty.to_star_difficulty : 0, # 0=N/A 10=EASY 20=NORMAL 30=HARD 40=HARDER 50=INSANE 50=AUTO 50=DEMON 10 => downloads, - 12 => song_id < 50 ? song_id : 0, + 12 => !Songs.is_custom_song(song_id) ? song_id : 0, 13 => game_version, 14 => likes, 17 => difficulty && difficulty.demon?, + # 0 for n/a, 10 for easy, 20, for medium, ... 43 => (demon_difficulty || DemonDifficulty::Hard).to_demon_difficulty, 25 => difficulty && difficulty.auto?, 18 => stars || 0, @@ -207,11 +215,25 @@ CrystalGauntlet.endpoints["/getGJLevels21.php"] = ->(body : String): String { 46 => 1, 47 => 2, 40 => has_ldm, - 35 => song_id >= 50 ? song_id : 0, # 0 for n/a, 10 for easy, 20, for medium, ... + 35 => Songs.is_custom_song(song_id) ? song_id : 0, }) users << "#{user_id}:#{user_username}:#{user_registered ? user_account_id : user_udid}" + if Songs.is_custom_song(song_id) && song_disabled == 0 + songs << Format.fmt_song({ + 1 => song_id, + 2 => song_name, + 3 => song_author_id, + 4 => song_author_name, + 5 => song_size.not_nil! / (1000 * 1000), + 6 => "", + 10 => song_download, + 7 => "", + 8 => "1" + }) + end + hash_data << {id, stars || 0, rated_coins} end end @@ -219,7 +241,7 @@ CrystalGauntlet.endpoints["/getGJLevels21.php"] = ->(body : String): String { # `${amount}:${offset}:${levelsPerPage}` searchMeta = "#{level_count}:#{page_offset}:#{levels_per_page}" - res = [results.join("|"), users.join("|"), songs.join("|"), searchMeta, CrystalGauntlet::Hashes.gen_multi(hash_data)].join("#") + res = [results.join("|"), users.join("|"), songs.join("~:~"), searchMeta, CrystalGauntlet::Hashes.gen_multi(hash_data)].join("#") puts res res diff --git a/src/endpoints/songs/getSongInfo.cr b/src/endpoints/songs/getSongInfo.cr new file mode 100644 index 0000000..8555b1b --- /dev/null +++ b/src/endpoints/songs/getSongInfo.cr @@ -0,0 +1,87 @@ +require "uri" +require "crystagiri" +require "http/client" +require "digest/sha256" + +include CrystalGauntlet + +NEWGROUNDS_AUDIO_URL_REGEX = /(?(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + song_id = params["songID"].to_i32 + + DATABASE.query("select name, author_id, author_name, size, download, disabled from songs where id = ?", song_id) do |rs| + if rs.move_next + song_name = rs.read(String) + author_id = rs.read(Int32) + author_name = rs.read(String) + size = rs.read(Int32) + download = rs.read(String) + disabled = rs.read(Int32) + + if disabled == 1 + return "-2" + end + + return Format.fmt_song({ + 1 => song_id, + 2 => song_name, + 3 => author_id, + 4 => author_name, + 5 => size / (1000 * 1000), + 6 => "", + 10 => download, + 7 => "", + 8 => "0" + }) + end + end + + if Songs.is_reuploaded_song(song_id) + # todo + "-1" + else + # todo: maybe use yt-dlp? for other sources too + doc = Crystagiri::HTML.from_url "https://www.newgrounds.com/audio/listen/#{song_id}" + + song_name = (doc.css("title") { |d| })[0].content + song_artist = (doc.css(".item-details-main > h4 > a") { |d| d })[0].content + song_url_str = (doc.css("script") { |d| }) + .map { |d| d.node.to_s.match(NEWGROUNDS_AUDIO_URL_REGEX) } + .reduce { |acc, d| acc || d } + .not_nil![1] + + # todo: proxy locally + song_url = unescape_string(song_url_str).split("?")[0].sub("https://", "http://") + + # todo: consider hashes? + size = 0 + + HTTP::Client.head(song_url) do |response| + size = response.headers["content-length"].to_i + end + + author_id = 9 # todo: what is this needed for? + + DATABASE.exec("insert into songs (id, name, author_id, author_name, size, download) values (?, ?, ?, ?, ?, ?)", song_id, song_name, author_id, song_artist, size, song_url) + + return Format.fmt_song({ + 1 => song_id, + 2 => song_name, + 3 => author_id, + 4 => song_artist, + 5 => size / (1000 * 1000), + 6 => "", + 10 => song_url, + 7 => "", + 8 => "0" + }) + end +} diff --git a/src/lib/clean.cr b/src/lib/clean.cr index 9f92108..543ee5e 100644 --- a/src/lib/clean.cr +++ b/src/lib/clean.cr @@ -7,7 +7,7 @@ module CrystalGauntlet::Clean # these are just the ones commonly used in response formatting # i'm unsure if any other ones should be added, so for the time # being i'll just keep it as is - str.gsub(/[:\|~#\(\)\0\n]/, "") + str.gsub(/[:\|~#\(\)\0\n~]/, "") end # for descriptions & similar diff --git a/src/lib/format.cr b/src/lib/format.cr index e1dc3a6..2d4d9d2 100644 --- a/src/lib/format.cr +++ b/src/lib/format.cr @@ -15,6 +15,10 @@ module CrystalGauntlet::Format def fmt_hash(hash) : String hash.map_with_index{ |(i, v)| "#{i}:#{fmt_value(v)}" }.join(":") end + + def fmt_song(hash) : String + hash.map_with_index{ |(i, v)| "#{i}~|~#{fmt_value(v)}" }.join("~|~") + end end module CrystalGauntlet::GDBase64 diff --git a/src/lib/songs.cr b/src/lib/songs.cr new file mode 100644 index 0000000..f187550 --- /dev/null +++ b/src/lib/songs.cr @@ -0,0 +1,14 @@ +include CrystalGauntlet + +module CrystalGauntlet::Songs + extend self + + def is_custom_song(id) + id >= 50 + end + + def is_reuploaded_song(id) + # todo: make configurable + id >= 5000000 + end +end