mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 23:12:20 +08:00
Minor changes
This commit is contained in:
183
src/router/custom_song/audio.rs
Normal file
183
src/router/custom_song/audio.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use std::num::{NonZeroU8, NonZeroU32};
|
||||
use symphonia::core::codecs::CodecParameters;
|
||||
use symphonia::core::codecs::audio::AudioDecoderOptions;
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::TrackType;
|
||||
use symphonia::core::formats::probe::Hint;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use vorbis_rs::{VorbisBitrateManagementStrategy, VorbisEncoderBuilder};
|
||||
|
||||
use super::{DEFAULT_PREVIEW_LENGTH_SEC, PREVIEW_FADE_SEC};
|
||||
|
||||
// The whole audio pipeline runs in-process: symphonia (pure Rust) decodes and
|
||||
// validates uploads, vorbis_rs (libvorbis compiled into the binary - a library
|
||||
// call, like rusqlite's bundled sqlite) encodes. No external processes are
|
||||
// ever spawned and nothing needs to be on PATH.
|
||||
//
|
||||
// - ogg-vorbis uploads are stored AS-IS once they prove decodable, so the
|
||||
// served play cue's md5 is stable across servers (export/import keeps it)
|
||||
// - mp3/wav uploads are transcoded to ogg-vorbis once, at upload time
|
||||
// - the select cue (menu preview) is cut + faded in samples and re-encoded
|
||||
// Encodes keep the source sample rate and use a fixed Ogg stream serial, so
|
||||
// they're deterministic: the same input always produces the same bytes
|
||||
|
||||
pub struct Cue {
|
||||
pub bytes: Vec<u8>,
|
||||
pub md5: String,
|
||||
pub duration_sec: f64
|
||||
}
|
||||
|
||||
// ~ffmpeg's -q:a 6
|
||||
const ENCODE_QUALITY: f32 = 0.6;
|
||||
// "SIF2" - fixed so encoding is deterministic
|
||||
const STREAM_SERIAL: i32 = 0x53494632;
|
||||
const ENCODE_BLOCK_FRAMES: usize = 65536;
|
||||
|
||||
struct DecodedAudio {
|
||||
// Planar f32, one Vec per channel. Channels past the first two are dropped
|
||||
channels: Vec<Vec<f32>>,
|
||||
sample_rate: u32
|
||||
}
|
||||
|
||||
impl DecodedAudio {
|
||||
fn frames(&self) -> usize {
|
||||
self.channels[0].len()
|
||||
}
|
||||
fn duration(&self) -> f64 {
|
||||
self.frames() as f64 / self.sample_rate as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ogg_vorbis(bytes: &[u8]) -> bool {
|
||||
// "OggS" capture pattern + the "\x01vorbis" identification header on the first page
|
||||
bytes.starts_with(b"OggS") && bytes.len() > 64 && bytes[..64].windows(7).any(|w| w == b"\x01vorbis")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Result<DecodedAudio, String> {
|
||||
let stream = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.to_vec())), Default::default());
|
||||
let mut format = symphonia::default::get_probe()
|
||||
.probe(&Hint::new(), stream, Default::default(), Default::default())
|
||||
.map_err(|_| String::from("Could not read audio file (expected ogg vorbis, mp3 or wav)"))?;
|
||||
|
||||
let track = format.default_track(TrackType::Audio).ok_or(String::from("Audio file has no audio track"))?;
|
||||
let track_id = track.id;
|
||||
let Some(CodecParameters::Audio(params)) = track.codec_params.clone() else {
|
||||
return Err(String::from("Audio file has no audio track"));
|
||||
};
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make_audio_decoder(¶ms, &AudioDecoderOptions::default())
|
||||
.map_err(|_| String::from("Could not read audio file (expected ogg vorbis, mp3 or wav)"))?;
|
||||
|
||||
let mut channels: Vec<Vec<f32>> = Vec::new();
|
||||
let mut sample_rate = 0;
|
||||
let mut interleaved: Vec<f32> = Vec::new();
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(Some(packet)) => packet,
|
||||
Ok(None) => break,
|
||||
Err(SymphoniaError::ResetRequired) => break,
|
||||
Err(_) => return Err(String::from("Audio file is corrupt or truncated"))
|
||||
};
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(decoded) => decoded,
|
||||
// Decoders treat a bad packet as recoverable; skip it like they do
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(_) => return Err(String::from("Audio file is corrupt or truncated"))
|
||||
};
|
||||
let count = decoded.spec().channels().count();
|
||||
if channels.is_empty() {
|
||||
sample_rate = decoded.spec().rate();
|
||||
channels = vec![Vec::new(); std::cmp::min(count, 2)];
|
||||
}
|
||||
decoded.copy_to_vec_interleaved(&mut interleaved);
|
||||
for (i, samples) in channels.iter_mut().enumerate() {
|
||||
samples.extend(interleaved.iter().skip(i).step_by(count));
|
||||
}
|
||||
}
|
||||
|
||||
if channels.is_empty() || channels[0].is_empty() || sample_rate == 0 {
|
||||
return Err(String::from("Audio file is corrupt or truncated"));
|
||||
}
|
||||
Ok(DecodedAudio { channels, sample_rate })
|
||||
}
|
||||
|
||||
fn encode(channels: &[&[f32]], sample_rate: u32) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut builder = VorbisEncoderBuilder::new_with_serial(
|
||||
NonZeroU32::new(sample_rate).ok_or(String::from("Audio file is corrupt or truncated"))?,
|
||||
NonZeroU8::new(channels.len() as u8).unwrap(),
|
||||
&mut out,
|
||||
STREAM_SERIAL
|
||||
);
|
||||
builder.bitrate_management_strategy(VorbisBitrateManagementStrategy::QualityVbr {
|
||||
target_quality: ENCODE_QUALITY
|
||||
});
|
||||
let mut encoder = builder.build().map_err(|e| format!("Audio encode failed: {}", e))?;
|
||||
|
||||
let frames = channels[0].len();
|
||||
let mut i = 0;
|
||||
while i < frames {
|
||||
let end = std::cmp::min(i + ENCODE_BLOCK_FRAMES, frames);
|
||||
let block: Vec<&[f32]> = channels.iter().map(|samples| &samples[i..end]).collect();
|
||||
encoder.encode_audio_block(&block).map_err(|e| format!("Audio encode failed: {}", e))?;
|
||||
i = end;
|
||||
}
|
||||
encoder.finish().map_err(|e| format!("Audio encode failed: {}", e))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn cue(bytes: Vec<u8>, duration_sec: f64) -> Cue {
|
||||
Cue {
|
||||
md5: format!("{:x}", md5::compute(&bytes)),
|
||||
bytes,
|
||||
duration_sec
|
||||
}
|
||||
}
|
||||
|
||||
// The play cue is the full track, the select cue is a preview cut with short
|
||||
// fades. Both are stored content-addressed by the md5 of the final ogg bytes -
|
||||
// the client validates md5(file) against the value served in the catalog
|
||||
pub fn process(bytes: &[u8], preview_start_sec: Option<f64>, preview_length_sec: Option<f64>) -> Result<(Cue, Cue), String> {
|
||||
let audio = decode(bytes)?;
|
||||
let duration = audio.duration();
|
||||
if duration <= 1.0 {
|
||||
return Err(String::from("Audio track is too short"));
|
||||
}
|
||||
|
||||
let planar: Vec<&[f32]> = audio.channels.iter().map(|samples| samples.as_slice()).collect();
|
||||
let play = if is_ogg_vorbis(bytes) {
|
||||
// Already ogg-vorbis and proven decodable: keep the exact bytes
|
||||
cue(bytes.to_vec(), duration)
|
||||
} else {
|
||||
cue(encode(&planar, audio.sample_rate)?, duration)
|
||||
};
|
||||
|
||||
// Preview defaults: start 30% into the track, 30 seconds long
|
||||
let mut start = preview_start_sec.unwrap_or(duration * 0.3);
|
||||
if start < 0.0 || start >= duration {
|
||||
start = duration * 0.3;
|
||||
}
|
||||
let length = preview_length_sec.unwrap_or(DEFAULT_PREVIEW_LENGTH_SEC).clamp(1.0, duration - start);
|
||||
let start_frame = (start * audio.sample_rate as f64) as usize;
|
||||
let end_frame = std::cmp::min(start_frame + (length * audio.sample_rate as f64) as usize, audio.frames());
|
||||
|
||||
let fade_frames = (PREVIEW_FADE_SEC * audio.sample_rate as f64) as usize;
|
||||
let mut segment: Vec<Vec<f32>> = audio.channels.iter().map(|samples| samples[start_frame..end_frame].to_vec()).collect();
|
||||
let frames = end_frame - start_frame;
|
||||
if frames > fade_frames * 2 {
|
||||
for samples in segment.iter_mut() {
|
||||
for i in 0..fade_frames {
|
||||
let gain = i as f32 / fade_frames as f32;
|
||||
samples[i] *= gain;
|
||||
samples[frames - 1 - i] *= gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
let planar: Vec<&[f32]> = segment.iter().map(|samples| samples.as_slice()).collect();
|
||||
let select = cue(encode(&planar, audio.sample_rate)?, frames as f64 / audio.sample_rate as f64);
|
||||
|
||||
Ok((play, select))
|
||||
}
|
||||
266
src/router/custom_song/chart.rs
Normal file
266
src/router/custom_song/chart.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use jzon::{object, JsonValue};
|
||||
|
||||
// Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position})
|
||||
// into the SIF2 chart JSON the client deserializes into NoteData.
|
||||
//
|
||||
// Mapping rules:
|
||||
// - line = position - 1 (both are right-to-left)
|
||||
// - effect 1 (and 2, the "parallel" marker) -> type 1 (tap)
|
||||
// - effect 3 (hold) -> head note (type 1) at timing_sec plus a SYNTHESIZED tail note
|
||||
// (type 1, same line) at timing_sec + effect_value, linked through parent/child ids
|
||||
// - effect 4 (star/token) -> type 3
|
||||
// - effect 11/12/13 (swing) and anything unknown -> plain type 1 tap for v1.
|
||||
// Slider chains are a later feature.
|
||||
// - notes_attribute / notes_level are dropped (SIF2 has no per-note attribute)
|
||||
// - ids are sequential from 1 in time order. num is the spawn group: the dummy
|
||||
// header occupies 100, real groups count up from 101, and notes that hit
|
||||
// simultaneously (equal timing_sec, which covers SIF1 effect 2 pairs) share one num.
|
||||
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
|
||||
// deserializes it verbatim.
|
||||
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
|
||||
// line (the game counts a same-lane hold as one combo for the chain)
|
||||
|
||||
struct WorkNote {
|
||||
time: f64,
|
||||
line: i64,
|
||||
kind: i64,
|
||||
// Index into the work list of the hold head this tail belongs to
|
||||
head: Option<usize>
|
||||
}
|
||||
|
||||
fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64), String> {
|
||||
let timing = data["timing_sec"].as_f64().ok_or(format!("Note {}: missing timing_sec", index))?;
|
||||
let effect = data["effect"].as_i64().ok_or(format!("Note {}: missing effect", index))?;
|
||||
let effect_value = data["effect_value"].as_f64().unwrap_or(0.0);
|
||||
let position = data["position"].as_i64().ok_or(format!("Note {}: missing position", index))?;
|
||||
|
||||
if !(1..=9).contains(&position) {
|
||||
return Err(format!("Note {}: position {} is outside 1-9", index, position));
|
||||
}
|
||||
if timing < 0.0 {
|
||||
return Err(format!("Note {}: negative timing_sec {}", index, timing));
|
||||
}
|
||||
if effect == 3 && effect_value <= 0.0 {
|
||||
return Err(format!("Note {}: hold with effect_value {} (must be > 0)", index, effect_value));
|
||||
}
|
||||
|
||||
Ok((timing, effect, effect_value, position))
|
||||
}
|
||||
|
||||
// Returns the chart JSON and its max_combo_count (== the difficulty's full_combo)
|
||||
pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
if !beatmap.is_array() || beatmap.is_empty() {
|
||||
return Err(String::from("Chart is not a JSON array of notes"));
|
||||
}
|
||||
|
||||
let mut work: Vec<WorkNote> = Vec::new();
|
||||
for (i, data) in beatmap.members().enumerate() {
|
||||
let (timing, effect, effect_value, position) = parse_sif_note(data, i)?;
|
||||
|
||||
for other in beatmap.members().take(i) {
|
||||
if other["timing_sec"].as_f64() == Some(timing) && other["position"].as_i64() == Some(position) && other["effect"].as_i64() != Some(effect) {
|
||||
return Err(format!("Note {}: duplicate timing {} on position {} with a different effect", i, timing, position));
|
||||
}
|
||||
}
|
||||
|
||||
let head = work.len();
|
||||
work.push(WorkNote {
|
||||
time: timing,
|
||||
line: position - 1,
|
||||
kind: if effect == 4 { 3 } else { 1 },
|
||||
head: None
|
||||
});
|
||||
if effect == 3 {
|
||||
work.push(WorkNote {
|
||||
time: timing + effect_value,
|
||||
line: position - 1,
|
||||
kind: 1,
|
||||
head: Some(head)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sequential ids in time order. Stable sort keeps input order on ties
|
||||
let mut order: Vec<usize> = (0..work.len()).collect();
|
||||
order.sort_by(|a, b| work[*a].time.partial_cmp(&work[*b].time).unwrap());
|
||||
|
||||
let mut ids = vec![0i64; work.len()];
|
||||
let mut nums = vec![0i64; work.len()];
|
||||
let mut num = 100;
|
||||
let mut last_time = f64::NEG_INFINITY;
|
||||
for (i, index) in order.iter().enumerate() {
|
||||
ids[*index] = (i + 1) as i64;
|
||||
// Simultaneous notes share a spawn group
|
||||
if work[*index].time != last_time {
|
||||
num += 1;
|
||||
last_time = work[*index].time;
|
||||
}
|
||||
nums[*index] = num;
|
||||
}
|
||||
|
||||
let mut tail_of = vec![0usize; work.len()];
|
||||
for (i, note) in work.iter().enumerate() {
|
||||
if let Some(head) = note.head {
|
||||
tail_of[head] = i;
|
||||
}
|
||||
}
|
||||
|
||||
let mut notes = jzon::array![{
|
||||
"id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0,
|
||||
"parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0,
|
||||
"force_sync_group_id": 0
|
||||
}];
|
||||
let mut max_combo_count = 0;
|
||||
for index in order.iter() {
|
||||
let note = &work[*index];
|
||||
let tail = tail_of[*index];
|
||||
let is_head = tail != 0;
|
||||
|
||||
// Same-lane hold heads don't count toward the combo, their tail does
|
||||
if !(is_head && work[tail].line == note.line) {
|
||||
max_combo_count += 1;
|
||||
}
|
||||
|
||||
notes.push(object!{
|
||||
"id": ids[*index],
|
||||
"num": nums[*index],
|
||||
"line": note.line,
|
||||
"time": note.time,
|
||||
"type": note.kind,
|
||||
"parent_id": if let Some(head) = note.head { ids[head] } else { 0 },
|
||||
"child_id": if is_head { ids[tail] } else { 0 },
|
||||
"child_num": if is_head { nums[tail] } else { 0 },
|
||||
"child_line": if is_head { work[tail].line } else { 0 },
|
||||
"force_sync_group_id": 0
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
Ok((object!{
|
||||
"max_lane": 9,
|
||||
"sound_name": "",
|
||||
"max_combo_count": max_combo_count,
|
||||
"notes": notes
|
||||
}, max_combo_count))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sif_note(timing_sec: f64, position: i64, effect: i64, effect_value: f64) -> JsonValue {
|
||||
object!{
|
||||
"timing_sec": timing_sec,
|
||||
"notes_attribute": 1,
|
||||
"notes_level": 1,
|
||||
"effect": effect,
|
||||
"effect_value": effect_value,
|
||||
"position": position
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_taps() {
|
||||
let beatmap = jzon::array![
|
||||
sif_note(1.0, 1, 1, 2.0),
|
||||
sif_note(2.0, 5, 1, 2.0),
|
||||
sif_note(3.0, 9, 1, 2.0)
|
||||
];
|
||||
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||
|
||||
assert_eq!(combo, 3);
|
||||
assert_eq!(chart["max_combo_count"], 3);
|
||||
assert_eq!(chart["max_lane"], 9);
|
||||
assert_eq!(chart["notes"].len(), 4);
|
||||
// Dummy header is verbatim
|
||||
assert_eq!(chart["notes"][0]["id"], 0);
|
||||
assert_eq!(chart["notes"][0]["num"], 100);
|
||||
assert_eq!(chart["notes"][0]["type"], 0);
|
||||
// Real notes: sequential ids, monotonic nums, right-to-left lines
|
||||
assert_eq!(chart["notes"][1]["id"], 1);
|
||||
assert_eq!(chart["notes"][1]["num"], 101);
|
||||
assert_eq!(chart["notes"][1]["line"], 0);
|
||||
assert_eq!(chart["notes"][1]["type"], 1);
|
||||
assert_eq!(chart["notes"][2]["num"], 102);
|
||||
assert_eq!(chart["notes"][2]["line"], 4);
|
||||
assert_eq!(chart["notes"][3]["id"], 3);
|
||||
assert_eq!(chart["notes"][3]["num"], 103);
|
||||
assert_eq!(chart["notes"][3]["line"], 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hold_head_and_tail() {
|
||||
let beatmap = jzon::array![
|
||||
sif_note(1.0, 3, 3, 2.5)
|
||||
];
|
||||
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||
|
||||
// The synthesized same-lane tail counts, the head does not
|
||||
assert_eq!(combo, 1);
|
||||
assert_eq!(chart["notes"].len(), 3);
|
||||
let head = &chart["notes"][1];
|
||||
let tail = &chart["notes"][2];
|
||||
assert_eq!(head["id"], 1);
|
||||
assert_eq!(head["child_id"], 2);
|
||||
assert_eq!(head["child_num"], tail["num"].clone());
|
||||
assert_eq!(head["child_line"], 2);
|
||||
assert_eq!(head["parent_id"], 0);
|
||||
assert_eq!(tail["id"], 2);
|
||||
assert_eq!(tail["parent_id"], 1);
|
||||
assert_eq!(tail["child_id"], 0);
|
||||
assert_eq!(tail["line"], 2);
|
||||
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_pair() {
|
||||
let beatmap = jzon::array![
|
||||
sif_note(1.0, 2, 2, 2.0),
|
||||
sif_note(1.0, 8, 2, 2.0)
|
||||
];
|
||||
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||
|
||||
// Simultaneous notes share a spawn group and both count
|
||||
assert_eq!(combo, 2);
|
||||
assert_eq!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
|
||||
assert_eq!(chart["notes"][1]["type"], 1);
|
||||
assert_eq!(chart["notes"][2]["type"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed() {
|
||||
let beatmap = jzon::array![
|
||||
sif_note(1.0, 5, 1, 2.0), // tap
|
||||
sif_note(2.0, 3, 3, 1.5), // hold: head at 2.0, tail at 3.5
|
||||
sif_note(2.5, 7, 4, 0.0), // star
|
||||
sif_note(3.5, 1, 2, 2.0), // parallel with the hold tail
|
||||
sif_note(4.0, 9, 11, 0.0) // swing -> plain tap for v1
|
||||
];
|
||||
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||
|
||||
// 6 real notes, minus the same-lane hold head
|
||||
assert_eq!(combo, 5);
|
||||
assert_eq!(chart["notes"].len(), 7);
|
||||
// Time order: tap(1.0), head(2.0), star(2.5), tail(3.5), parallel(3.5), swing(4.0)
|
||||
assert_eq!(chart["notes"][2]["child_id"], 4);
|
||||
assert_eq!(chart["notes"][3]["type"], 3);
|
||||
assert_eq!(chart["notes"][4]["parent_id"], 2);
|
||||
// The tail and the parallel tap at 3.5 share a spawn group
|
||||
assert_eq!(chart["notes"][4]["num"], chart["notes"][5]["num"].clone());
|
||||
assert_eq!(chart["notes"][6]["type"], 1);
|
||||
// Ids stay sequential in time order
|
||||
for (i, data) in chart["notes"].members().enumerate() {
|
||||
assert_eq!(data["id"], i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_charts() {
|
||||
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
|
||||
assert!(transcode(&jzon::array![sif_note(1.0, 10, 1, 2.0)]).is_err());
|
||||
assert!(transcode(&jzon::array![sif_note(-1.0, 5, 1, 2.0)]).is_err());
|
||||
assert!(transcode(&jzon::array![sif_note(1.0, 5, 3, 0.0)]).is_err());
|
||||
assert!(transcode(&jzon::array![sif_note(1.0, 5, 1, 2.0), sif_note(1.0, 5, 3, 2.0)]).is_err());
|
||||
assert!(transcode(&jzon::object!{}).is_err());
|
||||
}
|
||||
|
||||
}
|
||||
90
src/router/custom_song/package.rs
Normal file
90
src/router/custom_song/package.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read, Seek, Write};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use super::{song_path, LEVEL_COUNT};
|
||||
|
||||
// Export packages carry the ORIGINAL upload artifacts. SIF1 is the canonical
|
||||
// interchange format - the transcoded NoteData charts are derived data and are
|
||||
// never exported. Importing a package on another ew server replays the exact
|
||||
// same upload pipeline the multipart form uses. Layout of the zip:
|
||||
// manifest.json upload metadata, same schema as the multipart fields
|
||||
// jacket original jacket image bytes (png/jpg)
|
||||
// audio original audio bytes (ogg/mp3/wav)
|
||||
// chart_{level}.json SIF1-schema charts, level 1..4 (only uploaded levels)
|
||||
// visibility/shared_with/downloads_disabled are per-server settings and are
|
||||
// deliberately NOT part of the package.
|
||||
|
||||
pub fn build(music_id: i64) -> Result<Vec<u8>, String> {
|
||||
// Songs uploaded before export support have no original artifacts on disk
|
||||
let manifest = fs::read(song_path(music_id, "original/manifest.json"))
|
||||
.map_err(|_| String::from("This song was uploaded before export support and can't be downloaded"))?;
|
||||
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = SimpleFileOptions::default();
|
||||
let mut add = |name: &str, bytes: &[u8]| -> Result<(), String> {
|
||||
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(bytes).map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
add("manifest.json", &manifest)?;
|
||||
for name in ["jacket", "audio"] {
|
||||
let bytes = fs::read(song_path(music_id, &format!("original/{}", name))).map_err(|e| e.to_string())?;
|
||||
add(name, &bytes)?;
|
||||
}
|
||||
for level in 1..=LEVEL_COUNT {
|
||||
let Ok(bytes) = fs::read(song_path(music_id, &format!("original/chart_{}.json", level))) else { continue; };
|
||||
add(&format!("chart_{}.json", level), &bytes)?;
|
||||
}
|
||||
|
||||
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||
}
|
||||
|
||||
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
|
||||
let mut file = archive.by_name(name).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).ok()?;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
// Expands a package into the same field map the upload form produces - the zip
|
||||
// contents map 1:1 onto the multipart fields, so an import is just an upload
|
||||
// with its fields sourced from the package. Fields already present in the map
|
||||
// that the package also carries are overwritten (the package's metadata wins);
|
||||
// visibility/shared_with/downloads_disabled aren't packaged and stay untouched
|
||||
pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(), String> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(package)).map_err(|_| String::from("Package is not a valid zip file"))?;
|
||||
|
||||
let manifest = read_entry(&mut archive, "manifest.json").ok_or(String::from("Package has no manifest.json"))?;
|
||||
let manifest = jzon::parse(&String::from_utf8_lossy(&manifest)).map_err(|_| String::from("Package manifest is not valid JSON"))?;
|
||||
if manifest["format"].as_i64() != Some(1) {
|
||||
return Err(String::from("Unsupported package format"));
|
||||
}
|
||||
|
||||
for key in ["name", "name_en", "short_name", "kana", "artist", "artist_en", "band_category", "attribute", "bpm", "preview_start_sec", "preview_length_sec"] {
|
||||
if !manifest[key].is_null() {
|
||||
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
|
||||
}
|
||||
}
|
||||
for data in manifest["levels"].members() {
|
||||
let Some(level) = data["level"].as_i64() else { continue; };
|
||||
if !data["level_number"].is_null() {
|
||||
fields.insert(format!("level_number_{}", level), data["level_number"].to_string().into_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fields.insert(String::from("jacket"), read_entry(&mut archive, "jacket").ok_or(String::from("Package has no jacket"))?);
|
||||
fields.insert(String::from("audio"), read_entry(&mut archive, "audio").ok_or(String::from("Package has no audio"))?);
|
||||
let mut has_chart = false;
|
||||
for level in 1..=LEVEL_COUNT {
|
||||
if let Some(chart) = read_entry(&mut archive, &format!("chart_{}.json", level)) {
|
||||
fields.insert(format!("chart_{}", level), chart);
|
||||
has_chart = true;
|
||||
}
|
||||
}
|
||||
if !has_chart {
|
||||
return Err(String::from("Package has no charts"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user