modex/index.vue 分割
This commit is contained in:
@@ -13,6 +13,7 @@ const { authMiddleware } = require('../middleware/auth');
|
||||
const { fetchAndValidateCurve } = require('../services/curveClient');
|
||||
const { uploadMeasurementToS3, getMeasurementFromS3 } = require('../services/measurementStorage');
|
||||
const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage');
|
||||
const { fetchFromSquigLink, downloadTxtFile } = require('../services/squiglink');
|
||||
|
||||
/**
|
||||
* 将 TXT 频响文件内容转换为 CSV 格式
|
||||
@@ -303,6 +304,70 @@ router.get('/api/models/:model_id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/models/squiglink-fetch — 从 squig.link share URL 抓取频响数据
|
||||
router.post('/api/models/squiglink-fetch', async (req, res) => {
|
||||
try {
|
||||
const { share_url, selected_file } = req.body;
|
||||
if (!share_url) {
|
||||
return res.json(ApiResponse.error('缺少 share_url 参数'));
|
||||
}
|
||||
|
||||
logger.info(`SquigLink fetch: ${share_url}${selected_file ? ' (selected=' + selected_file + ')' : ''}`);
|
||||
|
||||
// 如果用户从候选列表中选择了特定文件,直接下载
|
||||
if (selected_file) {
|
||||
const { parseShareUrl, detectFormFromUrl } = require('../services/squiglink');
|
||||
const { baseUrl } = parseShareUrl(share_url);
|
||||
const [downloadResult, detectedForm] = await Promise.all([
|
||||
downloadTxtFile(baseUrl, selected_file),
|
||||
detectFormFromUrl(baseUrl),
|
||||
]);
|
||||
const csvContent = convertTxtToCsv(downloadResult.buffer);
|
||||
// 从 file 名解析品牌和型号
|
||||
const parts = selected_file.split(' ');
|
||||
const brandName = parts.length > 1 ? parts[0] : '';
|
||||
const modelName = parts.length > 1 ? parts.slice(1).join(' ') : selected_file;
|
||||
return res.json(ApiResponse.success({
|
||||
brand_name: brandName,
|
||||
model_name: modelName,
|
||||
form: detectedForm || null,
|
||||
csv_content: csvContent,
|
||||
data_url: downloadResult.url,
|
||||
matches: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// 自动抓取
|
||||
const result = await fetchFromSquigLink(share_url);
|
||||
|
||||
// 多个候选,返回给前端选择
|
||||
if (result.matches.length > 1) {
|
||||
return res.json(ApiResponse.success({
|
||||
brand_name: '',
|
||||
model_name: '',
|
||||
form: result.form || null,
|
||||
csv_content: null,
|
||||
data_url: null,
|
||||
matches: result.matches,
|
||||
}));
|
||||
}
|
||||
|
||||
// 单个匹配,返回 CSV 内容
|
||||
const csvContent = convertTxtToCsv(result.buffer);
|
||||
return res.json(ApiResponse.success({
|
||||
brand_name: result.brandName,
|
||||
model_name: result.modelName,
|
||||
form: result.form || null,
|
||||
csv_content: csvContent,
|
||||
data_url: result.dataUrl,
|
||||
matches: [],
|
||||
}));
|
||||
} catch (e) {
|
||||
logger.error(`SquigLink fetch error: ${e.message}`);
|
||||
return res.json(ApiResponse.error(e.message || '抓取失败'));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/models/ (multipart/form-data)
|
||||
router.post('/api/models/', upload.single('measurement_file'), async (req, res) => {
|
||||
try {
|
||||
@@ -316,8 +381,19 @@ router.post('/api/models/', upload.single('measurement_file'), async (req, res)
|
||||
return res.json(ApiResponse.error('该品牌下型号名称已存在'));
|
||||
}
|
||||
|
||||
// 处理文件上传
|
||||
if (req.file && req.file.originalname) {
|
||||
// 处理文件上传(优先 squiglink_csv,其次文件上传)
|
||||
const { squiglink_csv } = req.body;
|
||||
if (squiglink_csv) {
|
||||
// 从 squig.link 抓取的 CSV 内容直接上传 S3
|
||||
const csvBuffer = Buffer.from(squiglink_csv, 'utf-8');
|
||||
const s3Key = await uploadMeasurementToS3(csvBuffer, {
|
||||
source: source || 'Eafonyoung',
|
||||
form: form || 'in-ear',
|
||||
brandName: brand_name,
|
||||
modelName: name,
|
||||
});
|
||||
logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`);
|
||||
} else if (req.file && req.file.originalname) {
|
||||
const fileExt = path.extname(req.file.originalname).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(fileExt)) {
|
||||
logger.error(`Unsupported file format: ${fileExt}`);
|
||||
@@ -390,7 +466,21 @@ router.put('/api/models/:model_id', upload.single('measurement_file'), async (re
|
||||
const effSource = source === undefined || source === 'null' ? dbModel.source : source;
|
||||
const effForm = form === undefined || form === 'null' ? dbModel.form : form;
|
||||
|
||||
if (req.file && req.file.originalname) {
|
||||
// 处理频响文件(优先 squiglink_csv,其次文件上传)
|
||||
const { squiglink_csv } = req.body;
|
||||
if (squiglink_csv) {
|
||||
if (!effSource || !effForm) {
|
||||
return res.json(ApiResponse.error('上传频响文件需要来源与形式字段'));
|
||||
}
|
||||
const csvBuffer = Buffer.from(squiglink_csv, 'utf-8');
|
||||
const s3Key = await uploadMeasurementToS3(csvBuffer, {
|
||||
source: effSource,
|
||||
form: effForm,
|
||||
brandName: newBrandName,
|
||||
modelName: newName,
|
||||
});
|
||||
logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`);
|
||||
} else if (req.file && req.file.originalname) {
|
||||
const fileExt = path.extname(req.file.originalname).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(fileExt)) {
|
||||
logger.error(`Unsupported file format: ${fileExt}`);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* squig.link 频响数据抓取服务
|
||||
*
|
||||
* 核心机制:
|
||||
* - 每个 squig.link 实例都有 {baseURL}/data/phone_book.json 清单文件
|
||||
* - share URL 的 share 参数(下划线→空格)匹配 phone_book.json 中的 file 字段
|
||||
* - 数据 URL = {baseURL}/{DIR}/{file值} L.txt(DIR 通常为 data/)
|
||||
*/
|
||||
const axios = require('axios');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const FETCH_TIMEOUT = 15000; // 15s
|
||||
const USER_AGENT = 'Dashboard/1.0 (Measurement Fetcher)';
|
||||
const SQUIGLINK_SITES_URL = 'https://squig.link/squigsites.json';
|
||||
|
||||
// squig.link 类型 → 系统佩戴方式映射
|
||||
const TYPE_TO_FORM_MAP = {
|
||||
'iems': 'in-ear',
|
||||
'headphones': 'over-ear',
|
||||
'earbuds': 'earbud',
|
||||
'5128': 'over-ear',
|
||||
};
|
||||
|
||||
// squigsites.json 内存缓存(TTL 1 小时)
|
||||
let _squigsitesCache = null;
|
||||
let _squigsitesCacheTime = 0;
|
||||
const SQUIGLINK_SITES_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/**
|
||||
* 获取 squigsites.json(带内存缓存,1 小时内不重复请求)
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function getSquigSites() {
|
||||
const now = Date.now();
|
||||
if (_squigsitesCache && (now - _squigsitesCacheTime) < SQUIGLINK_SITES_TTL) {
|
||||
return _squigsitesCache;
|
||||
}
|
||||
logger.info('Fetching squigsites.json (cache miss or expired)');
|
||||
const res = await axios.get(SQUIGLINK_SITES_URL, {
|
||||
timeout: FETCH_TIMEOUT,
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
_squigsitesCache = res.data;
|
||||
_squigsitesCacheTime = now;
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 share URL,提取 base URL 和 share 参数
|
||||
* 支持格式:
|
||||
* https://theaudiostore.squig.link/?share=Nostalgia_Audio_Camelot
|
||||
* https://squig.link/?share=Moondrop_Chu_2
|
||||
* @param {string} shareUrl
|
||||
* @returns {{ baseUrl: string, shareParam: string }}
|
||||
*/
|
||||
function parseShareUrl(shareUrl) {
|
||||
const url = new URL(shareUrl.trim());
|
||||
const shareParam = url.searchParams.get('share');
|
||||
if (!shareParam) {
|
||||
throw new Error('URL 中缺少 share 参数');
|
||||
}
|
||||
// base URL = origin(如 https://theaudiostore.squig.link)
|
||||
const baseUrl = url.origin;
|
||||
return { baseUrl, shareParam };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 share URL 自动检测佩戴方式
|
||||
* 通过 squigsites.json 查找实例配置中的 dbs.type 并映射
|
||||
* @param {string} baseUrl - 如 https://theaudiostore.squig.link
|
||||
* @returns {Promise<string|null>} 如 'in-ear' / 'over-ear' / 'earbud' / null
|
||||
*/
|
||||
async function detectFormFromUrl(baseUrl) {
|
||||
try {
|
||||
// 从 URL 提取 username(子域名)
|
||||
const urlObj = new URL(baseUrl);
|
||||
const hostname = urlObj.hostname; // e.g. theaudiostore.squig.link
|
||||
const username = hostname.split('.')[0]; // e.g. theaudiostore
|
||||
if (!username || username === 'squig') return null;
|
||||
|
||||
// 获取 squigsites.json(带缓存)
|
||||
logger.info(`Detecting form for: ${username}`);
|
||||
const sites = await getSquigSites();
|
||||
if (!Array.isArray(sites)) return null;
|
||||
|
||||
// 查找匹配的实例
|
||||
const site = sites.find((s) => s.username === username);
|
||||
if (!site || !Array.isArray(site.dbs) || site.dbs.length === 0) return null;
|
||||
|
||||
// 提取 URL 路径,匹配 db folder
|
||||
const urlPath = urlObj.pathname || '/';
|
||||
let matchedType = null;
|
||||
|
||||
for (const db of site.dbs) {
|
||||
const folder = db.folder || '/';
|
||||
// 如果 URL 路径以 db.folder 开头,则匹配
|
||||
if (urlPath.startsWith(folder) || folder === '/') {
|
||||
matchedType = db.type;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果只有一个 db,直接用它
|
||||
if (!matchedType && site.dbs.length === 1) {
|
||||
matchedType = site.dbs[0].type;
|
||||
}
|
||||
|
||||
if (!matchedType) return null;
|
||||
|
||||
const form = TYPE_TO_FORM_MAP[matchedType.toLowerCase()] || null;
|
||||
if (form) {
|
||||
logger.info(`Detected form from squigsites.json: ${username} → ${matchedType} → ${form}`);
|
||||
}
|
||||
return form;
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to detect form from squigsites.json: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 squig.link 实例的 phone_book.json
|
||||
* @param {string} baseUrl - 如 https://theaudiostore.squig.link
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function fetchPhoneBook(baseUrl) {
|
||||
const url = `${baseUrl}/data/phone_book.json`;
|
||||
logger.info(`Fetching phone_book: ${url}`);
|
||||
const res = await axios.get(url, {
|
||||
timeout: FETCH_TIMEOUT,
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
// 忽略缓存参数(有些站点加 ?timestamp 后缀)
|
||||
maxRedirects: 5,
|
||||
});
|
||||
if (!Array.isArray(res.data)) {
|
||||
throw new Error('phone_book.json 格式异常:期望数组');
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 phone_book 中查找匹配的 file 字段值
|
||||
* @param {Array} phoneBook
|
||||
* @param {string} shareParam - share 参数(下划线形式)
|
||||
* @returns {{ fileName: string, brandName: string, modelName: string } | null}
|
||||
*/
|
||||
function findInPhoneBook(phoneBook, shareParam) {
|
||||
// share 参数转空格形式
|
||||
const target = shareParam.replace(/_/g, ' ').trim();
|
||||
const targetLower = target.toLowerCase();
|
||||
|
||||
for (const brand of phoneBook) {
|
||||
const brandName = brand.name || '';
|
||||
const phones = brand.phones || [];
|
||||
for (const phone of phones) {
|
||||
// file 字段可能是 string 或 string[]
|
||||
const fileField = phone.file || phone.name;
|
||||
const candidates = Array.isArray(fileField) ? fileField : [fileField];
|
||||
|
||||
for (const fileName of candidates) {
|
||||
if (!fileName) continue;
|
||||
if (fileName.toLowerCase() === targetLower) {
|
||||
return {
|
||||
fileName,
|
||||
brandName,
|
||||
modelName: phone.name || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模糊查找:当精确匹配失败时,用 share 参数关键词模糊搜索
|
||||
* @param {Array} phoneBook
|
||||
* @param {string} shareParam
|
||||
* @returns {Array<{ fileName: string, brandName: string, modelName: string }>}
|
||||
*/
|
||||
function fuzzyFindInPhoneBook(phoneBook, shareParam) {
|
||||
const target = shareParam.replace(/_/g, ' ').trim().toLowerCase();
|
||||
const keywords = target.split(/\s+/);
|
||||
const results = [];
|
||||
|
||||
for (const brand of phoneBook) {
|
||||
const brandName = brand.name || '';
|
||||
const phones = brand.phones || [];
|
||||
for (const phone of phones) {
|
||||
const fileField = phone.file || phone.name;
|
||||
const candidates = Array.isArray(fileField) ? fileField : [fileField];
|
||||
|
||||
for (const fileName of candidates) {
|
||||
if (!fileName) continue;
|
||||
const fileLower = fileName.toLowerCase();
|
||||
// 所有关键词都出现在 fileName 中
|
||||
if (keywords.every((kw) => fileLower.includes(kw))) {
|
||||
results.push({ fileName, brandName, modelName: phone.name || '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 TXT 频响文件
|
||||
* 按优先级尝试多种后缀:L声道 → R声道 → 无后缀 → 带编号样本
|
||||
* @param {string} baseUrl
|
||||
* @param {string} fileName - phone_book 中的 file 值
|
||||
* @returns {Promise<{ buffer: Buffer, url: string }>}
|
||||
*/
|
||||
async function downloadTxtFile(baseUrl, fileName) {
|
||||
// 按优先级尝试的后缀列表
|
||||
const suffixes = [
|
||||
' L.txt', ' R.txt', '.txt',
|
||||
' L1.txt', ' R1.txt',
|
||||
' L2.txt', ' R2.txt',
|
||||
];
|
||||
let lastError = null;
|
||||
|
||||
for (const suffix of suffixes) {
|
||||
const url = `${baseUrl}/data/${encodeURIComponent(fileName + suffix)}`;
|
||||
try {
|
||||
logger.info(`Downloading TXT: ${url}`);
|
||||
const res = await axios.get(url, {
|
||||
timeout: FETCH_TIMEOUT,
|
||||
responseType: 'arraybuffer',
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
});
|
||||
const buffer = Buffer.from(res.data);
|
||||
if (buffer.length < 10) {
|
||||
throw new Error('文件内容过短,可能无效');
|
||||
}
|
||||
logger.info(`TXT downloaded: ${url} (${buffer.length} bytes)`);
|
||||
return { buffer, url };
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
logger.warn(`TXT download failed (${suffix}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法下载频响文件(已尝试 ${suffixes.length} 种后缀):${lastError?.message || '未知错误'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 squig.link share URL 抓取频响数据
|
||||
* @param {string} shareUrl - 完整的 share URL
|
||||
* @returns {Promise<{
|
||||
* buffer: Buffer,
|
||||
* fileName: string,
|
||||
* dataUrl: string,
|
||||
* brandName: string,
|
||||
* modelName: string,
|
||||
* matches: Array<{ fileName: string, brandName: string, modelName: string }>
|
||||
* }>}
|
||||
*/
|
||||
async function fetchFromSquigLink(shareUrl) {
|
||||
const { baseUrl, shareParam } = parseShareUrl(shareUrl);
|
||||
|
||||
// 并行获取 phone_book 和检测佩戴方式
|
||||
const [phoneBook, detectedForm] = await Promise.all([
|
||||
fetchPhoneBook(baseUrl).catch((e) => {
|
||||
throw new Error(`获取 phone_book.json 失败:${e.message}`);
|
||||
}),
|
||||
detectFormFromUrl(baseUrl),
|
||||
]);
|
||||
|
||||
// 精确匹配
|
||||
let match = findInPhoneBook(phoneBook, shareParam);
|
||||
let matches = [];
|
||||
|
||||
if (!match) {
|
||||
// 模糊匹配
|
||||
matches = fuzzyFindInPhoneBook(phoneBook, shareParam);
|
||||
if (matches.length === 0) {
|
||||
throw new Error('在 phone_book.json 中未找到匹配的测量数据');
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
match = matches[0];
|
||||
} else {
|
||||
// 多个匹配,返回候选列表让前端选择
|
||||
return {
|
||||
buffer: null,
|
||||
fileName: null,
|
||||
dataUrl: null,
|
||||
brandName: '',
|
||||
modelName: '',
|
||||
form: detectedForm,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 下载 TXT 文件
|
||||
const { buffer, url: dataUrl } = await downloadTxtFile(baseUrl, match.fileName);
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName: match.fileName,
|
||||
dataUrl,
|
||||
brandName: match.brandName,
|
||||
modelName: match.modelName,
|
||||
form: detectedForm,
|
||||
matches: [match],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseShareUrl,
|
||||
fetchFromSquigLink,
|
||||
fetchPhoneBook,
|
||||
findInPhoneBook,
|
||||
fuzzyFindInPhoneBook,
|
||||
downloadTxtFile,
|
||||
detectFormFromUrl,
|
||||
getSquigSites,
|
||||
};
|
||||
Reference in New Issue
Block a user