Spaces:
Running
Running
Upload main.rs
Browse files- src/main.rs +227 -206
src/main.rs
CHANGED
|
@@ -24,12 +24,12 @@ use cbc::Decryptor;
|
|
| 24 |
use reqwest::{cookie::Jar, Url};
|
| 25 |
use reqwest::cookie::CookieStore;
|
| 26 |
use reqwest::header::ACCEPT;
|
| 27 |
-
use lofty::{
|
| 28 |
-
config::WriteOptions,
|
| 29 |
-
picture::{Picture, PictureType},
|
| 30 |
-
tag::{Accessor, Tag, TagExt, TagType},
|
| 31 |
-
};
|
| 32 |
-
use std::io::Cursor;
|
| 33 |
use std::time::{SystemTime, UNIX_EPOCH};
|
| 34 |
use tokio::sync::{Mutex, RwLock};
|
| 35 |
use tokio::time::{timeout, Duration};
|
|
@@ -53,8 +53,8 @@ struct CdnCacheEntry {
|
|
| 53 |
expires_at: std::time::Instant,
|
| 54 |
}
|
| 55 |
|
| 56 |
-
#[derive(Clone)]
|
| 57 |
-
struct AppState {
|
| 58 |
api: Arc<Mutex<APIClient>>,
|
| 59 |
api_by_arl: Arc<RwLock<HashMap<String, Arc<Mutex<APIClient>>>>>,
|
| 60 |
arl_sessions: Arc<RwLock<HashMap<String, ArlSession>>>,
|
|
@@ -715,38 +715,38 @@ struct StreamParams {
|
|
| 715 |
id: u64,
|
| 716 |
format: Option<String>,
|
| 717 |
arl: Option<String>,
|
| 718 |
-
title: Option<String>,
|
| 719 |
-
performer: Option<String>,
|
| 720 |
-
duration: Option<u64>,
|
| 721 |
-
album: Option<String>,
|
| 722 |
-
cover_url: Option<String>,
|
| 723 |
-
}
|
| 724 |
-
|
| 725 |
-
#[derive(Debug, Clone, Default)]
|
| 726 |
-
struct AudioTagMeta {
|
| 727 |
-
title: Option<String>,
|
| 728 |
-
performer: Option<String>,
|
| 729 |
-
album: Option<String>,
|
| 730 |
-
cover_url: Option<String>,
|
| 731 |
-
}
|
| 732 |
-
|
| 733 |
-
fn normalized_opt(value: &Option<String>) -> Option<String> {
|
| 734 |
-
value
|
| 735 |
-
.as_ref()
|
| 736 |
-
.map(|s| s.trim().to_string())
|
| 737 |
-
.filter(|s| !s.is_empty())
|
| 738 |
-
}
|
| 739 |
-
|
| 740 |
-
impl From<&StreamParams> for AudioTagMeta {
|
| 741 |
-
fn from(params: &StreamParams) -> Self {
|
| 742 |
-
Self {
|
| 743 |
-
title: normalized_opt(¶ms.title),
|
| 744 |
-
performer: normalized_opt(¶ms.performer),
|
| 745 |
-
album: normalized_opt(¶ms.album),
|
| 746 |
-
cover_url: normalized_opt(¶ms.cover_url),
|
| 747 |
-
}
|
| 748 |
-
}
|
| 749 |
-
}
|
| 750 |
|
| 751 |
async fn stream_info(State(state): State<AppState>, Query(q): Query<StreamParams>) -> impl IntoResponse {
|
| 752 |
let _ = std::fs::remove_file("debug_dzmedia.log");
|
|
@@ -869,97 +869,97 @@ struct SendAudioParams {
|
|
| 869 |
title: Option<String>,
|
| 870 |
performer: Option<String>,
|
| 871 |
duration: Option<u64>,
|
| 872 |
-
album: Option<String>,
|
| 873 |
-
cover_url: Option<String>,
|
| 874 |
-
}
|
| 875 |
-
|
| 876 |
-
impl From<&SendAudioParams> for AudioTagMeta {
|
| 877 |
-
fn from(params: &SendAudioParams) -> Self {
|
| 878 |
-
Self {
|
| 879 |
-
title: normalized_opt(¶ms.title),
|
| 880 |
-
performer: normalized_opt(¶ms.performer),
|
| 881 |
-
album: normalized_opt(¶ms.album),
|
| 882 |
-
cover_url: normalized_opt(¶ms.cover_url),
|
| 883 |
-
}
|
| 884 |
-
}
|
| 885 |
-
}
|
| 886 |
-
|
| 887 |
-
async fn fetch_cover_picture(cover_url: &str) -> Option<Picture> {
|
| 888 |
-
let http = reqwest::Client::builder()
|
| 889 |
-
.timeout(Duration::from_secs(20))
|
| 890 |
-
.build()
|
| 891 |
-
.ok()?;
|
| 892 |
-
let resp = http.get(cover_url).send().await.ok()?;
|
| 893 |
-
if !resp.status().is_success() {
|
| 894 |
-
return None;
|
| 895 |
-
}
|
| 896 |
-
let bytes = resp.bytes().await.ok()?;
|
| 897 |
-
let mut picture = Picture::from_reader(&mut Cursor::new(bytes.to_vec())).ok()?;
|
| 898 |
-
picture.set_pic_type(PictureType::CoverFront);
|
| 899 |
-
Some(picture)
|
| 900 |
-
}
|
| 901 |
-
|
| 902 |
-
async fn inject_audio_metadata(mut bytes: Vec<u8>, ext: &str, meta: &AudioTagMeta) -> Vec<u8> {
|
| 903 |
-
let has_text = meta.title.is_some() || meta.performer.is_some() || meta.album.is_some();
|
| 904 |
-
let cover = match meta.cover_url.as_deref() {
|
| 905 |
-
Some(url) => fetch_cover_picture(url).await,
|
| 906 |
-
None => None,
|
| 907 |
-
};
|
| 908 |
-
|
| 909 |
-
if !has_text && cover.is_none() {
|
| 910 |
-
return bytes;
|
| 911 |
-
}
|
| 912 |
-
|
| 913 |
-
let tag_type = if ext.eq_ignore_ascii_case("flac") {
|
| 914 |
-
TagType::VorbisComments
|
| 915 |
-
} else {
|
| 916 |
-
TagType::Id3v2
|
| 917 |
-
};
|
| 918 |
-
|
| 919 |
-
let mut tag = Tag::new(tag_type);
|
| 920 |
-
if let Some(title) = meta.title.clone() {
|
| 921 |
-
tag.set_title(title);
|
| 922 |
-
}
|
| 923 |
-
if let Some(performer) = meta.performer.clone() {
|
| 924 |
-
tag.set_artist(performer);
|
| 925 |
-
}
|
| 926 |
-
if let Some(album) = meta.album.clone() {
|
| 927 |
-
tag.set_album(album);
|
| 928 |
-
}
|
| 929 |
-
if let Some(picture) = cover {
|
| 930 |
-
tag.push_picture(picture);
|
| 931 |
-
}
|
| 932 |
-
|
| 933 |
-
let unique = SystemTime::now()
|
| 934 |
-
.duration_since(UNIX_EPOCH)
|
| 935 |
-
.map(|d| d.as_nanos())
|
| 936 |
-
.unwrap_or(0);
|
| 937 |
-
let temp_path = std::env::temp_dir().join(format!("dzmedia_tagged_{}.{}", unique, ext));
|
| 938 |
-
if let Err(e) = std::fs::write(&temp_path, &bytes) {
|
| 939 |
-
tracing::warn!("metadata temp write failed: {}", e);
|
| 940 |
-
return bytes;
|
| 941 |
-
}
|
| 942 |
-
|
| 943 |
-
let write_options = if ext.eq_ignore_ascii_case("mp3") {
|
| 944 |
-
WriteOptions::new().use_id3v23(true)
|
| 945 |
-
} else {
|
| 946 |
-
WriteOptions::new()
|
| 947 |
-
};
|
| 948 |
-
|
| 949 |
-
let write_result = tag.save_to_path(&temp_path, write_options);
|
| 950 |
-
if let Err(e) = write_result {
|
| 951 |
-
let _ = std::fs::remove_file(&temp_path);
|
| 952 |
-
tracing::warn!("metadata tagging failed: {}", e);
|
| 953 |
-
return bytes;
|
| 954 |
-
}
|
| 955 |
-
|
| 956 |
-
match std::fs::read(&temp_path) {
|
| 957 |
-
Ok(tagged) => bytes = tagged,
|
| 958 |
-
Err(e) => tracing::warn!("metadata temp read failed: {}", e),
|
| 959 |
-
}
|
| 960 |
-
let _ = std::fs::remove_file(&temp_path);
|
| 961 |
-
bytes
|
| 962 |
-
}
|
| 963 |
|
| 964 |
async fn send_audio(
|
| 965 |
State(state): State<AppState>,
|
|
@@ -1066,81 +1066,81 @@ async fn send_audio(
|
|
| 1066 |
bi += 1; pos += bs;
|
| 1067 |
}
|
| 1068 |
|
| 1069 |
-
let ext = if used_fmt.contains("FLAC") || content_type.contains("flac") { "flac" } else { "mp3" };
|
| 1070 |
-
let meta = AudioTagMeta::from(&q);
|
| 1071 |
-
dec = inject_audio_metadata(dec, ext, &meta).await;
|
| 1072 |
let mime = if ext == "flac" { "audio/flac" } else { "audio/mpeg" };
|
| 1073 |
let filename = format!("track_{}.{}", q.id, ext);
|
| 1074 |
|
| 1075 |
-
// Загружаем в Telegram через отдельный клиент.
|
| 1076 |
-
// Если аплоад виснет по сети, важно быстро вернуть ошибку боту,
|
| 1077 |
-
// чтобы он раньше переключился на fallback по URL.
|
| 1078 |
let tg_url = format!("https://api.telegram.org/bot{}/sendAudio", bot_token);
|
| 1079 |
-
let tg_http = reqwest::Client::builder()
|
| 1080 |
-
.https_only(true)
|
| 1081 |
-
.connect_timeout(Duration::from_secs(8))
|
| 1082 |
-
.timeout(Duration::from_secs(18))
|
| 1083 |
-
.tcp_keepalive(Duration::from_secs(30))
|
| 1084 |
-
.build()
|
| 1085 |
-
.unwrap();
|
| 1086 |
-
|
| 1087 |
-
let mut tg_json: Option<serde_json::Value> = None;
|
| 1088 |
-
let mut last_tg_err: Option<String> = None;
|
| 1089 |
-
|
| 1090 |
-
for attempt in 1..=2u8 {
|
| 1091 |
-
let part = reqwest::multipart::Part::bytes(dec.clone())
|
| 1092 |
-
.file_name(filename.clone())
|
| 1093 |
-
.mime_str(mime)
|
| 1094 |
-
.unwrap();
|
| 1095 |
-
let mut form = reqwest::multipart::Form::new()
|
| 1096 |
-
.text("chat_id", q.chat_id.clone())
|
| 1097 |
-
.part("audio", part);
|
| 1098 |
-
if let Some(ref t) = q.title { form = form.text("title", t.clone()); }
|
| 1099 |
-
if let Some(ref p) = q.performer { form = form.text("performer", p.clone()); }
|
| 1100 |
-
if let Some(d) = q.duration { form = form.text("duration", d.to_string()); }
|
| 1101 |
-
|
| 1102 |
-
match tg_http.post(&tg_url).multipart(form).send().await {
|
| 1103 |
-
Ok(resp) => {
|
| 1104 |
-
let status = resp.status();
|
| 1105 |
-
match resp.json::<serde_json::Value>().await {
|
| 1106 |
-
Ok(j) => {
|
| 1107 |
-
if status.is_success() && j.get("ok").and_then(|v| v.as_bool()) == Some(true) {
|
| 1108 |
-
tg_json = Some(j);
|
| 1109 |
-
break;
|
| 1110 |
-
}
|
| 1111 |
-
let desc = j
|
| 1112 |
-
.get("description")
|
| 1113 |
-
.and_then(|v| v.as_str())
|
| 1114 |
-
.unwrap_or("unknown")
|
| 1115 |
-
.to_string();
|
| 1116 |
-
tracing::warn!("Telegram sendAudio attempt {} failed: status={} desc={}", attempt, status, desc);
|
| 1117 |
-
last_tg_err = Some(format!("Telegram {}: {}", status, desc));
|
| 1118 |
-
}
|
| 1119 |
-
Err(e) => {
|
| 1120 |
-
tracing::warn!("Telegram sendAudio attempt {} parse failed: {}", attempt, e);
|
| 1121 |
-
last_tg_err = Some(format!("Telegram parse: {}", e));
|
| 1122 |
-
}
|
| 1123 |
-
}
|
| 1124 |
-
}
|
| 1125 |
-
Err(e) => {
|
| 1126 |
-
tracing::warn!("Telegram sendAudio attempt {} upload failed: {}", attempt, e);
|
| 1127 |
-
last_tg_err = Some(format!("Telegram upload: {}", e));
|
| 1128 |
-
}
|
| 1129 |
-
}
|
| 1130 |
-
|
| 1131 |
-
if attempt < 2 {
|
| 1132 |
-
tokio::time::sleep(Duration::from_secs(1)).await;
|
| 1133 |
-
}
|
| 1134 |
-
}
|
| 1135 |
-
|
| 1136 |
-
let tg_json = match tg_json {
|
| 1137 |
-
Some(j) => j,
|
| 1138 |
-
None => {
|
| 1139 |
-
let err = last_tg_err.unwrap_or_else(|| "Telegram upload failed".to_string());
|
| 1140 |
-
tracing::error!("Telegram sendAudio final failure: {}", err);
|
| 1141 |
-
return json_error(StatusCode::BAD_GATEWAY, err);
|
| 1142 |
-
}
|
| 1143 |
-
};
|
| 1144 |
|
| 1145 |
let file_id = tg_json.pointer("/result/audio/file_id")
|
| 1146 |
.and_then(|v| v.as_str())
|
|
@@ -1281,10 +1281,31 @@ async fn download(
|
|
| 1281 |
}
|
| 1282 |
|
| 1283 |
// Determine file extension
|
| 1284 |
-
let ext = if used_fmt.contains("FLAC") || content_type.contains("flac") { "flac" } else { "mp3" };
|
| 1285 |
-
let meta = AudioTagMeta::from(&q);
|
| 1286 |
-
dec = inject_audio_metadata(dec, ext, &meta).await;
|
| 1287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
let ct = if ext == "flac" { "audio/flac" } else { "audio/mpeg" };
|
| 1289 |
|
| 1290 |
axum::response::Response::builder()
|
|
|
|
| 24 |
use reqwest::{cookie::Jar, Url};
|
| 25 |
use reqwest::cookie::CookieStore;
|
| 26 |
use reqwest::header::ACCEPT;
|
| 27 |
+
use lofty::{
|
| 28 |
+
config::WriteOptions,
|
| 29 |
+
picture::{Picture, PictureType},
|
| 30 |
+
tag::{Accessor, Tag, TagExt, TagType},
|
| 31 |
+
};
|
| 32 |
+
use std::io::Cursor;
|
| 33 |
use std::time::{SystemTime, UNIX_EPOCH};
|
| 34 |
use tokio::sync::{Mutex, RwLock};
|
| 35 |
use tokio::time::{timeout, Duration};
|
|
|
|
| 53 |
expires_at: std::time::Instant,
|
| 54 |
}
|
| 55 |
|
| 56 |
+
#[derive(Clone)]
|
| 57 |
+
struct AppState {
|
| 58 |
api: Arc<Mutex<APIClient>>,
|
| 59 |
api_by_arl: Arc<RwLock<HashMap<String, Arc<Mutex<APIClient>>>>>,
|
| 60 |
arl_sessions: Arc<RwLock<HashMap<String, ArlSession>>>,
|
|
|
|
| 715 |
id: u64,
|
| 716 |
format: Option<String>,
|
| 717 |
arl: Option<String>,
|
| 718 |
+
title: Option<String>,
|
| 719 |
+
performer: Option<String>,
|
| 720 |
+
duration: Option<u64>,
|
| 721 |
+
album: Option<String>,
|
| 722 |
+
cover_url: Option<String>,
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
#[derive(Debug, Clone, Default)]
|
| 726 |
+
struct AudioTagMeta {
|
| 727 |
+
title: Option<String>,
|
| 728 |
+
performer: Option<String>,
|
| 729 |
+
album: Option<String>,
|
| 730 |
+
cover_url: Option<String>,
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
fn normalized_opt(value: &Option<String>) -> Option<String> {
|
| 734 |
+
value
|
| 735 |
+
.as_ref()
|
| 736 |
+
.map(|s| s.trim().to_string())
|
| 737 |
+
.filter(|s| !s.is_empty())
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
impl From<&StreamParams> for AudioTagMeta {
|
| 741 |
+
fn from(params: &StreamParams) -> Self {
|
| 742 |
+
Self {
|
| 743 |
+
title: normalized_opt(¶ms.title),
|
| 744 |
+
performer: normalized_opt(¶ms.performer),
|
| 745 |
+
album: normalized_opt(¶ms.album),
|
| 746 |
+
cover_url: normalized_opt(¶ms.cover_url),
|
| 747 |
+
}
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
|
| 751 |
async fn stream_info(State(state): State<AppState>, Query(q): Query<StreamParams>) -> impl IntoResponse {
|
| 752 |
let _ = std::fs::remove_file("debug_dzmedia.log");
|
|
|
|
| 869 |
title: Option<String>,
|
| 870 |
performer: Option<String>,
|
| 871 |
duration: Option<u64>,
|
| 872 |
+
album: Option<String>,
|
| 873 |
+
cover_url: Option<String>,
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
impl From<&SendAudioParams> for AudioTagMeta {
|
| 877 |
+
fn from(params: &SendAudioParams) -> Self {
|
| 878 |
+
Self {
|
| 879 |
+
title: normalized_opt(¶ms.title),
|
| 880 |
+
performer: normalized_opt(¶ms.performer),
|
| 881 |
+
album: normalized_opt(¶ms.album),
|
| 882 |
+
cover_url: normalized_opt(¶ms.cover_url),
|
| 883 |
+
}
|
| 884 |
+
}
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
async fn fetch_cover_picture(cover_url: &str) -> Option<Picture> {
|
| 888 |
+
let http = reqwest::Client::builder()
|
| 889 |
+
.timeout(Duration::from_secs(20))
|
| 890 |
+
.build()
|
| 891 |
+
.ok()?;
|
| 892 |
+
let resp = http.get(cover_url).send().await.ok()?;
|
| 893 |
+
if !resp.status().is_success() {
|
| 894 |
+
return None;
|
| 895 |
+
}
|
| 896 |
+
let bytes = resp.bytes().await.ok()?;
|
| 897 |
+
let mut picture = Picture::from_reader(&mut Cursor::new(bytes.to_vec())).ok()?;
|
| 898 |
+
picture.set_pic_type(PictureType::CoverFront);
|
| 899 |
+
Some(picture)
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
async fn inject_audio_metadata(mut bytes: Vec<u8>, ext: &str, meta: &AudioTagMeta) -> Vec<u8> {
|
| 903 |
+
let has_text = meta.title.is_some() || meta.performer.is_some() || meta.album.is_some();
|
| 904 |
+
let cover = match meta.cover_url.as_deref() {
|
| 905 |
+
Some(url) => fetch_cover_picture(url).await,
|
| 906 |
+
None => None,
|
| 907 |
+
};
|
| 908 |
+
|
| 909 |
+
if !has_text && cover.is_none() {
|
| 910 |
+
return bytes;
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
let tag_type = if ext.eq_ignore_ascii_case("flac") {
|
| 914 |
+
TagType::VorbisComments
|
| 915 |
+
} else {
|
| 916 |
+
TagType::Id3v2
|
| 917 |
+
};
|
| 918 |
+
|
| 919 |
+
let mut tag = Tag::new(tag_type);
|
| 920 |
+
if let Some(title) = meta.title.clone() {
|
| 921 |
+
tag.set_title(title);
|
| 922 |
+
}
|
| 923 |
+
if let Some(performer) = meta.performer.clone() {
|
| 924 |
+
tag.set_artist(performer);
|
| 925 |
+
}
|
| 926 |
+
if let Some(album) = meta.album.clone() {
|
| 927 |
+
tag.set_album(album);
|
| 928 |
+
}
|
| 929 |
+
if let Some(picture) = cover {
|
| 930 |
+
tag.push_picture(picture);
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
let unique = SystemTime::now()
|
| 934 |
+
.duration_since(UNIX_EPOCH)
|
| 935 |
+
.map(|d| d.as_nanos())
|
| 936 |
+
.unwrap_or(0);
|
| 937 |
+
let temp_path = std::env::temp_dir().join(format!("dzmedia_tagged_{}.{}", unique, ext));
|
| 938 |
+
if let Err(e) = std::fs::write(&temp_path, &bytes) {
|
| 939 |
+
tracing::warn!("metadata temp write failed: {}", e);
|
| 940 |
+
return bytes;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
let write_options = if ext.eq_ignore_ascii_case("mp3") {
|
| 944 |
+
WriteOptions::new().use_id3v23(true)
|
| 945 |
+
} else {
|
| 946 |
+
WriteOptions::new()
|
| 947 |
+
};
|
| 948 |
+
|
| 949 |
+
let write_result = tag.save_to_path(&temp_path, write_options);
|
| 950 |
+
if let Err(e) = write_result {
|
| 951 |
+
let _ = std::fs::remove_file(&temp_path);
|
| 952 |
+
tracing::warn!("metadata tagging failed: {}", e);
|
| 953 |
+
return bytes;
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
match std::fs::read(&temp_path) {
|
| 957 |
+
Ok(tagged) => bytes = tagged,
|
| 958 |
+
Err(e) => tracing::warn!("metadata temp read failed: {}", e),
|
| 959 |
+
}
|
| 960 |
+
let _ = std::fs::remove_file(&temp_path);
|
| 961 |
+
bytes
|
| 962 |
+
}
|
| 963 |
|
| 964 |
async fn send_audio(
|
| 965 |
State(state): State<AppState>,
|
|
|
|
| 1066 |
bi += 1; pos += bs;
|
| 1067 |
}
|
| 1068 |
|
| 1069 |
+
let ext = if used_fmt.contains("FLAC") || content_type.contains("flac") { "flac" } else { "mp3" };
|
| 1070 |
+
let meta = AudioTagMeta::from(&q);
|
| 1071 |
+
dec = inject_audio_metadata(dec, ext, &meta).await;
|
| 1072 |
let mime = if ext == "flac" { "audio/flac" } else { "audio/mpeg" };
|
| 1073 |
let filename = format!("track_{}.{}", q.id, ext);
|
| 1074 |
|
| 1075 |
+
// Загружаем в Telegram через отдельный клиент.
|
| 1076 |
+
// Если аплоад виснет по сети, важно быстро вернуть ошибку боту,
|
| 1077 |
+
// чтобы он раньше переключился на fallback по URL.
|
| 1078 |
let tg_url = format!("https://api.telegram.org/bot{}/sendAudio", bot_token);
|
| 1079 |
+
let tg_http = reqwest::Client::builder()
|
| 1080 |
+
.https_only(true)
|
| 1081 |
+
.connect_timeout(Duration::from_secs(8))
|
| 1082 |
+
.timeout(Duration::from_secs(18))
|
| 1083 |
+
.tcp_keepalive(Duration::from_secs(30))
|
| 1084 |
+
.build()
|
| 1085 |
+
.unwrap();
|
| 1086 |
+
|
| 1087 |
+
let mut tg_json: Option<serde_json::Value> = None;
|
| 1088 |
+
let mut last_tg_err: Option<String> = None;
|
| 1089 |
+
|
| 1090 |
+
for attempt in 1..=2u8 {
|
| 1091 |
+
let part = reqwest::multipart::Part::bytes(dec.clone())
|
| 1092 |
+
.file_name(filename.clone())
|
| 1093 |
+
.mime_str(mime)
|
| 1094 |
+
.unwrap();
|
| 1095 |
+
let mut form = reqwest::multipart::Form::new()
|
| 1096 |
+
.text("chat_id", q.chat_id.clone())
|
| 1097 |
+
.part("audio", part);
|
| 1098 |
+
if let Some(ref t) = q.title { form = form.text("title", t.clone()); }
|
| 1099 |
+
if let Some(ref p) = q.performer { form = form.text("performer", p.clone()); }
|
| 1100 |
+
if let Some(d) = q.duration { form = form.text("duration", d.to_string()); }
|
| 1101 |
+
|
| 1102 |
+
match tg_http.post(&tg_url).multipart(form).send().await {
|
| 1103 |
+
Ok(resp) => {
|
| 1104 |
+
let status = resp.status();
|
| 1105 |
+
match resp.json::<serde_json::Value>().await {
|
| 1106 |
+
Ok(j) => {
|
| 1107 |
+
if status.is_success() && j.get("ok").and_then(|v| v.as_bool()) == Some(true) {
|
| 1108 |
+
tg_json = Some(j);
|
| 1109 |
+
break;
|
| 1110 |
+
}
|
| 1111 |
+
let desc = j
|
| 1112 |
+
.get("description")
|
| 1113 |
+
.and_then(|v| v.as_str())
|
| 1114 |
+
.unwrap_or("unknown")
|
| 1115 |
+
.to_string();
|
| 1116 |
+
tracing::warn!("Telegram sendAudio attempt {} failed: status={} desc={}", attempt, status, desc);
|
| 1117 |
+
last_tg_err = Some(format!("Telegram {}: {}", status, desc));
|
| 1118 |
+
}
|
| 1119 |
+
Err(e) => {
|
| 1120 |
+
tracing::warn!("Telegram sendAudio attempt {} parse failed: {}", attempt, e);
|
| 1121 |
+
last_tg_err = Some(format!("Telegram parse: {}", e));
|
| 1122 |
+
}
|
| 1123 |
+
}
|
| 1124 |
+
}
|
| 1125 |
+
Err(e) => {
|
| 1126 |
+
tracing::warn!("Telegram sendAudio attempt {} upload failed: {}", attempt, e);
|
| 1127 |
+
last_tg_err = Some(format!("Telegram upload: {}", e));
|
| 1128 |
+
}
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
if attempt < 2 {
|
| 1132 |
+
tokio::time::sleep(Duration::from_secs(1)).await;
|
| 1133 |
+
}
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
let tg_json = match tg_json {
|
| 1137 |
+
Some(j) => j,
|
| 1138 |
+
None => {
|
| 1139 |
+
let err = last_tg_err.unwrap_or_else(|| "Telegram upload failed".to_string());
|
| 1140 |
+
tracing::error!("Telegram sendAudio final failure: {}", err);
|
| 1141 |
+
return json_error(StatusCode::BAD_GATEWAY, err);
|
| 1142 |
+
}
|
| 1143 |
+
};
|
| 1144 |
|
| 1145 |
let file_id = tg_json.pointer("/result/audio/file_id")
|
| 1146 |
.and_then(|v| v.as_str())
|
|
|
|
| 1281 |
}
|
| 1282 |
|
| 1283 |
// Determine file extension
|
| 1284 |
+
let ext = if used_fmt.contains("FLAC") || content_type.contains("flac") { "flac" } else { "mp3" };
|
| 1285 |
+
let meta = AudioTagMeta::from(&q);
|
| 1286 |
+
dec = inject_audio_metadata(dec, ext, &meta).await;
|
| 1287 |
+
|
| 1288 |
+
// Generate filename from metadata
|
| 1289 |
+
let filename = if let Some(ref title) = meta.title {
|
| 1290 |
+
let safe_title = title
|
| 1291 |
+
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_")
|
| 1292 |
+
.chars()
|
| 1293 |
+
.take(100)
|
| 1294 |
+
.collect::<String>();
|
| 1295 |
+
if let Some(ref performer) = meta.performer {
|
| 1296 |
+
let safe_performer = performer
|
| 1297 |
+
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_")
|
| 1298 |
+
.chars()
|
| 1299 |
+
.take(50)
|
| 1300 |
+
.collect::<String>();
|
| 1301 |
+
format!("{} - {}.{}", safe_performer, safe_title, ext)
|
| 1302 |
+
} else {
|
| 1303 |
+
format!("{}.{}", safe_title, ext)
|
| 1304 |
+
}
|
| 1305 |
+
} else {
|
| 1306 |
+
format!("track_{}.{}", q.id, ext)
|
| 1307 |
+
};
|
| 1308 |
+
|
| 1309 |
let ct = if ext == "flac" { "audio/flac" } else { "audio/mpeg" };
|
| 1310 |
|
| 1311 |
axum::response::Response::builder()
|