bms_table/lib.rs
1//! BMS difficulty table fetching and parsing
2//!
3//! Provides building a complete BMS difficulty table data structure from a web page or a header JSON, covering the header, courses, trophies, and chart items.
4//! Combined with feature flags, it implements network fetching and HTML parsing, suitable for CLI tools, server programs, or data-processing pipelines.
5//!
6//! # Feature overview
7//!
8//! - Parse header JSON into [`BmsTableHeader`], preserving unrecognized fields in `extra` for forward compatibility;
9//! - Parse chart data into [`BmsTableData`], supporting a plain array of [`ChartItem`] structure;
10//! - Courses automatically convert `md5`/`sha256` lists into chart items, filling missing `level` with "0";
11//! - Extract the header JSON URL from HTML `<meta name="bmstable">`;
12//! - One-stop network fetching APIs (web page → header JSON → chart data);
13//! - Support fetching a list of difficulty tables into [`BmsTableList`]. [An example source page](https://darksabun.club/table/tablelist.html).
14//!
15//! # Feature flags
16//!
17//! - `serde`: enable serialization/deserialization support for types (enabled by default).
18//! - `scraper`: enable HTML parsing and bmstable header URL extraction (enabled by default; implicitly enabled by `reqwest`).
19//! - `reqwest`: enable the network fetching implementation (enabled by default; requires the `tokio` runtime).
20//!
21//! # Quick start (network fetching)
22//!
23//! ```rust,no_run
24//! # #[tokio::main]
25//! # #[cfg(feature = "reqwest")]
26//! # async fn main() -> anyhow::Result<()> {
27//! use bms_table::fetch::reqwest::Fetcher;
28//!
29//! let fetcher = Fetcher::lenient()?;
30//! let table = fetcher.fetch_table("https://stellabms.xyz/sl/table.html").await?.table;
31//! println!("{}: {} charts", table.header.name, table.data.charts.len());
32//! # Ok(())
33//! # }
34//! #
35//! # #[cfg(not(feature = "reqwest"))]
36//! # fn main() {}
37//! ```
38//!
39//! # Offline usage (parse JSON directly)
40//!
41//! ```rust
42//! # #[cfg(feature = "serde")]
43//! # fn main() -> anyhow::Result<()> {
44//! use bms_table::{BmsTable, BmsTableHeader, BmsTableData};
45//!
46//! let header_json = r#"{ "name": "Test", "symbol": "t", "data_url": "charts.json", "course": [], "level_order": [] }"#;
47//! let data_json = r#"[]"#;
48//! let header: BmsTableHeader = serde_json::from_str(header_json)?;
49//! let data: BmsTableData = serde_json::from_str(data_json)?;
50//! let _table = BmsTable { header, data };
51//! # Ok(())
52//! # }
53//! # #[cfg(not(feature = "serde"))]
54//! # fn main() {}
55//! ```
56//!
57//! # Example: fetch table list
58//!
59//! ```rust,no_run
60//! # #[tokio::main]
61//! # #[cfg(feature = "reqwest")]
62//! # async fn main() -> anyhow::Result<()> {
63//! use bms_table::fetch::reqwest::Fetcher;
64//! let fetcher = Fetcher::lenient()?;
65//! let listes = fetcher.fetch_table_list("https://example.com/table_list.json").await?.tables;
66//! assert!(!listes.is_empty());
67//! # Ok(())
68//! # }
69//! # #[cfg(not(feature = "reqwest"))]
70//! # fn main() {}
71//! ```
72//!
73//! Hint: enabling the `reqwest` feature implicitly enables `scraper` to support locating the bmstable header URL from page content.
74
75#![warn(missing_docs)]
76#![warn(clippy::must_use_candidate)]
77#![deny(rustdoc::broken_intra_doc_links)]
78#![cfg_attr(docsrs, feature(doc_cfg))]
79
80pub mod de;
81pub mod fetch;
82
83#[cfg(feature = "serde")]
84use serde::{Deserialize, Serialize};
85#[cfg(feature = "serde")]
86use serde_json::Value;
87#[cfg(feature = "serde")]
88use std::collections::BTreeMap;
89
90#[cfg(feature = "serde")]
91use crate::de::{de_numstring, deserialize_course_groups, deserialize_level_order};
92
93/// Top-level BMS difficulty table data structure.
94///
95/// Packs header metadata and chart data together to simplify passing and use in applications.
96#[derive(Debug, Clone, PartialEq)]
97#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
98pub struct BmsTable {
99 /// Header information and extra fields
100 pub header: BmsTableHeader,
101 /// Table data containing the chart list
102 pub data: BmsTableData,
103}
104
105/// BMS header information.
106///
107/// Strictly parses common fields and preserves unrecognized fields in `extra` for forward compatibility.
108#[derive(Debug, Clone, PartialEq)]
109#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
110pub struct BmsTableHeader {
111 /// Table name, e.g. "Satellite"
112 pub name: String,
113 /// Table symbol, e.g. "sl"
114 pub symbol: String,
115 /// URL of chart data file (preserves the original string from header JSON)
116 pub data_url: String,
117 /// Course information as an array of course groups
118 #[cfg_attr(
119 feature = "serde",
120 serde(default, deserialize_with = "deserialize_course_groups")
121 )]
122 pub course: Vec<Vec<CourseInfo>>,
123 /// Difficulty level order containing numbers and strings
124 #[cfg_attr(
125 feature = "serde",
126 serde(default, deserialize_with = "deserialize_level_order")
127 )]
128 pub level_order: Vec<String>,
129 /// Extra data (unrecognized fields from header JSON)
130 #[cfg(feature = "serde")]
131 #[cfg_attr(feature = "serde", serde(flatten))]
132 pub extra: BTreeMap<String, Value>,
133}
134
135/// BMS table data.
136///
137/// Contains only the chart array. Parsing supports both a plain array and `{ charts: [...] }` input forms.
138#[derive(Debug, Clone, PartialEq, Eq)]
139#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
140#[cfg_attr(feature = "serde", serde(transparent))]
141pub struct BmsTableData {
142 /// Charts
143 pub charts: Vec<ChartItem>,
144}
145
146/// Course information.
147///
148/// Describes a course's name, constraints, trophies and chart set. During parsing, `md5`/`sha256` lists are automatically converted into `ChartItem`s, and charts missing `level` are filled with default value `"0"`.
149#[derive(Debug, Clone, PartialEq)]
150#[cfg_attr(feature = "serde", derive(Serialize))]
151pub struct CourseInfo {
152 /// Course name, e.g. "Satellite Skill Analyzer 2nd sl0"
153 pub name: String,
154 /// Constraint list, e.g. ["`grade_mirror`", "`gauge_lr2`", "ln"]
155 #[cfg_attr(feature = "serde", serde(default))]
156 pub constraint: Vec<String>,
157 /// List of trophies, defining requirements for different ranks
158 #[cfg_attr(feature = "serde", serde(default))]
159 pub trophy: Vec<Trophy>,
160 /// List of charts included in the course
161 #[cfg_attr(feature = "serde", serde(default))]
162 pub charts: Vec<ChartItem>,
163}
164
165/// Chart data item.
166///
167/// Describes metadata and resource links for a single BMS file.
168#[derive(Debug, Clone, PartialEq, Eq)]
169#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
170pub struct ChartItem {
171 /// Difficulty level, e.g. "0"
172 #[cfg_attr(feature = "serde", serde(default, deserialize_with = "de_numstring"))]
173 pub level: String,
174 /// MD5 hash of the file
175 pub md5: Option<String>,
176 /// SHA256 hash of the file
177 pub sha256: Option<String>,
178 /// Song title
179 pub title: Option<String>,
180 /// Song subtitle
181 pub subtitle: Option<String>,
182 /// Artist name
183 pub artist: Option<String>,
184 /// Song sub-artist
185 pub subartist: Option<String>,
186 /// File download URL
187 pub url: Option<String>,
188 /// Differential file download URL (optional)
189 pub url_diff: Option<String>,
190 /// Extra data
191 #[cfg(feature = "serde")]
192 #[cfg_attr(feature = "serde", serde(flatten))]
193 pub extra: BTreeMap<String, Value>,
194}
195
196/// Trophy information.
197///
198/// Defines conditions to achieve specific trophies, including maximum miss rate and minimum score rate.
199#[derive(Debug, Clone, PartialEq)]
200#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
201pub struct Trophy {
202 /// Trophy name, e.g. "silvermedal" or "goldmedal"
203 pub name: String,
204 /// Maximum miss rate (percent), e.g. 5.0 means at most 5% miss rate
205 pub missrate: f64,
206 /// Minimum score rate (percent), e.g. 70.0 means at least 70% score rate
207 pub scorerate: f64,
208}
209
210/// Complete set of original JSON strings.
211#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
212pub struct BmsTableRaw {
213 /// Full URL of the header JSON
214 #[cfg(feature = "scraper")]
215 pub header_json_url: url::Url,
216 /// Raw header JSON string
217 pub header_raw: String,
218 /// Full URL of the chart data JSON
219 #[cfg(feature = "scraper")]
220 pub data_json_url: url::Url,
221 /// Raw chart data JSON string
222 pub data_raw: String,
223}
224
225/// BMS difficulty table list item.
226///
227/// Represents the basic information of a difficulty table in a list. Only `name`, `symbol`, and `url` are required; other fields such as `tag1`, `tag2`, `comment`, `date`, `state`, and `tag_order` are collected into `extra`.
228#[derive(Debug, Clone, PartialEq, Eq)]
229#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
230pub struct BmsTableInfo {
231 /// Table name, e.g. ".WAS Difficulty Table"
232 pub name: String,
233 /// Table symbol, e.g. "." or "[F]"
234 pub symbol: String,
235 /// Table URL (as a full `url::Url` type)
236 #[cfg(feature = "scraper")]
237 pub url: url::Url,
238 /// Table URL (as a full `url::Url` type)
239 #[cfg(not(feature = "scraper"))]
240 pub url: String,
241 /// Extra fields collection (stores all data except required fields)
242 #[cfg(feature = "serde")]
243 #[cfg_attr(feature = "serde", serde(flatten))]
244 pub extra: BTreeMap<String, Value>,
245}
246
247/// Wrapper type for the list of BMS difficulty tables.
248///
249/// Transparently serialized as an array: serialization/deserialization behaves the same as the internal `Vec<BmsTableInfo>`, resulting in a JSON array rather than an object.
250#[derive(Debug, Clone, PartialEq, Eq)]
251#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
252#[cfg_attr(feature = "serde", serde(transparent))]
253pub struct BmsTableList {
254 /// List of entries
255 pub listes: Vec<BmsTableInfo>,
256}