diff --git a/Cargo.lock b/Cargo.lock index bf5072a..1f035a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,6 +434,7 @@ dependencies = [ "anyhow", "clap", "umc_kuwo", + "umc_ncm", "umc_qmc", "umc_utils", ] diff --git a/um_cli/Cargo.toml b/um_cli/Cargo.toml index 2bfb996..7e516a0 100644 --- a/um_cli/Cargo.toml +++ b/um_cli/Cargo.toml @@ -8,4 +8,5 @@ anyhow = "1.0.86" clap = { version = "4.5.17", features = ["derive"] } umc_kuwo = { path = "../um_crypto/kuwo" } umc_qmc = { path = "../um_crypto/qmc" } +umc_ncm = { path = "../um_crypto/ncm" } umc_utils = { path = "../um_crypto/utils" } diff --git a/um_cli/src/cmd/mod.rs b/um_cli/src/cmd/mod.rs index 430a8d5..07c9f21 100644 --- a/um_cli/src/cmd/mod.rs +++ b/um_cli/src/cmd/mod.rs @@ -1,5 +1,6 @@ use clap::Subcommand; +pub mod ncm; pub mod qmc1; pub mod qmc2; @@ -9,4 +10,6 @@ pub enum Commands { QMCv1(qmc1::ArgsQMCv1), #[command(name = "qmc2")] QMCv2(qmc2::ArgsQMCv2), + #[command(name = "ncm")] + NCM(ncm::ArgsNCM), } diff --git a/um_cli/src/cmd/ncm.rs b/um_cli/src/cmd/ncm.rs new file mode 100644 index 0000000..735bc9c --- /dev/null +++ b/um_cli/src/cmd/ncm.rs @@ -0,0 +1,90 @@ +use crate::Cli; +use clap::Args; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; +use umc_ncm::header::NCMFile; + +/// Decrypt a NCM file (NetEase Cloud Music) +#[derive(Args)] +pub struct ArgsNCM { + /// Path to output file, e.g. /export/Music/song.flac + #[arg(short, long)] + output: Option, + + /// Path to input file, e.g. /export/Music/song.ncm + #[arg(name = "input")] + input: PathBuf, + + /// Path to export cover image. Could be either JPG or PNG. + /// File will not be created if not found. + #[arg(short)] + cover: Option, + + /// Path to export metadata. JSON string. + #[arg(short)] + metadata: Option, +} + +impl ArgsNCM { + fn write_metadata(&self, cli: &Cli, ncm: &NCMFile) -> anyhow::Result<()> { + if let Some(metadata_path) = &self.metadata { + if !ncm.metadata.is_empty() { + let metadata = ncm.get_metadata()?; + File::create(metadata_path)?.write_all(&metadata)?; + if cli.verbose { + let metadata_path = metadata_path.display(); + let len = metadata.len(); + println!("metadata written to {metadata_path} ({len} bytes)"); + } + } else { + println!("metadata is empty, skip."); + } + } + Ok(()) + } + + fn write_cover(&self, cli: &Cli, ncm: &NCMFile) -> anyhow::Result<()> { + if let Some(cover_path) = &self.cover { + if let Some(cover) = &ncm.image1 { + File::create(cover_path)?.write_all(&cover)?; + if cli.verbose { + let cover_path = cover_path.display(); + let len = cover.len(); + println!("cover written to {cover_path} ({len} bytes)"); + } + } else { + println!("cover#1 is empty, skip."); + } + } + Ok(()) + } + + pub fn run(&self, cli: &Cli) -> anyhow::Result { + let mut file_input = File::open(&self.input)?; + let ncm = NCMFile::new_from_readable(&mut file_input)?; + + self.write_metadata(cli, &ncm)?; + self.write_cover(cli, &ncm)?; + + if let Some(output_path) = &self.output { + file_input.seek(SeekFrom::Start(ncm.audio_data_offset as u64))?; + let mut file_output = File::create(output_path)?; + + let mut offset = 0usize; + let mut buffer = vec![0u8; cli.buffer_size].into_boxed_slice(); + + while let Ok(n) = file_input.read(&mut buffer) { + if n == 0 { + break; + } + + ncm.decrypt(&mut buffer[..n], offset); + file_output.write_all(&buffer[..n])?; + offset += n; + } + } + + Ok(0) + } +} diff --git a/um_cli/src/cmd/qmc1.rs b/um_cli/src/cmd/qmc1.rs index a9ed9f2..2941149 100644 --- a/um_cli/src/cmd/qmc1.rs +++ b/um_cli/src/cmd/qmc1.rs @@ -5,7 +5,7 @@ use std::fs::File; use std::io::{Read, Write}; use std::path::PathBuf; -/// Decrypt a QMCv1 file +/// Decrypt a QMCv1 file (QQMusic) #[derive(Args)] pub struct ArgsQMCv1 { /// Path to output file, e.g. /export/Music/song.flac diff --git a/um_cli/src/cmd/qmc2.rs b/um_cli/src/cmd/qmc2.rs index df2f5da..e0d124e 100644 --- a/um_cli/src/cmd/qmc2.rs +++ b/um_cli/src/cmd/qmc2.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use umc_qmc::{footer, QMCv2Cipher}; use umc_utils::base64; -/// Decrypt a QMCv2 file +/// Decrypt a QMCv2 file (QQMusic) #[derive(Args)] pub struct ArgsQMCv2 { /// Path to output file, e.g. /export/Music/song.flac diff --git a/um_cli/src/main.rs b/um_cli/src/main.rs index 517e77c..640c44d 100644 --- a/um_cli/src/main.rs +++ b/um_cli/src/main.rs @@ -29,6 +29,7 @@ fn run_command(cli: &Cli) -> Result { match &cli.command { Some(Commands::QMCv1(cmd)) => cmd.run(&cli), Some(Commands::QMCv2(cmd)) => cmd.run(&cli), + Some(Commands::NCM(cmd)) => cmd.run(&cli), None => { // https://github.com/clap-rs/clap/issues/3857#issuecomment-1161796261 todo!("implement a sensible default command, similar to um/cli"); diff --git a/um_crypto/ncm/src/header.rs b/um_crypto/ncm/src/header.rs index a17e0e6..6c9493a 100644 --- a/um_crypto/ncm/src/header.rs +++ b/um_crypto/ncm/src/header.rs @@ -1,5 +1,7 @@ -use crate::{content_key, metadata, NetEaseCryptoError as Error}; +use crate::{content_key, metadata, NetEaseCryptoError as Error, NetEaseCryptoError}; use byteorder::{ByteOrder, LE}; +use std::cmp::max; +use std::io::Read; const CRC32: crc::Crc = crc::Crc::::new(&crc::CRC_32_ISO_HDLC); @@ -97,10 +99,10 @@ impl NCMFile { let offset = offset + 1; let cover_frame_len = LE::read_u32(&header[offset..offset + 4]) as usize; + let offset = offset + 4; if header.len() < offset + cover_frame_len + 4 { Err(Error::HeaderTooSmall(offset + cover_frame_len + 4))?; } - let offset = offset + 4; let image1_len = LE::read_u32(&header[offset..offset + 4]) as usize; if image1_len > cover_frame_len { @@ -110,6 +112,7 @@ impl NCMFile { })?; } let offset = offset + 4; + let image2_len = cover_frame_len - image1_len; let image1 = match image1_len { 0 => None, @@ -135,6 +138,36 @@ impl NCMFile { }) } + pub fn new_from_readable(file: &mut T) -> Result + where + T: Read + ?Sized, + { + let mut total_bytes_read = 0; + let mut next_read_len = 4096; + let mut header = vec![]; + + let ncm = loop { + header.resize(total_bytes_read + next_read_len, 0); + let this_read = file + .read(&mut header[total_bytes_read..]) + .map_err(NetEaseCryptoError::FileIOError)?; + if this_read == 0 { + Err(NetEaseCryptoError::FileIOErrorReadZero)?; + } + total_bytes_read += this_read; + + match NCMFile::new(&header) { + Ok(ncm) => break ncm, + Err(NetEaseCryptoError::HeaderTooSmall(expected_len)) => { + next_read_len = max(expected_len - total_bytes_read, 4096); + } + Err(err) => Err(err)?, + }; + }; + + Ok(ncm) + } + pub fn get_metadata(&self) -> Result, Error> { metadata::decrypt(&self.metadata) } @@ -145,18 +178,14 @@ impl NCMFile { /// /// * `data`: The data to decrypt /// * `offset`: Offset of the data in file, subtract `self.audio_data_offset` - /// - /// returns: Result<(), NetEaseCryptoError> - pub fn decrypt(&self, data: &mut T, offset: usize) -> Result<(), Error> + pub fn decrypt(&self, data: &mut T, offset: usize) where - T: AsMut<[u8]>, + T: AsMut<[u8]> + ?Sized, { let key_stream = self.audio_rc4_key_stream.iter().cycle().skip(offset & 0xff); for (datum, &key) in data.as_mut().iter_mut().zip(key_stream) { *datum ^= key; } - - Ok(()) } } diff --git a/um_crypto/ncm/src/lib.rs b/um_crypto/ncm/src/lib.rs index c3b3811..c062527 100644 --- a/um_crypto/ncm/src/lib.rs +++ b/um_crypto/ncm/src/lib.rs @@ -11,6 +11,11 @@ pub enum NetEaseCryptoError { #[error("Header need at least {0} more bytes")] HeaderTooSmall(usize), + #[error("File I/O Error: {0}")] + FileIOError(std::io::Error), + #[error("File I/O Error: Read 0 bytes")] + FileIOErrorReadZero, + #[error("Not a NCM file")] NotNCMFile, #[error("Invalid NCM checksum. Expected {expected:08x}, actual: {expected:08x}")]