audiobook_server/
lib.rs

1use axum::headers::IfModifiedSince;
2use axum::response::Response;
3use axum::TypedHeader;
4use axum::{response::IntoResponse, routing::get, Router};
5use clap::Parser;
6use dotenv::dotenv;
7#[cfg(not(target_os = "linux"))]
8use hyper::server::{accept::Accept, conn::AddrIncoming};
9use hyper::{header, Method, StatusCode};
10use lazy_static::lazy_static;
11use mime::Mime;
12use sea_orm::{Database, DatabaseConnection};
13use serde::{Deserialize, Serialize};
14use std::env;
15#[cfg(not(target_os = "linux"))]
16use std::net::Ipv4Addr;
17use std::path::PathBuf;
18#[cfg(not(target_os = "linux"))]
19use std::pin::Pin;
20use std::str::FromStr;
21#[cfg(not(target_os = "linux"))]
22use std::task::{Context, Poll};
23
24use std::time::SystemTime;
25use std::{
26    net::{Ipv6Addr, SocketAddr},
27    sync::Arc,
28};
29use tera::Tera;
30use tokio::sync::Mutex;
31use tower_cookies::CookieManagerLayer;
32use tower_http::{
33    cors::{Any, CorsLayer},
34    services::ServeDir,
35};
36use tracing::{debug, info};
37
38mod auth;
39pub mod consts;
40mod database;
41pub mod entities;
42mod management;
43mod middleware;
44mod music;
45pub(crate) mod progress;
46pub mod tools;
47mod webui;
48
49lazy_static! {
50    pub static ref BUILD_TIME: SystemTime = {
51        let build_sec: i64 = env!("BUILD_TIME").parse().unwrap();
52        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(build_sec as u64)
53    };
54}
55
56#[derive(Serialize, Deserialize)]
57enum MathOp {
58    Add,
59    Sub,
60    Mul,
61    Div,
62}
63
64pub(crate) struct AppStats {
65    pub tera: Tera,
66    pub connections: AppConnections,
67    pub book_dir: PathBuf,
68}
69pub(crate) struct AppConnections {
70    pub db: DatabaseConnection,
71    pub redis: Mutex<redis::aio::Connection>,
72}
73impl AppConnections {
74    pub fn new(db: DatabaseConnection, redis: redis::aio::Connection) -> Self {
75        Self {
76            db,
77            redis: Mutex::new(redis),
78        }
79    }
80}
81type AppStat = Arc<AppStats>;
82#[derive(Debug, Parser)]
83pub struct Cli {
84    /// the redis url,start at "redis://"
85    #[clap(short, long, env = "REDIS_URL", default_value = "redis://localhost/0")]
86    redis: String,
87
88    /// the database url,start at "mysql://"
89    #[clap(
90        short,
91        long,
92        env = "DATABASE_URL",
93        default_value = "mysql://root:qiuqiu123@localhost/music_db"
94    )]
95    db: String,
96
97    #[clap(short, long, env = "PORT", default_value = "3000")]
98    port: u16,
99    /// the path store all books
100    #[clap(short, long, env = "BOOKS", default_value = "./books")]
101    book_dir: String,
102}
103
104pub fn init_log() {
105    // init tracing_subscriber
106    tracing_subscriber::fmt::SubscriberBuilder::default()
107        .with_env_filter(
108            tracing_subscriber::EnvFilter::builder()
109                .with_default_directive("audiobook_server=info".parse().unwrap())
110                .from_env_lossy(),
111        )
112        .with_ansi(true)
113        .init();
114}
115pub async fn init_mysql(db: &str) -> DatabaseConnection {
116    let db = Database::connect(db).await.unwrap();
117    db
118}
119pub async fn init_redis(redis: &str) -> redis::aio::Connection {
120    let redis = redis::Client::open(redis)
121        .unwrap()
122        .get_async_connection()
123        .await
124        .unwrap();
125    redis
126}
127
128pub async fn init_db(db: &str, redis: &str) -> (DatabaseConnection, redis::aio::Connection) {
129    (init_mysql(db).await, init_redis(redis).await)
130}
131
132async fn redirect(redirect_path: &str) -> impl IntoResponse {
133    // redirect to redirect_path
134    (
135        StatusCode::FOUND,
136        [(header::LOCATION, redirect_path.to_owned())],
137    )
138}
139fn setup_tera() -> Tera {
140    let mut tera = Tera::default();
141    let index = include_str!("../templates/index.tera");
142    let login = include_str!("../templates/login.tera");
143    let logout = include_str!("../templates/logout.tera");
144    let author_detail = include_str!("../templates/author_detail.tera");
145    let book_detail = include_str!("../templates/book_detail.tera");
146    let books = include_str!("../templates/books.tera");
147    let authors = include_str!("../templates/authors.tera");
148    let base = include_str!("../templates/base.tera");
149    let player = include_str!("../templates/player.tera");
150    let newplayer = include_str!("../templates/newplayer.tera");
151    let manager = include_str!("../templates/manager.tera");
152    let book_manager = include_str!("../templates/book_manager.tera");
153    let account_manager = include_str!("../templates/account_manager.tera");
154    let manager_base = include_str!("../templates/manager_base.tera");
155    let user_op = include_str!("../templates/user_op.tera");
156    let simple = include_str!("../templates/simple.tera");
157    tera.add_raw_templates([
158        ("index.tera", index),
159        ("login.tera", login),
160        ("logout.tera", logout),
161        ("author_detail.tera", author_detail),
162        ("book_detail.tera", book_detail),
163        ("books.tera", books),
164        ("authors.tera", authors),
165        ("base.tera", base),
166        ("player.tera", player),
167        ("newplayer.tera", newplayer),
168        ("manager.tera", manager),
169        ("book_manager.tera", book_manager),
170        ("account_manager.tera", account_manager),
171        ("manager_base.tera", manager_base),
172        ("user_op.tera", user_op),
173        ("simple.tera", simple),
174    ])
175    .unwrap();
176    tera
177}
178
179pub async fn app_main() -> eyre::Result<()> {
180    dotenv().ok();
181    init_log();
182    let cli = Cli::parse();
183    debug!("cli:{:?}", cli);
184
185    info!("redis url:{}", cli.redis);
186    info!("database url:{}", cli.db);
187    info!("starting server,connecting to database and redis");
188
189    info!("database connected");
190    let (db, redis) = init_db(&cli.db, &cli.redis).await;
191    let stat: AppStat = Arc::new(AppStats {
192        tera: setup_tera(),
193        connections: AppConnections::new(db, redis),
194        book_dir: PathBuf::from(cli.book_dir.clone()),
195    });
196    let fetch_book_router = Router::new()
197        .nest_service("/fetchbook", ServeDir::new(cli.book_dir))
198        .route_layer(axum::middleware::from_fn_with_state(
199            stat.clone(),
200            middleware::user_auth::user_auth,
201        ));
202
203    let app = Router::new()
204        .nest("/account", auth::route(stat.clone()))
205        .nest("/music", music::route(stat.clone()))
206        .nest("/progress", progress::route(stat.clone()))
207        .nest("/webui", webui::route(stat.clone()))
208        .nest("/management", management::route(stat.clone()))
209        .merge(fetch_book_router)
210        .route_layer(CookieManagerLayer::new()) // above route need login auth, so need cookie service
211        .route("/", get(|| async { redirect("/webui/index").await }))
212        .route(
213            "/css/style.css",
214            get(
215                |if_last_modified: Option<TypedHeader<axum::headers::IfModifiedSince>>| async move {
216                    let text = include_bytes!("../static/css/style.css");
217                    let text_type = TypedHeader(axum::headers::ContentType::from(
218                        Mime::from_str("text/css").unwrap(),
219                    ));
220
221                    cached_response(if_last_modified, text_type, text.to_vec())
222                },
223            ),
224        )
225        .route(
226            "/favicon.ico",
227            get(
228                |if_last_modified: Option<TypedHeader<axum::headers::IfModifiedSince>>| async move {
229                    let text = include_bytes!("../static/favicon.ico");
230                    let text_type = TypedHeader(axum::headers::ContentType::from(
231                        Mime::from_str("image/x-icon").unwrap(),
232                    ));
233                    cached_response(if_last_modified, text_type, text.to_vec())
234                },
235            ),
236        )
237        .route(
238            "/panda256.ico",
239            get(
240                |if_last_modified: Option<TypedHeader<axum::headers::IfModifiedSince>>| async move {
241                    let text = include_bytes!("../static/panda256.ico");
242                    let text_type = TypedHeader(axum::headers::ContentType::from(
243                        Mime::from_str("image/x-icon").unwrap(),
244                    ));
245                    cached_response(if_last_modified, text_type, text.to_vec())
246                },
247            ),
248        )
249        .route(
250            "/manifest.json",
251            get(
252                |if_last_modified: Option<TypedHeader<axum::headers::IfModifiedSince>>| async move {
253                    let text = include_bytes!("../static/manifest.json");
254                    let text_type = TypedHeader(axum::headers::ContentType::from(
255                        Mime::from_str("application/json").unwrap(),
256                    ));
257                    cached_response(if_last_modified, text_type, text.to_vec())
258                },
259            ),
260        )
261        .route_layer(
262            CorsLayer::new()
263                .allow_methods([Method::GET, Method::POST])
264                .allow_origin(Any),
265        )
266        .with_state(stat);
267    #[cfg(target_os = "linux")]
268    {
269        let addr6 = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), cli.port);
270        axum::Server::bind(&addr6)
271            .serve(app.into_make_service_with_connect_info::<SocketAddr>())
272            .await
273            .unwrap();
274    }
275    #[cfg(not(target_os = "linux"))]
276    {
277        let addr4 = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), cli.port);
278        let addr6 = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), cli.port);
279        let combined = CombinedAddr {
280            a: AddrIncoming::bind(&addr4).unwrap(),
281            b: AddrIncoming::bind(&addr6).unwrap(),
282        };
283        info!("server started at addrv4: {}", addr4);
284        info!("server started at addrv6: {}", addr6);
285        axum::Server::builder(combined)
286            .serve(app.into_make_service())
287            .await
288            .unwrap();
289    }
290
291    Ok(())
292}
293
294fn cached_response(
295    if_last_modified: Option<TypedHeader<IfModifiedSince>>,
296    text_type: TypedHeader<axum::headers::ContentType>,
297    text: Vec<u8>,
298) -> Response {
299    match if_last_modified {
300        Some(if_last_modified) => {
301            debug!("if_last_modified:{:?}", if_last_modified);
302            debug!("BUILD_TIME:{:?}", *BUILD_TIME);
303            if if_last_modified.is_modified(*BUILD_TIME) {
304                (
305                    StatusCode::OK,
306                    text_type,
307                    TypedHeader(axum::headers::LastModified::from(*BUILD_TIME)),
308                    text,
309                )
310                    .into_response()
311            } else {
312                // no modified
313                debug!("no modified");
314                (StatusCode::NOT_MODIFIED, "").into_response()
315            }
316        }
317        None => (
318            StatusCode::OK,
319            text_type,
320            TypedHeader(axum::headers::LastModified::from(*BUILD_TIME)),
321            text,
322        )
323            .into_response(),
324    }
325}
326#[cfg(not(target_os = "linux"))]
327
328struct CombinedAddr {
329    a: AddrIncoming,
330    b: AddrIncoming,
331}
332
333#[cfg(not(target_os = "linux"))]
334
335impl Accept for CombinedAddr {
336    type Conn = <AddrIncoming as Accept>::Conn;
337    type Error = <AddrIncoming as Accept>::Error;
338    fn poll_accept(
339        mut self: Pin<&mut Self>,
340        cx: &mut Context<'_>,
341    ) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
342        if let Poll::Ready(v) = Pin::new(&mut self.a).poll_accept(cx) {
343            return Poll::Ready(v);
344        }
345        if let Poll::Ready(v) = Pin::new(&mut self.b).poll_accept(cx) {
346            return Poll::Ready(v);
347        }
348        Poll::Pending
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use chrono::format::StrftimeItems;
355    use chrono::{DateTime, FixedOffset, Utc};
356
357    #[tokio::test]
358    async fn test_database() {
359        // let db = Database::connect("mysql://root:qiuqiu123@localhost/music_db")
360        //     .await
361        //     .unwrap();
362        // let account_admin = account::ActiveModel {
363        //     name: sea_orm::ActiveValue::Set("admin".to_string()),
364        //     password: sea_orm::ActiveValue::Set("123".to_string()),
365        //     ..Default::default()
366        // };
367        // Account::insert(account_admin).exec(&db).await.unwrap();
368    }
369
370    #[tokio::test]
371    async fn test_database_get() {
372        // let db = Database::connect("mysql://root:qiuqiu123@localhost/music_db")
373        //     .await
374        //     .unwrap();
375        // let account_admin = Account::find().all(&db).await.unwrap();
376        // for account in account_admin {
377        //     println!("{:?}", account);
378        // }
379    }
380    #[tokio::test]
381    async fn test_redis() -> eyre::Result<()> {
382        // let client = redis::Client::open("redis://localhost/0")?;
383        // let mut con = client.get_async_connection().await?;
384        // con.set("hell", "world").await?;
385        // let hell: String = con.get("hell").await?;
386        // println!("{}", hell);
387        // let client = redis::Client::open("redis://localhost/1")?;
388        // let mut con = client.get_async_connection().await?;
389        // con.set("hell2", "world2").await?;
390        // let hell2: String = con.get("hell2").await?;
391        // println!("{}", hell2);
392
393        Ok(())
394    }
395
396    #[test]
397    fn test_build_time() {
398        let now: DateTime<Utc> = Utc::now();
399        let offset = FixedOffset::east_opt(0).unwrap(); // GMT
400        let now = now.with_timezone(&offset);
401        let items = StrftimeItems::new("%a, %d %b %Y %H:%M:%S GMT"); // Define the timestamp format
402        let formatted_date = now.format_with_items(items).to_string();
403        println!("{}", formatted_date);
404    }
405}