commit 1c892259a9eb092c7f53f8b9cee15039d3137be4 Author: nanxun Date: Thu Apr 16 15:19:48 2026 +0800 1111 diff --git a/.codex_tmp_danmaku/index.js b/.codex_tmp_danmaku/index.js new file mode 100644 index 0000000..d0769c3 --- /dev/null +++ b/.codex_tmp_danmaku/index.js @@ -0,0 +1,7 @@ +// index.js +const sign = require('./xbogus'); + +module.exports = function (url, userAgent) { + return sign(url.split("?")[1], userAgent); +}; + diff --git a/.codex_tmp_danmaku/probe.js b/.codex_tmp_danmaku/probe.js new file mode 100644 index 0000000..96fae88 --- /dev/null +++ b/.codex_tmp_danmaku/probe.js @@ -0,0 +1,80 @@ +const fs = require('fs'); +const path = require('path'); +const sqlite3 = require('node:sqlite'); +const https = require('https'); +const sign = require(path.join(process.argv[2], 'index.js')); +const db = new sqlite3.DatabaseSync(process.argv[3]); +const rows = db.prepare("select Key, Value from AppSettings where Key in ('douyin.cookie','douyin.user_agent')").all(); +const settings = Object.fromEntries(rows.map(r => [r.Key, r.Value])); +const webRid = '24482384478'; +function request(url, headers) { + return new Promise((resolve, reject) => { + https.get(url, { headers }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }).on('error', reject); + }); +} +(async () => { + const headers = { + 'user-agent': settings['douyin.user_agent'] || 'Mozilla/5.0', + 'referer': `https://live.douyin.com/${webRid}`, + 'origin': 'https://live.douyin.com', + 'cookie': settings['douyin.cookie'] || '' + }; + const html = await request(`https://live.douyin.com/${webRid}`, headers); + const match = html.match(/self\.__pace_f\.push\(\[1,"[a-z]?:\[\\"\$\\",\\"\$L\d+\\",null,([\s\S]+?state[\s\S]+?)\]\\n"\]\)<\/script>/); + const json = match ? match[1].replace(/\\{1,7}"/g, '"') : ''; + const roomId = (json.match(/\"roomId\":\"(\d+)\"/) || [])[1]; + const uniqueId = '7300000000000000000'; + const msToken = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv'; + const params = new URLSearchParams({ + aid: '6383', + app_name: 'douyin_web', + browser_language: 'zh-CN', + browser_name: 'Mozilla', + browser_online: 'true', + browser_platform: 'Win32', + browser_version: settings['douyin.user_agent'] || 'Mozilla/5.0', + cookie_enabled: 'true', + cursor: '', + device_id: '', + device_platform: 'web', + did_rule: '3', + endpoint: 'live_pc', + fetch_rule: '1', + identity: 'audience', + insert_task_id: '', + internal_ext: '', + last_rtt: '0', + live_id: '1', + live_reason: '', + need_persist_msg_count: '15', + resp_content_type: 'protobuf', + screen_height: '1080', + screen_width: '1920', + support_wrds: '1', + tz_name: 'Asia/Shanghai', + version_code: '180800', + room_id: roomId, + user_unique_id: uniqueId, + live_pc: roomId, + msToken + }); + const rawQuery = params.toString(); + const xb = sign(`https://live.douyin.com/webcast/im/fetch/?${rawQuery}`, headers['user-agent']); + params.set('a_bogus', xb); + const url = `https://live.douyin.com/webcast/im/fetch/?${params.toString()}`; + const body = await new Promise((resolve, reject) => { + https.get(url, { headers: { ...headers, accept: 'application/protobuf, application/octet-stream, */*' } }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + }).on('error', reject); + }); + console.log('ROOM_ID', roomId); + console.log('A_BOGUS_LEN', xb.length); + console.log('RESP_LEN', body.length); + console.log('RESP_HEAD', Array.from(body.subarray(0, 48))); +})(); diff --git a/.codex_tmp_danmaku/xbogus.js b/.codex_tmp_danmaku/xbogus.js new file mode 100644 index 0000000..044cab5 --- /dev/null +++ b/.codex_tmp_danmaku/xbogus.js @@ -0,0 +1,563 @@ +var window = null; + +function _0x5cd844(e) { + var b = { + exports: {} + }; + return e(b, b.exports), b.exports +} +jsvmp = function(e, b, a) { + function f(e, b, a) { + return (f = function() { + if ("undefined" == typeof Reflect || !Reflect.construct || Reflect.construct.sham) return !1; + if ("function" == typeof Proxy) return !0; + try { + return Date.prototype.toString.call(Reflect.construct(Date, [], function() {})), !0 + } catch (e) { + return !1 + } + }() ? Reflect.construct : function(e, b, a) { + var f = [null]; + f.push.apply(f, b); + var c = new(Function.bind.apply(e, f)); + return a && function(e, b) { + (Object.setPrototypeOf || function(e, b) { + return e.__proto__ = b, e + })(e, b) + }(c, a.prototype), c + }).apply(null, arguments) + } + + function c(e) { + return function(e) { + if (Array.isArray(e)) { + for (var b = 0, a = new Array(e.length); b < e.length; b++) a[b] = e[b]; + return a + } + }(e) || function(e) { + if (Symbol.iterator in Object(e) || "[object Arguments]" === Object.prototype.toString.call(e)) return Array.from(e) + }(e) || function() { + throw new TypeError("Invalid attempt to spread non-iterable instance") + }() + } + for (var r = [], t = 0, d = [], i = 0, n = function(e, b) { + var a = e[b++], + f = e[b], + c = parseInt("" + a + f, 16); + if (c >> 7 == 0) return [1, c]; + if (c >> 6 == 2) { + var r = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [2, r = (c <<= 8) + r] + } + if (c >> 6 == 3) { + var t = parseInt("" + e[++b] + e[++b], 16), + d = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [3, d = (c <<= 16) + (t <<= 8) + d] + } + }, s = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1], 16); + return a > 127 ? -256 + a : a + }, o = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16); + return a > 32767 ? -65536 + a : a + }, l = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3] + e[b + 4] + e[b + 5] + e[b + 6] + e[b + 7], 16); + return a > 2147483647 ? 0 + a : a + }, _ = function(e, b) { + return parseInt("" + e[b] + e[b + 1], 16) + }, x = function(e, b) { + return parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16) + }, u = u || this || window, h = (e.length, 0), p = "", y = h; y < h + 16; y++) { + var v = "" + e[y++] + e[y]; + v = parseInt(v, 16), p += String.fromCharCode(v) + } + if ("HNOJ@?RC" != p) throw new Error("error magic number " + p); + parseInt("" + e[h += 16] + e[h + 1], 16), h += 8, t = 0; + for (var g = 0; g < 4; g++) { + var w = h + 2 * g, + A = parseInt("" + e[w++] + e[w], 16); + t += (3 & A) << 2 * g + } + h += 16; + var C = parseInt("" + e[h += 8] + e[h + 1] + e[h + 2] + e[h + 3] + e[h + 4] + e[h + 5] + e[h + 6] + e[h + 7], 16), + m = C, + S = h += 8, + z = x(e, h += C); + z[1], h += 4, r = { + p: [], + q: [] + }; + for (var B = 0; B < z; B++) { + for (var R = n(e, h), q = h += 2 * R[0], I = r.p.length, k = 0; k < R[1]; k++) { + var j = n(e, q); + r.p.push(j[1]), q += 2 * j[0] + } + h = q, r.q.push([I, r.p.length]) + } + var O = { + 5: 1, + 6: 1, + 70: 1, + 22: 1, + 23: 1, + 37: 1, + 73: 1 + }, + U = { + 72: 1 + }, + D = { + 74: 1 + }, + N = { + 11: 1, + 12: 1, + 24: 1, + 26: 1, + 27: 1, + 31: 1 + }, + J = { + 10: 1 + }, + L = { + 2: 1, + 29: 1, + 30: 1, + 20: 1 + }, + T = [], + E = []; + + function M(e, b, a) { + for (var f = b; f < b + a;) { + var c = _(e, f); + T[f] = c, f += 2, U[c] ? (E[f] = s(e, f), f += 2) : O[c] ? (E[f] = o(e, f), f += 4) : D[c] ? (E[f] = l(e, f), f += 8) : N[c] ? (E[f] = _(e, f), f += 2) : J[c] ? (E[f] = x(e, f), f += 4) : L[c] && (E[f] = x(e, f), f += 4) + } + } + return F(e, S, m / 2, [], b, a); + + function P(e, b, a, n, h, p, y, v) { + null == p && (p = this); + var g, w, A, C, m = [], + S = 0; + y && (w = y); + var z, B, R = b, + q = R + 2 * a; + if (!v) + for (; R < q;) { + var I = parseInt("" + e[R] + e[R + 1], 16); + R += 2; + var j = 3 & (z = 13 * I % 241); + if (z >>= 2, j < 1) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = o(e, R), (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + else if (j < 2) + if (j = 3 & z, z >>= 2, j < 1) + if ((j = z) < 5) { + B = o(e, R); + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } else j < 7 ? (B = _(e, R), R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1)))) : j < 9 && (w = m[S--], m[S] = m[S] & w); + else if (j < 2) + if ((j = z) > 12) m[++S] = s(e, R), R += 2; + else if (j > 10) w = m[S--], m[S] = m[S] << w; + else if (j > 8) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j > 6 && (A = m[S--], w = delete m[S--][A]); + else if (j < 3)(j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w); + else if ((j = z) > 12) m[++S] = p; + else if (j > 5) w = m[S--], m[S] = m[S] !== w; + else if (j > 3) w = m[S--], m[S] = m[S] / w; + else if (j > 1) { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } else j > -1 && (m[S] = !m[S]); + else if (j < 3) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = o(e, R), R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = _(e, R), R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = x(e, R), R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) > 12 ? (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A) : j > 3 ? (w = m[S--], m[S] = m[S] == w) : j > 1 ? (w = m[S--], m[S] = m[S] + w) : j > -1 && (m[++S] = u); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) + if (m[S--]) R += 4; + else { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } + else if (j > 0) { + for (B = x(e, R), w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = _(e, R), R += 2, m[++S] = h["$" + B]) : j > 3 && (B = o(e, R), d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j = 3 & z, z >>= 2, j > 2)(j = z) > 13 ? (m[++S] = l(e, R), R += 8) : j > 11 ? (w = m[S--], m[S] = m[S] >>> w) : j > 9 ? m[++S] = !0 : j > 7 ? (B = _(e, R), R += 2, m[S] = m[S][B]) : j > 0 && (w = m[S--], m[S] = m[S] < w); + else if (j > 1)(j = z) > 10 ? (B = o(e, R), d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2) : j > 8 ? (w = m[S--], m[S] = m[S] ^ w) : j > 6 && (w = m[S--]); + else if (j > 0) { + if ((j = z) > 7) w = m[S--], m[S] = m[S] in w; + else if (j > 5) m[S] = ++m[S]; + else if (j > 3) B = _(e, R), R += 2, w = h[B], m[++S] = w; + else if (j > 1) { + var O = 0, + U = m[S].length, + D = m[S]; + m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + } + } + } else if ((j = z) > 13) w = m[S], m[S] = m[S - 1], m[S - 1] = w; + else if (j > 4) w = m[S--], m[S] = m[S] === w; + else if (j > 2) w = m[S--], m[S] = m[S] - w; + else if (j > 0) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } + } + if (v) + for (; R < q;) + if (I = T[R], R += 2, j = 3 & (z = 13 * I % 241), z >>= 2, j > 2) + if (j = 3 & z, z >>= 2, j > 2)(j = z) < 2 ? (w = m[S--], m[S] = m[S] < w) : j < 9 ? (B = E[R], R += 2, m[S] = m[S][B]) : j < 11 ? m[++S] = !0 : j < 13 ? (w = m[S--], m[S] = m[S] >>> w) : j < 15 && (m[++S] = E[R], R += 8); + else if (j > 1)(j = z) < 6 || (j < 8 ? w = m[S--] : j < 10 ? (w = m[S--], m[S] = m[S] ^ w) : j < 12 && (B = E[R], d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2)); + else if (j > 0)(j = z) > 7 ? (w = m[S--], m[S] = m[S] in w) : j > 5 ? m[S] = ++m[S] : j > 3 ? (B = E[R], R += 2, w = h[B], m[++S] = w) : j > 1 && (O = 0, U = m[S].length, D = m[S], m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + }); + else if ((j = z) < 2) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } else j < 4 ? (w = m[S--], m[S] = m[S] - w) : j < 6 ? (w = m[S--], m[S] = m[S] === w) : j < 15 && (w = m[S], m[S] = m[S - 1], m[S - 1] = w); + else if (j > 1) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = E[R], R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = E[R], R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = E[R], R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) < 1 ? m[++S] = u : j < 3 ? (w = m[S--], m[S] = m[S] + w) : j < 5 ? (w = m[S--], m[S] = m[S] == w) : j < 14 && (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) m[S--] ? R += 4 : R += 2 * (B = E[R]) - 2; + else if (j > 0) { + for (B = E[R], w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = E[R], R += 2, m[++S] = h["$" + B]) : j > 3 && (B = E[R], d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j > 0) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) > 9); + else if (j > 7) w = m[S--], m[S] = m[S] & w; + else if (j > 5) B = E[R], R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1))); + else if (j > 3) { + B = E[R]; + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } + } else if (j < 2) + if ((j = z) < 8) A = m[S--], w = delete m[S--][A]; + else if (j < 10) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j < 12 ? (w = m[S--], m[S] = m[S] << w) : j < 14 && (m[++S] = E[R], R += 2); + else j < 3 ? (j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w) : (j = z) > 12 ? m[++S] = p : j > 5 ? (w = m[S--], m[S] = m[S] !== w) : j > 3 ? (w = m[S--], m[S] = m[S] / w) : j > 1 ? R += 2 * (B = E[R]) - 2 : j > -1 && (m[S] = !m[S]); + else if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = E[R], (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + return [0, null] + } + + function F(e, b, a, f, c, r, t, d) { + null == r && (r = this), c && !c.d && (c.d = 0, c.$0 = c, c[1] = {}); + var i, n, s = {}, + o = s.d = c ? c.d + 1 : 0; + for (s["$" + o] = s, n = 0; n < o; n++) s[i = "$" + n] = c[i]; + for (n = 0, o = s.length = f.length; n < o; n++) s[n] = f[n]; + return d && !T[b] && M(e, b, 2 * a), T[b] ? P(e, b, a, 0, s, r, null, 1)[1] : P(e, b, a, 0, s, r, null, 0)[1] + } +}; +var _0x397dc7 = "undefined" != typeof globalThis ? globalThis : void 0 !== window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : {}, + _0x124d1a = _0x5cd844(function(_0x770f81) { + ! function() { + var _0x250d36 = "input is invalid type", + _0x4cfaee = !1, + _0x1702f9 = {}, + _0x5ccbb3 = !_0x4cfaee && "object" == typeof self, + _0x54d876 = !_0x1702f9.JS_MD5_NO_NODE_JS && "object" == typeof process && process.versions && process.versions.node, + _0x185caf; + _0x54d876 ? _0x1702f9 = _0x397dc7 : _0x5ccbb3 && (_0x1702f9 = self); + var _0x17dcbf = !_0x1702f9.JS_MD5_NO_COMMON_JS && _0x770f81.exports, + _0x554fed = !1, + _0x2de28f = !_0x1702f9.JS_MD5_NO_ARRAY_BUFFER && "undefined" != typeof ArrayBuffer, + _0x3a9a1b = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], + _0x465562 = [128, 32768, 8388608, -2147483648], + _0x20b37e = [0, 8, 16, 24], + _0x323604 = ["hex", "array", "digest", "buffer", "arrayBuffer", "base64"], + _0x2c185e = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+", "/"], + _0x4b59e0 = []; + if (_0x2de28f) { + var _0x395837 = new ArrayBuffer(68); + _0x185caf = new Uint8Array(_0x395837), _0x4b59e0 = new Uint32Array(_0x395837) + }!_0x1702f9.JS_MD5_NO_NODE_JS && Array.isArray || (Array.isArray = function(e) { + return "[object Array]" === Object.prototype.toString.call(e) + }), _0x2de28f && (_0x1702f9.JS_MD5_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView) && (ArrayBuffer.isView = function(e) { + return "object" == typeof e && e.buffer && e.buffer.constructor === ArrayBuffer + }); + var _0x4e9930 = function(e) { + return function(b) { + return new _0x5887c8(!0).update(b)[e]() + } + }, + _0x38ba77 = function() { + var e = _0x4e9930("hex"); + _0x54d876 && (e = _0x474989(e)), e.create = function() { + return new _0x5887c8 + }, e.update = function(b) { + return e.create().update(b) + }; + for (var b = 0; b < _0x323604.length; ++b) { + var a = _0x323604[b]; + e[a] = _0x4e9930(a) + } + return e + }, + _0x474989 = function(_0x57eeaa) { + var _0x114910, _0x226465 = eval("require('crypto');"), + _0x1f6ae0 = eval("require('buffer')['Buffer'];"); + return function(e) { + if ("string" == typeof e) return _0x226465.createHash("md5").update(e, "utf8").digest("hex"); + if (null == e) throw _0x250d36; + return e.constructor === ArrayBuffer && (e = new Uint8Array(e)), Array.isArray(e) || ArrayBuffer.isView(e) || e.constructor === _0x1f6ae0 ? _0x226465.createHash("md5").update(new _0x1f6ae0.from(e)).digest("hex") : _0x57eeaa(e) + } + }; + + function _0x5887c8(e) { + if (e) _0x4b59e0[0] = _0x4b59e0[16] = _0x4b59e0[1] = _0x4b59e0[2] = _0x4b59e0[3] = _0x4b59e0[4] = _0x4b59e0[5] = _0x4b59e0[6] = _0x4b59e0[7] = _0x4b59e0[8] = _0x4b59e0[9] = _0x4b59e0[10] = _0x4b59e0[11] = _0x4b59e0[12] = _0x4b59e0[13] = _0x4b59e0[14] = _0x4b59e0[15] = 0, this.blocks = _0x4b59e0, this.buffer8 = _0x185caf; + else if (_0x2de28f) { + var b = new ArrayBuffer(68); + this.buffer8 = new Uint8Array(b), this.blocks = new Uint32Array(b) + } else this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + this.h0 = this.h1 = this.h2 = this.h3 = this.start = this.bytes = this.hBytes = 0, this.finalized = this.hashed = !1, this.first = !0 + } + _0x5887c8.prototype.update = function(e) { + if (!this.finalized) { + var b, a = typeof e; + if ("string" !== a) { + if ("object" !== a || null === e) throw _0x250d36; + if (_0x2de28f && e.constructor === ArrayBuffer) e = new Uint8Array(e); + else if (!(Array.isArray(e) || _0x2de28f && ArrayBuffer.isView(e))) throw _0x250d36; + b = !0 + } + for (var f, c, r = 0, t = e.length, d = this.blocks, i = this.buffer8; r < t;) { + if (this.hashed && (this.hashed = !1, d[0] = d[16], d[16] = d[1] = d[2] = d[3] = d[4] = d[5] = d[6] = d[7] = d[8] = d[9] = d[10] = d[11] = d[12] = d[13] = d[14] = d[15] = 0), b) + if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r) i[c++] = e[r]; + else + for (c = this.start; r < t && c < 64; ++r) d[c >> 2] |= e[r] << _0x20b37e[3 & c++]; + else if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? i[c++] = f : f < 2048 ? (i[c++] = 192 | f >> 6, i[c++] = 128 | 63 & f) : f < 55296 || f >= 57344 ? (i[c++] = 224 | f >> 12, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), i[c++] = 240 | f >> 18, i[c++] = 128 | f >> 12 & 63, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f); + else + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? d[c >> 2] |= f << _0x20b37e[3 & c++] : f < 2048 ? (d[c >> 2] |= (192 | f >> 6) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : f < 55296 || f >= 57344 ? (d[c >> 2] |= (224 | f >> 12) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), d[c >> 2] |= (240 | f >> 18) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 12 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]); + this.lastByteIndex = c, this.bytes += c - this.start, c >= 64 ? (this.start = c - 64, this.hash(), this.hashed = !0) : this.start = c + } + return this.bytes > 4294967295 && (this.hBytes += this.bytes / 4294967296 << 0, this.bytes = this.bytes % 4294967296), this + } + }, _0x5887c8.prototype.finalize = function() { + if (!this.finalized) { + this.finalized = !0; + var e = this.blocks, + b = this.lastByteIndex; + e[b >> 2] |= _0x465562[3 & b], b >= 56 && (this.hashed || this.hash(), e[0] = e[16], e[16] = e[1] = e[2] = e[3] = e[4] = e[5] = e[6] = e[7] = e[8] = e[9] = e[10] = e[11] = e[12] = e[13] = e[14] = e[15] = 0), e[14] = this.bytes << 3, e[15] = this.hBytes << 3 | this.bytes >>> 29, this.hash() + } + }, _0x5887c8.prototype.hash = function() { + var e, b, a, f, c, r, t = this.blocks; + this.first ? b = ((b = ((e = ((e = t[0] - 680876937) << 7 | e >>> 25) - 271733879 << 0) ^ (a = ((a = (-271733879 ^ (f = ((f = (-1732584194 ^ 2004318071 & e) + t[1] - 117830708) << 12 | f >>> 20) + e << 0) & (-271733879 ^ e)) + t[2] - 1126478375) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1316259209) << 22 | b >>> 10) + a << 0 : (e = this.h0, b = this.h1, a = this.h2, b = ((b += ((e = ((e += ((f = this.h3) ^ b & (a ^ f)) + t[0] - 680876936) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[1] - 389564586) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[2] + 606105819) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1044525330) << 22 | b >>> 10) + a << 0), b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[4] - 176418897) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[5] + 1200080426) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[6] - 1473231341) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[7] - 45705983) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[8] + 1770035416) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[9] - 1958414417) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[10] - 42063) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[11] - 1990404162) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[12] + 1804603682) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[13] - 40341101) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[14] - 1502002290) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[15] + 1236535329) << 22 | b >>> 10) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[1] - 165796510) << 5 | e >>> 27) + b << 0) ^ b)) + t[6] - 1069501632) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[11] + 643717713) << 14 | a >>> 18) + f << 0) ^ f)) + t[0] - 373897302) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[5] - 701558691) << 5 | e >>> 27) + b << 0) ^ b)) + t[10] + 38016083) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[15] - 660478335) << 14 | a >>> 18) + f << 0) ^ f)) + t[4] - 405537848) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[9] + 568446438) << 5 | e >>> 27) + b << 0) ^ b)) + t[14] - 1019803690) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[3] - 187363961) << 14 | a >>> 18) + f << 0) ^ f)) + t[8] + 1163531501) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[13] - 1444681467) << 5 | e >>> 27) + b << 0) ^ b)) + t[2] - 51403784) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[7] + 1735328473) << 14 | a >>> 18) + f << 0) ^ f)) + t[12] - 1926607734) << 20 | b >>> 12) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[5] - 378558) << 4 | e >>> 28) + b << 0)) + t[8] - 2022574463) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[11] + 1839030562) << 16 | a >>> 16) + f << 0)) + t[14] - 35309556) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[1] - 1530992060) << 4 | e >>> 28) + b << 0)) + t[4] + 1272893353) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[7] - 155497632) << 16 | a >>> 16) + f << 0)) + t[10] - 1094730640) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[13] + 681279174) << 4 | e >>> 28) + b << 0)) + t[0] - 358537222) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[3] - 722521979) << 16 | a >>> 16) + f << 0)) + t[6] + 76029189) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[9] - 640364487) << 4 | e >>> 28) + b << 0)) + t[12] - 421815835) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[15] + 530742520) << 16 | a >>> 16) + f << 0)) + t[2] - 995338651) << 23 | b >>> 9) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[0] - 198630844) << 6 | e >>> 26) + b << 0) | ~a)) + t[7] + 1126891415) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[14] - 1416354905) << 15 | a >>> 17) + f << 0) | ~e)) + t[5] - 57434055) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[12] + 1700485571) << 6 | e >>> 26) + b << 0) | ~a)) + t[3] - 1894986606) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[10] - 1051523) << 15 | a >>> 17) + f << 0) | ~e)) + t[1] - 2054922799) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[8] + 1873313359) << 6 | e >>> 26) + b << 0) | ~a)) + t[15] - 30611744) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[6] - 1560198380) << 15 | a >>> 17) + f << 0) | ~e)) + t[13] + 1309151649) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[4] - 145523070) << 6 | e >>> 26) + b << 0) | ~a)) + t[11] - 1120210379) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[2] + 718787259) << 15 | a >>> 17) + f << 0) | ~e)) + t[9] - 343485551) << 21 | b >>> 11) + a << 0, this.first ? (this.h0 = e + 1732584193 << 0, this.h1 = b - 271733879 << 0, this.h2 = a - 1732584194 << 0, this.h3 = f + 271733878 << 0, this.first = !1) : (this.h0 = this.h0 + e << 0, this.h1 = this.h1 + b << 0, this.h2 = this.h2 + a << 0, this.h3 = this.h3 + f << 0) + }, _0x5887c8.prototype.hex = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return _0x3a9a1b[e >> 4 & 15] + _0x3a9a1b[15 & e] + _0x3a9a1b[e >> 12 & 15] + _0x3a9a1b[e >> 8 & 15] + _0x3a9a1b[e >> 20 & 15] + _0x3a9a1b[e >> 16 & 15] + _0x3a9a1b[e >> 28 & 15] + _0x3a9a1b[e >> 24 & 15] + _0x3a9a1b[b >> 4 & 15] + _0x3a9a1b[15 & b] + _0x3a9a1b[b >> 12 & 15] + _0x3a9a1b[b >> 8 & 15] + _0x3a9a1b[b >> 20 & 15] + _0x3a9a1b[b >> 16 & 15] + _0x3a9a1b[b >> 28 & 15] + _0x3a9a1b[b >> 24 & 15] + _0x3a9a1b[a >> 4 & 15] + _0x3a9a1b[15 & a] + _0x3a9a1b[a >> 12 & 15] + _0x3a9a1b[a >> 8 & 15] + _0x3a9a1b[a >> 20 & 15] + _0x3a9a1b[a >> 16 & 15] + _0x3a9a1b[a >> 28 & 15] + _0x3a9a1b[a >> 24 & 15] + _0x3a9a1b[f >> 4 & 15] + _0x3a9a1b[15 & f] + _0x3a9a1b[f >> 12 & 15] + _0x3a9a1b[f >> 8 & 15] + _0x3a9a1b[f >> 20 & 15] + _0x3a9a1b[f >> 16 & 15] + _0x3a9a1b[f >> 28 & 15] + _0x3a9a1b[f >> 24 & 15] + }, _0x5887c8.prototype.toString = _0x5887c8.prototype.hex, _0x5887c8.prototype.digest = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return [255 & e, e >> 8 & 255, e >> 16 & 255, e >> 24 & 255, 255 & b, b >> 8 & 255, b >> 16 & 255, b >> 24 & 255, 255 & a, a >> 8 & 255, a >> 16 & 255, a >> 24 & 255, 255 & f, f >> 8 & 255, f >> 16 & 255, f >> 24 & 255] + }, _0x5887c8.prototype.array = _0x5887c8.prototype.digest, _0x5887c8.prototype.arrayBuffer = function() { + this.finalize(); + var e = new ArrayBuffer(16), + b = new Uint32Array(e); + return b[0] = this.h0, b[1] = this.h1, b[2] = this.h2, b[3] = this.h3, e + }, _0x5887c8.prototype.buffer = _0x5887c8.prototype.arrayBuffer, _0x5887c8.prototype.base64 = function() { + for (var e, b, a, f = "", c = this.array(), r = 0; r < 15;) e = c[r++], b = c[r++], a = c[r++], f += _0x2c185e[e >>> 2] + _0x2c185e[63 & (e << 4 | b >>> 4)] + _0x2c185e[63 & (b << 2 | a >>> 6)] + _0x2c185e[63 & a]; + return f + (_0x2c185e[(e = c[r]) >>> 2] + _0x2c185e[e << 4 & 63] + "==") + }; + var _0x4dd781 = _0x38ba77(); + _0x17dcbf ? _0x770f81.exports = _0x4dd781 : (_0x1702f9.md5 = _0x4dd781, _0x554fed && (void 0)(function() { + return _0x4dd781 + })) + }() + }); + +function _0x178cef(e) { + return jsvmp("484e4f4a403f52430038001eab0015840e8ee21a00000000000000621b000200001d000146000306000e271f001b000200021d00010500121b001b000b021b000b04041d0001071b000b0500000003000126207575757575757575757575757575757575757575757575757575757575757575", [, , void 0 !== _0x124d1a ? _0x124d1a : void 0, _0x178cef, e]) +} +for (var _0xb55f3e = { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, _0x3eaf64 = { + debug: function(e, b) { + let a = !1; + a = !1 + } + }, _0x233455 = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], _0x2e9f6d = [], _0x511f86 = [], _0x3d35de = 0; _0x3d35de < 256; _0x3d35de++) _0x2e9f6d[_0x3d35de] = _0x233455[_0x3d35de >> 4 & 15] + _0x233455[15 & _0x3d35de], _0x3d35de < 16 && (_0x3d35de < 10 ? _0x511f86[48 + _0x3d35de] = _0x3d35de : _0x511f86[87 + _0x3d35de] = _0x3d35de); +var _0x2ce54d = function(e) { + for (var b = e.length, a = "", f = 0; f < b;) a += _0x2e9f6d[e[f++]]; + return a + }, + _0x5960a2 = function(e) { + for (var b = e.length >> 1, a = b << 1, f = new Uint8Array(b), c = 0, r = 0; r < a;) f[c++] = _0x511f86[e.charCodeAt(r++)] << 4 | _0x511f86[e.charCodeAt(r++)]; + return f + }, + _0x4e46b6 = { + encode: _0x2ce54d, + decode: _0x5960a2 + }; + +function sign(e, b) { + return jsvmp("484e4f4a403f5243001f240fbf2031ccf317480300000000000007181b0002012e1d00921b000b171b000b02402217000a1c1b000b1726402217000c1c1b000b170200004017002646000306000e271f001b000200021d00920500121b001b000b031b000b17041d0092071b000b041e012f17000d1b000b05260a0000101c1b000b06260a0000101c1b001b000b071e01301d00931b001b000b081e00081d00941b0048021d00951b001b000b1b1d00961b0048401d009e1b001b000b031b000b16041d009f1b001b000b09221e0131241b000b031b000b09221e0131241b000b1e0a000110040a0001101d00d51b001b000b09221e0131241b000b031b000b09221e0131241b000b180a000110040a0001101d00d71b001b000b0a1e00101d00d91b001b000b0b261b000b1a1b000b190a0002101d00db1b001b000b0c261b000b221b000b210a0002101d00dc1b001b000b0d261b000b230200200a0002101d00dd1b001b000b09221e0131241b000b031b000b24040a0001101d00df1b001b000b0e1a00221e00de240a0000104903e82b1d00e31b001b000b0f260a0000101d00e41b001b000b1d1d00e71b001b000b1a4901002b1d00e81b001b000b1a4901002c1d00ea1b001b000b191d00f21b001b000b1f480e191d00f81b001b000b1f480f191d00f91b001b000b20480e191d00fb1b001b000b20480f191d00fe1b001b000b25480e191d01001b001b000b25480f191d01011b001b000b264818344900ff2f1d01031b001b000b264810344900ff2f1d01321b001b000b264808344900ff2f1d01331b001b000b264800344900ff2f1d01341b001b000b274818344900ff2f1d01351b001b000b274810344900ff2f1d01361b001b000b274808344900ff2f1d01371b001b000b274800344900ff2f1d01381b001b000b281b000b29311b000b2a311b000b2b311b000b2c311b000b2d311b000b2e311b000b2f311b000b30311b000b31311b000b32311b000b33311b000b34311b000b35311b000b36311b000b37311b000b38311b000b39311d01391b004900ff1d013a1b001b000b10261b000b281b000b2a1b000b2c1b000b2e1b000b301b000b321b000b341b000b361b000b381b000b3a1b000b291b000b2b1b000b2d1b000b2f1b000b311b000b331b000b351b000b371b000b390a0013101d013b1b001b000b0c261b000b111b000b3b041b000b3c0a0002101d013c1b001b000b12261b000b1c1b000b3b1b000b3d0a0003101d013d1b001b000b13261b000b3e0200240a0002101d013e1b000b3f0000013f000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b726152670f487c717976706733447a7d777c644e08577c70667e767d6712487c7179767067335d72657a7472677c614e057960777c7e10487c7179767067335b7a60677c616a4e07637f66747a7d60084c637b727d677c7e0b70727f7f437b727d677c7e0b4c4c7d7a747b677e726176055266777a7c1850727d65726041767d7776617a7d74507c7d67766b6721570964767177617a657661137476675c647d43617c637661676a5d727e7660097f727d74667274766006707b617c7e760761667d677a7e7607707c7d7d767067144c4c64767177617a6576614c7665727f66726776134c4c60767f767d7a667e4c7665727f667267761b4c4c64767177617a6576614c6070617a63674c75667d70677a7c7d174c4c64767177617a6576614c6070617a63674c75667d70154c4c64767177617a6576614c6070617a63674c757d134c4c756b77617a6576614c7665727f66726776124c4c77617a6576614c667d64617263637677154c4c64767177617a6576614c667d64617263637677114c4c77617a6576614c7665727f66726776144c4c60767f767d7a667e4c667d64617263637677144c4c756b77617a6576614c667d64617263637677094c60767f767d7a667e0c70727f7f40767f767d7a667e164c40767f767d7a667e4c5a57564c4176707c6177766108777c70667e767d670478766a60057e7267707b06417674566b630a4f3748723e694e77704c067072707b764c04607c7e7608707675407b72616308507675407b72616305767c72637a16767c44767151617c64607661577a60637267707b76610f717a7d775c717976706752606a7d700e7a60565c44767151617c646076610120047c63767d0467766067097a7d707c747d7a677c077c7d7661617c6104707c77761242465c47524c564b5056565756574c5641410e607660607a7c7d40677c61727476076076675a67767e10607c7e7658766a5b766176516a6776770a61767e7c65765a67767e097a7d77766b767757510c437c7a7d6776615665767d670e5e40437c7a7d6776615665767d670d706176726776567f767e767d670670727d65726009677c5772677246415f076176637f727076034f603901740a7d72677a6576707c777614487c717976706733437f66747a7d526161726a4e4a4d7b676763602c294f3c4f3c3b48233e2a4e68223f206e3b4f3d48233e2a4e68223f206e3a68206e6f48723e75233e2a4e68223f276e3b2948723e75233e2a4e68223f276e3a68246e3a0127087f7c7072677a7c7d047b61767504757a7f76107b676763293c3c7f7c70727f7b7c606708637f7267757c617e02222102222007647a7d777c646002222703647a7d02222607727d77617c7a77022225057f7a7d666b022224067a637b7c7d7602222b047a63727702222a047a637c77022123037e7270022122097e72707a7d677c607b0c7e72704c637c64766163703a0470617c60036b22220570617a7c6005756b7a7c6004637a787602212102212002212702212602212502212402212b08757a6176757c6b3c067c637661723c05337c63613c05337c63673c07707b617c7e763c0867617a77767d673c047e607a7602212a0220230665767d777c6106547c7c747f760e4c637261727e40647a67707b5c7d0a777a61767067407a747d0a707c7d607a6067767d670660647a67707b03777c7e07637b727d677c7e047b7c7c7840525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e3d03727a77017d01750161096067726167477a7e7601670972717a7f7a677a76600a677a7e766067727e6322137b72617764726176507c7d70666161767d706a0c7776657a70765e767e7c616a087f727d74667274760a6176607c7f66677a7c7d0f7265727a7f4176607c7f66677a7c7d0960706176767d477c630a60706176767d5f767567107776657a7076437a6b767f4172677a7c0a63617c77667067406671077172676776616a016309677c66707b5a7d757c08677a7e76697c7d760a677a7e766067727e6321077463665a7d757c0b7960557c7d67605f7a60670b637f66747a7d605f7a60670a677a7e766067727e63200a76657661507c7c787a760767674c60707a77017e0b606a7d67726b5661617c610c7d72677a65765f767d74677b056167705a43097563457661607a7c7d0b4c4c657661607a7c7d4c4c08707f7a767d675a770a677a7e766067727e63270b766b67767d77557a767f77046366607b03727f7f04677b767d097172607625274c707b0c75617c7e507b7261507c7776067125274c2023022022087172607625274c23022021087172607625274c22022020087172607625274c2102202702202602202507747667477a7e760220240b777c7e5d7c6745727f7a77096066716067617a7d740863617c677c707c7f02202b02202a01230e222323232323232322222323232302272302272207757c616176727f02272104717c776a096067617a7d747a756a02686e0b717c776a45727f216067610a717c776a4c7b72607b2e01350366617f02272005626676616a0a72607c7f774c607a747d096372677b7d727e762e0967674c6476717a772e063566667a772e0227270227260e4c716a6776774c6076704c777a770227250a27212a272a2524212a25097576457661607a7c7d0227240e4c232151274925647c232323232202272b02272a05607f7a7076022623074056505a5d555c037d7c6409677a7e766067727e6305757f7c7c610661727d777c7e0f7476674747447671507c7c787a7660056767647a770867674c6476717a770767674476715a770b67674c6476717a774c65210967674476717a7745210761667d7d7a7d7405757f66607b087e7c65765f7a60670660637f7a70760671765e7c657609707f7a70785f7a6067077176507f7a70780c78766a717c7261775f7a60670a717658766a717c7261770b7270677a657640677267760b647a7d777c6440677267760360477e05676172707808667d7a67477a7e76037270700a667d7a67527e7c667d670871767b72657a7c61077e6074476a637603645a5707727a775f7a60670b63617a6572706a5e7c777606706660677c7e067260607a747d0f4456514c5756455a50564c5a5d555c0479607c7d0a6176747a7c7d507c7d75096176637c616746617f04766b7a67094b3e5e403e404746510c4b3e5e403e43524a5f5c525720232323232323232323232323232323232323232323232323232323232323232320772722772b70772a2b75232371212327762a2b23232a2a2b7670752b272124760165066671707c7776067776707c777602262202262102262002262702262602262502262402262b02262a022523022522022521022520", [, , void 0, void 0 !== _0x178cef ? _0x178cef : void 0, { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [/\/web\/report/], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, () => 0, () => "03v", { + ubcode: 0 + }, { + bogusIndex: 0, + msNewTokenList: [], + moveList: [], + clickList: [], + keyboardList: [], + activeState: [], + aidList: [], + envcode: 0, + msToken: "", + msStatus: 0, + __ac_testid: "", + ttwid: "", + tt_webid: "", + tt_webid_v2: "" + }, void 0 !== _0x4e46b6 ? _0x4e46b6 : void 0, { + userAgent: b + }, (e, b) => { + let a = new Uint8Array(3); + return a[0] = e / 256, a[1] = e % 256, a[2] = b % 256, String.fromCharCode.apply(null, a) + }, (e, b) => { + let a, f = [], + c = 0, + r = ""; + for (let e = 0; e < 256; e++) f[e] = e; + for (let b = 0; b < 256; b++) c = (c + f[b] + e.charCodeAt(b % e.length)) % 256, a = f[b], f[b] = f[c], f[c] = a; + let t = 0; + c = 0; + for (let e = 0; e < b.length; e++) c = (c + f[t = (t + 1) % 256]) % 256, a = f[t], f[t] = f[c], f[c] = a, r += String.fromCharCode(b.charCodeAt(e) ^ f[(f[t] + f[c]) % 256]); + return r + }, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), "undefined" != typeof Date ? Date : void 0, () => 0, (e, b, a, f, c, r, t, d, i, n, s, o, l, _, x, u, h, p, y) => { + let v = new Uint8Array(19); + return v[0] = e, v[1] = s, v[2] = b, v[3] = o, v[4] = a, v[5] = l, v[6] = f, v[7] = _, v[8] = c, v[9] = x, v[10] = r, v[11] = u, v[12] = t, v[13] = h, v[14] = d, v[15] = p, v[16] = i, v[17] = y, v[18] = n, String.fromCharCode.apply(null, v) + }, e => String.fromCharCode(e), (e, b, a) => String.fromCharCode(e) + String.fromCharCode(b) + a, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), , sign, e, void 0]) +} + +//export text function +module.exports = sign; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fb7f9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +**/.vs/ +**/*.user +**/*.suo +frontend/node_modules/ +frontend/dist/ +build.log +webapi-build.log +webapi-build-no-restore.log +artifacts/ diff --git a/ConsoleApp1/ConsoleApp1.csproj b/ConsoleApp1/ConsoleApp1.csproj new file mode 100644 index 0000000..05b303a --- /dev/null +++ b/ConsoleApp1/ConsoleApp1.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/ConsoleApp1/Program.cs b/ConsoleApp1/Program.cs new file mode 100644 index 0000000..f2d9383 --- /dev/null +++ b/ConsoleApp1/Program.cs @@ -0,0 +1,40 @@ +using WebSocketSharp; +using WebSocketSharp.Server; + +namespace ConsoleApp1 +{ + internal class Program + { + static void Main(string[] args) + { + var wssv = new WebSocketServer("ws://0.0.0.0:8080"); + + wssv.AddWebSocketService("/ws"); + + wssv.Start(); + Console.WriteLine("WebSocket服务器已启动 ws://localhost:8080/ws"); + + Console.ReadKey(); + wssv.Stop(); + } + } + + // 处理连接 + public class WsHandler : WebSocketBehavior + { + protected override void OnOpen() + { + Console.WriteLine("客户端连接"); + } + + protected override void OnMessage(MessageEventArgs e) + { + Console.WriteLine($"收到消息: {e.Data}"); + } + + protected override void OnClose(CloseEventArgs e) + { + Console.WriteLine("客户端断开"); + } + } +} diff --git a/LiveRecorder.sln b/LiveRecorder.sln new file mode 100644 index 0000000..213a278 --- /dev/null +++ b/LiveRecorder.sln @@ -0,0 +1,99 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.WebApi", "src\LiveRecorder.WebApi\LiveRecorder.WebApi.csproj", "{71D7526B-862F-4DAD-A229-76EFBC24661D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Domain", "src\LiveRecorder.Domain\LiveRecorder.Domain.csproj", "{CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Application", "src\LiveRecorder.Application\LiveRecorder.Application.csproj", "{3B807934-A995-4F7F-8A4E-878D161F95A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Infrastructure", "src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj", "{A502FCC8-83F9-402B-A027-D020D34624E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp1", "ConsoleApp1\ConsoleApp1.csproj", "{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x64.ActiveCfg = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x64.Build.0 = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x86.ActiveCfg = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x86.Build.0 = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|Any CPU.Build.0 = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x64.ActiveCfg = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x64.Build.0 = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x86.ActiveCfg = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x86.Build.0 = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x64.ActiveCfg = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x64.Build.0 = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x86.ActiveCfg = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x86.Build.0 = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|Any CPU.Build.0 = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x64.ActiveCfg = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x64.Build.0 = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x86.ActiveCfg = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x86.Build.0 = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x64.Build.0 = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x86.Build.0 = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|Any CPU.Build.0 = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x64.ActiveCfg = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x64.Build.0 = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x86.ActiveCfg = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x86.Build.0 = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x64.Build.0 = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x86.Build.0 = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|Any CPU.Build.0 = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x64.ActiveCfg = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x64.Build.0 = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x86.ActiveCfg = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x86.Build.0 = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x64.Build.0 = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x86.Build.0 = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|Any CPU.Build.0 = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x64.ActiveCfg = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x64.Build.0 = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x86.ActiveCfg = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {71D7526B-862F-4DAD-A229-76EFBC24661D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {3B807934-A995-4F7F-8A4E-878D161F95A1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A502FCC8-83F9-402B-A027-D020D34624E1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8ad531 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# Live Recorder + +Live Recorder is a multi-platform live recording system built with: + +- Backend: `.NET 8 Web API + EF Core + SQLite` +- Frontend: `Vue 3 + TypeScript + Element Plus` + +The current production-ready adapter is `DouyinLivePlatformAdapter`. The architecture keeps platform-specific logic isolated so Bilibili, Huya, Douyu, and Kuaishou can be added without changing application services. + +## Architecture + +```text +. +├─ src +│ ├─ LiveRecorder.Domain +│ │ ├─ Entities +│ │ └─ Enums +│ ├─ LiveRecorder.Application +│ │ ├─ Abstractions +│ │ ├─ Common +│ │ ├─ Models +│ │ └─ Services +│ ├─ LiveRecorder.Infrastructure +│ │ ├─ Persistence +│ │ ├─ Platforms +│ │ │ ├─ Douyin +│ │ │ ├─ Bilibili +│ │ │ └─ Huya +│ │ └─ Services +│ └─ LiveRecorder.WebApi +│ ├─ Controllers +│ └─ Middleware +└─ frontend + └─ src + ├─ api + ├─ components + ├─ router + ├─ stores + └─ views +``` + +## Core design + +### Domain + +- `LiveRoom`: platform, room id, source URL, title, anchor, cover, availability status +- `RecordTask`: recording state, quality, output format, ffmpeg process id, output path +- `RecordResult`: final file or segment directory, file size, duration, final status +- `SystemLogEntry`: persistent system log +- `AppSetting`: runtime settings store +- `UserAccount`, `UserSession`: lightweight token-based sign-in + +### Application + +- `ILivePlatformAdapter` + - `ParseRoomAsync` + - `GetLiveStatusAsync` + - `GetStreamUrlAsync` +- `ILivePlatformAdapterFactory` +- `LiveRoomService` +- `RecordService` +- `SystemSettingsService` +- `SystemLogService` +- `AuthService` + +### Infrastructure + +- `DouyinHttpClient` + - wraps User-Agent, Referer, Cookie, and `ttwid` handling +- `DouyinLivePlatformAdapter` + - share-link resolution, room id extraction, status check, stream selection +- `FfmpegService` + - single file mode, segmented mode, reconnect arguments, recording templates +- `LiveRoomPollingBackgroundService` + - background polling scheduler for live detection and auto-start recording +- `LiveRecorderDbContext` + +### Web API + +- `AuthController` +- `LiveRoomsController` +- `RecordTasksController` +- `LogsController` +- `SettingsController` +- `ApiTokenAuthenticationMiddleware` +- `ExceptionHandlingMiddleware` + +## Implemented features + +### Douyin adapter + +- resolve share links and redirects +- extract real `roomId` +- call `https://live.douyin.com/webcast/room/web/enter/` +- preload `ttwid` when needed +- parse both: + - `live_core_sdk_data.pull_data.stream_data` + - `flv_pull_url / hls_pull_url_map` +- select the highest quality by default or respect configured quality + +### Background polling scheduler + +- configurable polling interval +- configurable enable/disable switch +- configurable auto-start switch +- updates room status in background +- auto-creates a recording task when a room is live and no task is running +- also works as a recovery layer when ffmpeg exits unexpectedly while the room is still live + +### Recording enhancements + +- save format setting: `MP4` or `TS` +- save mode setting: + - `SingleFile` + - `Segmented` +- recording template setting: + - `StreamCopy` + - `BalancedMp4` + - `ArchiveTs` +- reconnect tuning: + - enable/disable reconnect + - max reconnect delay + - read/write timeout + +### Frontend + +- login page +- live room management +- recording task list +- recording task detail +- log viewer +- system settings +- route lazy loading +- vendor chunk splitting in Vite + +## Settings exposed in UI + +- ffmpeg path +- output root +- default quality +- live save format +- save mode +- recording template +- segment duration +- reconnect switch and reconnect delay +- read/write timeout +- background polling switch +- auto-start-on-live switch +- polling interval +- Douyin User-Agent / Referer / Cookie + +## Run + +### Backend + +```powershell +dotnet restore LiveRecorder.sln +dotnet build LiveRecorder.sln --no-restore -m:1 +dotnet run --project src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj --urls http://localhost:5000 +``` + +Default account: + +- username: `admin` +- password: `Admin@123` + +Swagger: + +- [http://localhost:5000/swagger](http://localhost:5000/swagger) + +### Frontend + +```powershell +cd frontend +npm install +npm run dev +``` + +Frontend dev server: + +- [http://localhost:5173](http://localhost:5173) + +## Verified locally + +- `dotnet build LiveRecorder.sln --no-restore -m:1` +- `npm run build` + +## Next steps + +1. Implement full adapters for Bilibili, Huya, Douyu, and Kuaishou. +2. Add per-room recording preferences instead of relying only on global defaults. +3. Add full-text log search and time-range filters. +4. Replace lightweight token sessions with JWT or external SSO if needed. diff --git a/dycast_repo b/dycast_repo new file mode 160000 index 0000000..dc3ab9a --- /dev/null +++ b/dycast_repo @@ -0,0 +1 @@ +Subproject commit dc3ab9ad4633b21ec255942e5262bba78750a1c3 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a11aff5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Live Recorder Console + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8a6775b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2046 @@ +{ + "name": "live-recorder-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "live-recorder-frontend", + "version": "0.1.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.9.0", + "element-plus": "^2.10.1", + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1f29d0d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "live-recorder-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.9.0", + "element-plus": "^2.10.1", + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..c04eb9d --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,33 @@ +import axios from "axios"; + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api", + timeout: 30000 +}); + +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem("live-recorder-token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem("live-recorder-token"); + localStorage.removeItem("live-recorder-user"); + + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue new file mode 100644 index 0000000..e6d6d0d --- /dev/null +++ b/frontend/src/components/layout/MainLayout.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..015a333 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,6 @@ +declare module "*.vue" { + import type { DefineComponent } from "vue"; + + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..ba1c71a --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import App from "./App.vue"; +import router from "./router"; +import "./styles/main.css"; + +createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..47e8b7f --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,74 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { useAuthStore } from "@/stores/auth"; + +const LoginView = () => import("@/views/LoginView.vue"); +const MainLayout = () => import("@/components/layout/MainLayout.vue"); +const LiveRoomsView = () => import("@/views/LiveRoomsView.vue"); +const RecordTasksView = () => import("@/views/RecordTasksView.vue"); +const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue"); +const LogsView = () => import("@/views/LogsView.vue"); +const SettingsView = () => import("@/views/SettingsView.vue"); + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: LoginView, + meta: { anonymous: true } + }, + { + path: "/", + component: MainLayout, + children: [ + { + path: "", + redirect: "/live-rooms" + }, + { + path: "live-rooms", + name: "live-rooms", + component: LiveRoomsView + }, + { + path: "record-tasks", + name: "record-tasks", + component: RecordTasksView + }, + { + path: "record-tasks/:id", + name: "record-task-detail", + component: RecordTaskDetailView, + props: true + }, + { + path: "logs", + name: "logs", + component: LogsView + }, + { + path: "settings", + name: "settings", + component: SettingsView + } + ] + } + ] +}); + +router.beforeEach((to) => { + const authStore = useAuthStore(); + + if (!to.meta.anonymous && !authStore.isAuthenticated) { + return { name: "login" }; + } + + if (to.name === "login" && authStore.isAuthenticated) { + return { name: "live-rooms" }; + } + + return true; +}); + +export default router; diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..f3663de --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,60 @@ +import { computed, ref } from "vue"; +import { defineStore } from "pinia"; +import apiClient from "@/api/client"; +import type { AuthenticatedUser, LoginResponse } from "@/types"; + +const USER_STORAGE_KEY = "live-recorder-user"; +const TOKEN_STORAGE_KEY = "live-recorder-token"; + +export const useAuthStore = defineStore("auth", () => { + const token = ref(localStorage.getItem(TOKEN_STORAGE_KEY) ?? ""); + const user = ref( + (() => { + const raw = localStorage.getItem(USER_STORAGE_KEY); + return raw ? (JSON.parse(raw) as AuthenticatedUser) : null; + })() + ); + + const isAuthenticated = computed(() => Boolean(token.value)); + + function persistSession(session: LoginResponse) { + token.value = session.token; + user.value = session.user; + localStorage.setItem(TOKEN_STORAGE_KEY, session.token); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user)); + } + + function clearSession() { + token.value = ""; + user.value = null; + localStorage.removeItem(TOKEN_STORAGE_KEY); + localStorage.removeItem(USER_STORAGE_KEY); + } + + async function login(username: string, password: string) { + const { data } = await apiClient.post("/auth/login", { + username, + password + }); + + persistSession(data); + return data; + } + + async function logout() { + try { + await apiClient.post("/auth/logout"); + } finally { + clearSession(); + } + } + + return { + token, + user, + isAuthenticated, + login, + logout, + clearSession + }; +}); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..e3207a7 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,552 @@ +:root { + color-scheme: light; + color: #171614; + background: #f6f4ef; + font-family: "SF Pro Display", "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif; + + --bg-base: #f6f4ef; + --bg-soft: #fbfaf7; + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: #fffcf7; + --surface-muted: #f3f0ea; + --border-light: rgba(82, 74, 63, 0.08); + --border-base: rgba(82, 74, 63, 0.12); + --border-strong: rgba(82, 74, 63, 0.18); + --text-primary: #171614; + --text-secondary: #6d675f; + --text-tertiary: #948d83; + --accent: #3e5f7c; + --accent-strong: #2f4c66; + --accent-soft: rgba(62, 95, 124, 0.12); + --danger-soft: rgba(183, 80, 70, 0.1); + --shadow-soft: 0 10px 28px rgba(31, 24, 18, 0.05); + --shadow-card: 0 18px 48px rgba(31, 24, 18, 0.07); + --shadow-hover: 0 20px 40px rgba(31, 24, 18, 0.1); + + --el-color-primary: var(--accent); + --el-color-primary-light-3: #6683a0; + --el-color-primary-light-5: #8da4ba; + --el-color-primary-light-7: #bcc9d7; + --el-color-primary-light-8: #d8e0e8; + --el-color-primary-light-9: #edf2f6; + --el-color-primary-dark-2: var(--accent-strong); + --el-text-color-primary: var(--text-primary); + --el-text-color-regular: var(--text-secondary); + --el-text-color-secondary: var(--text-tertiary); + --el-border-color: var(--border-base); + --el-border-color-light: var(--border-light); + --el-border-radius-base: 10px; + --el-bg-color: transparent; + --el-fill-color-blank: rgba(255, 255, 255, 0.72); + --el-fill-color-light: rgba(244, 241, 236, 0.94); + --el-mask-color: rgba(17, 19, 17, 0.35); +} + +* { + box-sizing: border-box; +} + +*::selection { + color: #ffffff; + background: rgba(62, 95, 124, 0.75); +} + +html, +body, +#app { + margin: 0; + min-height: 100%; +} + +body { + color: var(--text-primary); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.8) 0, rgba(255, 255, 255, 0) 32%), + radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 28%), + linear-gradient(180deg, #faf8f3 0%, #f6f4ef 42%, #f1eee9 100%); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body, +button, +input, +textarea, +select { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: none; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 4px; +} + +.page-header > div:first-child { + flex: 1; + min-width: 0; +} + +.page-title { + margin: 0; + font-size: clamp(28px, 3vw, 38px); + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1.02; + color: var(--text-primary); +} + +.page-subtitle { + max-width: 760px; + margin: 12px 0 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.8; +} + +.surface-card { + position: relative; + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--border-base); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 252, 247, 0.84) 100%); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(14px); + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease; +} + +.surface-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0)); + pointer-events: none; +} + +.surface-card:hover { + transform: translateY(-2px); + border-color: var(--border-strong); + box-shadow: var(--shadow-card); +} + +.surface-card .el-card__body { + padding: 22px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(208px, 1fr)); + gap: 16px; + margin-bottom: 2px; +} + +.stat-card { + position: relative; + overflow: hidden; + padding: 18px 18px 16px; + border-radius: 12px; + border: 1px solid var(--border-light); + background: + radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 48%), + linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(250, 248, 244, 0.92) 100%); + box-shadow: 0 12px 26px rgba(31, 24, 18, 0.05); + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease; +} + +.stat-card::after { + content: ""; + position: absolute; + top: 14px; + right: 14px; + width: 10px; + height: 10px; + border-radius: 999px; + background: rgba(62, 95, 124, 0.12); + box-shadow: 0 0 0 6px rgba(62, 95, 124, 0.05); +} + +.stat-card:hover { + transform: translateY(-3px); + border-color: var(--border-base); + box-shadow: 0 18px 32px rgba(31, 24, 18, 0.08); +} + +.stat-card__label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.stat-card__value { + margin-top: 14px; + font-size: 32px; + font-weight: 700; + letter-spacing: -0.05em; + color: var(--text-primary); +} + +.stat-card__hint { + margin-top: 8px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.65; +} + +.section-title { + margin: 0 0 6px; + font-size: 17px; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.section-subtitle { + margin: 0 0 18px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.75; +} + +.token-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.token-chip { + display: inline-flex; + align-items: center; + padding: 7px 11px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 12px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.monospace { + font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", "Consolas", monospace; +} + +.el-card { + --el-card-border-color: transparent; + background: transparent; +} + +.el-button { + height: 38px; + padding: 0 15px; + border-radius: 10px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background-color 0.18s ease, + color 0.18s ease; +} + +.el-button.el-button--default:not(.is-text):not(.is-link) { + border-color: rgba(91, 110, 129, 0.16); + background: rgba(255, 255, 255, 0.86); + color: #22303e; +} + +.el-button:not(.is-disabled):hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(31, 24, 18, 0.08); +} + +.el-button:not(.is-disabled):active { + transform: translateY(0); + box-shadow: 0 6px 14px rgba(31, 24, 18, 0.06); +} + +.el-button.el-button--primary { + background: linear-gradient(180deg, #4a6984 0%, #3e5f7c 100%) !important; + border-color: #35516a !important; + color: #f7fafc !important; + box-shadow: 0 12px 22px rgba(62, 95, 124, 0.2); +} + +.el-button.el-button--primary:not(.is-disabled):hover { + background: linear-gradient(180deg, #3d5b75 0%, #2f4c66 100%) !important; + border-color: #2f4c66 !important; + color: #f7fafc !important; +} + +.el-button.el-button--primary:not(.is-disabled):active { + background: #2f4c66 !important; + border-color: #29445c !important; +} + +.el-button.is-plain:not(.el-button--danger):not(.el-button--primary) { + background: rgba(62, 95, 124, 0.08) !important; + border-color: rgba(62, 95, 124, 0.18) !important; + color: #35536d !important; +} + +.el-button.el-button--danger.is-plain { + background: rgba(183, 80, 70, 0.08) !important; + border-color: rgba(183, 80, 70, 0.22) !important; + color: #a1473d !important; +} + +.el-button.is-disabled, +.el-button.is-disabled:hover, +.el-button.is-disabled:active { + background: rgba(233, 237, 241, 0.92) !important; + border-color: rgba(91, 110, 129, 0.12) !important; + color: #9aa5b1 !important; + box-shadow: none !important; + transform: none !important; +} + +.el-button.is-text { + padding-left: 8px; + padding-right: 8px; +} + +.el-input, +.el-select, +.el-date-editor, +.el-input-number { + width: 100%; +} + +.el-input__wrapper, +.el-select__wrapper, +.el-textarea__inner, +.el-input-number, +.el-input-number__decrease, +.el-input-number__increase { + border-radius: 10px; +} + +.el-input__wrapper, +.el-select__wrapper, +.el-textarea__inner, +.el-input-number { + background: rgba(255, 255, 255, 0.72); + box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.1) !important; + transition: + box-shadow 0.18s ease, + background-color 0.18s ease, + transform 0.18s ease; +} + +.el-input__wrapper:hover, +.el-select__wrapper:hover, +.el-textarea__inner:hover, +.el-input-number:hover { + box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.16) !important; +} + +.el-input__wrapper.is-focus, +.el-select__wrapper.is-focused, +.el-textarea__inner:focus, +.el-input-number:focus-within { + background: rgba(255, 255, 255, 0.92); + box-shadow: + inset 0 0 0 1px rgba(62, 95, 124, 0.34), + 0 0 0 4px rgba(62, 95, 124, 0.08) !important; +} + +.el-textarea__inner { + line-height: 1.7; +} + +.el-form-item { + margin-bottom: 18px; +} + +.el-form-item__label { + padding-bottom: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary) !important; +} + +.el-switch { + --el-switch-on-color: var(--accent); + --el-switch-off-color: rgba(120, 114, 105, 0.28); +} + +.el-tag { + border: none; + border-radius: 999px; + height: 28px; + padding: 0 10px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.el-tag--success { + background: rgba(40, 132, 95, 0.12); + color: #1d6d53; +} + +.el-tag--warning { + background: rgba(189, 131, 37, 0.12); + color: #8a6221; +} + +.el-tag--danger { + background: rgba(183, 80, 70, 0.12); + color: #9c4036; +} + +.el-tag--info, +.el-tag.el-tag--primary { + background: rgba(101, 95, 86, 0.12); + color: #5d554d; +} + +.el-table { + --el-table-border-color: transparent; + --el-table-row-hover-bg-color: rgba(62, 95, 124, 0.05); + --el-table-current-row-bg-color: rgba(62, 95, 124, 0.07); + --el-table-header-bg-color: transparent; + --el-table-bg-color: transparent; + --el-fill-color-blank: transparent; + background: transparent; +} + +.el-table::before, +.el-table__inner-wrapper::before { + display: none; +} + +.el-table th.el-table__cell { + padding: 4px 0 12px; + border-bottom: 1px solid var(--border-base); + background: transparent; +} + +.el-table th.el-table__cell > .cell { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.el-table td.el-table__cell { + padding: 16px 0; + border-bottom: 1px solid rgba(82, 74, 63, 0.06); + background: transparent; +} + +.el-table .cell { + line-height: 1.45; +} + +.el-table tr { + background: transparent; +} + +.el-descriptions { + --el-descriptions-table-border: 1px solid rgba(82, 74, 63, 0.08); +} + +.el-descriptions__body .el-descriptions__table { + border-radius: 10px; + overflow: hidden; +} + +.el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label { + background: var(--surface-muted); + color: var(--text-tertiary); + font-weight: 600; +} + +.el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { + background: rgba(255, 255, 255, 0.72); + color: var(--text-primary); +} + +.el-dialog { + border-radius: 16px; + border: 1px solid var(--border-base); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(252, 249, 244, 0.94)); + box-shadow: var(--shadow-hover); +} + +.el-dialog__header { + margin: 0; + padding: 22px 24px 10px; +} + +.el-dialog__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.el-dialog__body { + padding: 12px 24px 6px; +} + +.el-dialog__footer { + padding: 12px 24px 22px; +} + +.el-empty__description, +.el-result__subtitle, +.el-empty__description p { + color: var(--text-secondary); +} + +@media (max-width: 1200px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .page-header { + flex-direction: column; + align-items: flex-start; + } + + .page-title { + font-size: 30px; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .surface-card .el-card__body { + padding: 18px; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..f732bb0 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,202 @@ +export interface AuthenticatedUser { + userId: string; + username: string; + displayName: string; + token: string; + expiresAt: string; +} + +export interface LoginResponse { + token: string; + expiresAt: string; + user: AuthenticatedUser; +} + +export interface LiveRoom { + id: string; + platform: number; + platformName: string; + sourceUrl: string; + roomId: string; + normalizedUrl: string; + title?: string; + anchorName?: string; + coverUrl?: string; + isEnabled: boolean; + availabilityStatus: number; + lastCheckedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface RecordTask { + id: string; + liveRoomId: string; + recordSessionId: string; + segmentIndex: number; + liveRoomTitle: string; + platform: number; + roomId: string; + status: number; + preferredQuality: string; + outputFormat: number; + streamUrl?: string; + outputFilePath?: string; + recorderProcessId?: number; + errorMessage?: string; + createdAt: string; + startedAt?: string; + endedAt?: string; + durationSeconds?: number; +} + +export interface RecordResult { + id: string; + filePath: string; + fileSizeBytes?: number; + durationSeconds?: number; + danmakuFilePath?: string; + danmakuMessageCount: number; + finalStatus: number; + errorMessage?: string; + createdAt: string; +} + +export interface RecordSession { + id: string; + liveRoomId: string; + liveRoomTitle: string; + platform: number; + roomId: string; + status: number; + preferredQuality: string; + outputFormat: number; + saveMode: number; + activeSegmentIndex: number; + segmentCount: number; + recorderProcessId?: number; + errorMessage?: string; + createdAt: string; + startedAt?: string; + endedAt?: string; + totalFileSizeBytes: number; + totalDanmakuMessageCount: number; + tasks: RecordTask[]; +} + +export interface SystemLog { + id: string; + level: number; + category: string; + message: string; + detail?: string; + liveRoomId?: string; + recordSessionId?: string; + recordTaskId?: string; + createdAt: string; +} + +export interface RecordTaskDetail { + task: RecordTask; + result?: RecordResult; + logs: SystemLog[]; +} + +export interface DeleteCompletedRecordTasksResult { + deletedTaskIds: string[]; + deletedFilePaths: string[]; + deletedDanmakuPaths: string[]; + deletedSessionIds: string[]; + warnings: string[]; +} + +export interface RecordPreviewTicket { + url: string; + expiresAt: string; +} + +export interface RecordSessionDetail { + session: RecordSession; + logs: SystemLog[]; +} + +export interface SystemSettings { + ffmpegPath: string; + outputRoot: string; + outputDirectoryTemplate: string; + outputFileNameTemplate: string; + defaultQuality: string; + defaultOutputFormat: number; + saveMode: number; + recordingTemplate: number; + segmentDurationMinutes: number; + enableAutoReconnect: boolean; + reconnectDelayMaxSeconds: number; + readWriteTimeoutMilliseconds: number; + enableDanmakuRecording: boolean; + danmakuIncludeNonChatEvents: boolean; + danmakuMinPollIntervalMilliseconds: number; + danmakuRetryDelayMaxSeconds: number; + enableBackgroundPolling: boolean; + autoStartRecordingOnLive: boolean; + pollingIntervalSeconds: number; + enableEmailNotification: boolean; + emailSmtpHost: string; + emailSmtpPort: number; + emailUseSsl: boolean; + emailUsername: string; + emailPassword: string; + emailFromAddress: string; + emailFromDisplayName: string; + emailToAddresses: string; + notifyOnLiveStarted: boolean; + notifyOnException: boolean; + emailLiveStartedSubjectTemplate: string; + emailLiveStartedBodyTemplateHtml: string; + emailExceptionSubjectTemplate: string; + emailExceptionBodyTemplateHtml: string; + douyinUserAgent: string; + douyinReferer: string; + douyinCookie: string; +} + +export const availabilityLabelMap: Record = { + 0: "Unknown", + 1: "Offline", + 2: "Live" +}; + +export const taskStatusLabelMap: Record = { + 0: "Pending", + 1: "Starting", + 2: "Running", + 3: "Stopping", + 4: "Completed", + 5: "Failed", + 6: "Stopped" +}; + +export const sessionStatusLabelMap = taskStatusLabelMap; + +export const logLevelLabelMap: Record = { + 0: "Trace", + 1: "Info", + 2: "Warning", + 3: "Error" +}; + +export const outputFormatLabelMap: Record = { + 0: "MP4", + 1: "TS" +}; + +export const saveModeLabelMap: Record = { + 0: "Single File", + 1: "Segmented" +}; + +export const recordingTemplateLabelMap: Record = { + 0: "Stream Copy", + 1: "Balanced MP4", + 2: "Archive TS" +}; diff --git a/frontend/src/views/LiveRoomsView.vue b/frontend/src/views/LiveRoomsView.vue new file mode 100644 index 0000000..63205f1 --- /dev/null +++ b/frontend/src/views/LiveRoomsView.vue @@ -0,0 +1,570 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..72557c4 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue new file mode 100644 index 0000000..c71cf22 --- /dev/null +++ b/frontend/src/views/LogsView.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/views/RecordTaskDetailView.vue b/frontend/src/views/RecordTaskDetailView.vue new file mode 100644 index 0000000..6122dee --- /dev/null +++ b/frontend/src/views/RecordTaskDetailView.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue new file mode 100644 index 0000000..812c933 --- /dev/null +++ b/frontend/src/views/RecordTasksView.vue @@ -0,0 +1,738 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..0a8a977 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,657 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..244d8a3 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..16c89c7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import path from "node:path"; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src") + } + }, + build: { + chunkSizeWarningLimit: 900, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes("node_modules/element-plus")) { + return "element-plus"; + } + + if (id.includes("node_modules/@element-plus/icons-vue")) { + return "element-plus-icons"; + } + + if (id.includes("node_modules/vue") || id.includes("node_modules/pinia") || id.includes("node_modules/vue-router")) { + return "vue-vendor"; + } + + if (id.includes("node_modules/axios")) { + return "network-vendor"; + } + } + } + } + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:5000", + changeOrigin: true + } + } + } +}); diff --git a/src/LiveRecorder.Application/Abstractions/Auth/IAuthService.cs b/src/LiveRecorder.Application/Abstractions/Auth/IAuthService.cs new file mode 100644 index 0000000..252cb79 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Auth/IAuthService.cs @@ -0,0 +1,12 @@ +using LiveRecorder.Application.Models.Auth; + +namespace LiveRecorder.Application.Abstractions.Auth; + +public interface IAuthService +{ + Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default); + + Task LogoutAsync(string token, CancellationToken cancellationToken = default); + + Task ValidateTokenAsync(string token, CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Logging/ISystemLogService.cs b/src/LiveRecorder.Application/Abstractions/Logging/ISystemLogService.cs new file mode 100644 index 0000000..131ee47 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Logging/ISystemLogService.cs @@ -0,0 +1,24 @@ +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Logging; + +public interface ISystemLogService +{ + Task WriteAsync( + SystemLogLevel level, + string category, + string message, + string? detail = null, + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + CancellationToken cancellationToken = default); + + Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs b/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs new file mode 100644 index 0000000..d834d45 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs @@ -0,0 +1,19 @@ +using LiveRecorder.Domain.Entities; +using LiveRecorder.Application.Models.Settings; + +namespace LiveRecorder.Application.Abstractions.Notifications; + +public interface IEmailNotificationService +{ + Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default); + + Task SendExceptionAsync( + string source, + string summary, + string? detail = null, + LiveRoom? liveRoom = null, + RecordTask? recordTask = null, + CancellationToken cancellationToken = default); + + Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs new file mode 100644 index 0000000..79ea6b7 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs @@ -0,0 +1,121 @@ +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Persistence; + +public interface IAppSettingRepository +{ + Task GetByKeyAsync(string key, CancellationToken cancellationToken = default); + + Task> ListAsync(CancellationToken cancellationToken = default); + + Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default); + + void Update(AppSetting setting); +} + +public interface ILiveRoomRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task GetByPlatformRoomIdAsync( + LivePlatformType platformType, + string roomId, + CancellationToken cancellationToken = default); + + Task> ListAsync(CancellationToken cancellationToken = default); + + Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default); + + void Remove(LiveRoom liveRoom); +} + +public interface IRecordTaskRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + + Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default); + + Task> ListBySessionIdAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + Task GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default); + + Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default); + + void Remove(RecordTask recordTask); + + void RemoveRange(IEnumerable recordTasks); +} + +public interface IRecordSessionRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default); + + Task GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default); + + Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default); + + void Remove(RecordSession recordSession); +} + +public interface IRecordResultRepository +{ + Task GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default); + + Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default); + + void Update(RecordResult recordResult); + + void Remove(RecordResult recordResult); + + void RemoveRange(IEnumerable recordResults); +} + +public interface ISystemLogRepository +{ + Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default); + + Task> ListByRecordTaskIdsAsync( + IReadOnlyCollection recordTaskIds, + CancellationToken cancellationToken = default); + + Task> ListByRecordSessionIdsAsync( + IReadOnlyCollection recordSessionIds, + CancellationToken cancellationToken = default); + + Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default); + + void RemoveRange(IEnumerable entries); +} + +public interface IUserAccountRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default); + + Task AddAsync(UserAccount userAccount, CancellationToken cancellationToken = default); +} + +public interface IUserSessionRepository +{ + Task GetByTokenAsync(string token, CancellationToken cancellationToken = default); + + Task AddAsync(UserSession session, CancellationToken cancellationToken = default); + + void Update(UserSession session); +} + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Platforms/DanmakuContracts.cs b/src/LiveRecorder.Application/Abstractions/Platforms/DanmakuContracts.cs new file mode 100644 index 0000000..052b4e6 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Platforms/DanmakuContracts.cs @@ -0,0 +1,42 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Platforms; + +public interface ILiveDanmakuAdapter +{ + LivePlatformType PlatformType { get; } + + bool CanHandle(LivePlatformType platformType); + + Task ConnectAsync(DanmakuConnectionContext context, CancellationToken cancellationToken = default); +} + +public interface ILiveDanmakuAdapterFactory +{ + ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType); +} + +public interface ILiveDanmakuConnection : IAsyncDisposable +{ + Task StartAsync(Func onEvent, CancellationToken cancellationToken = default); +} + +public sealed record DanmakuConnectionContext( + Guid LiveRoomId, + Guid RecordSessionId, + LivePlatformType PlatformType, + string RoomId, + string? AnchorName, + string? Title, + string? SourceUrl, + int MinPollIntervalMilliseconds = 1000, + int RetryDelayMaxSeconds = 15); + +public sealed record DanmakuEvent( + string Type, + string? User, + string? UserId, + string? Content, + DateTimeOffset OccurredAt, + string RawPayload, + IReadOnlyDictionary? Extra = null); diff --git a/src/LiveRecorder.Application/Abstractions/Platforms/PlatformContracts.cs b/src/LiveRecorder.Application/Abstractions/Platforms/PlatformContracts.cs new file mode 100644 index 0000000..e409418 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Platforms/PlatformContracts.cs @@ -0,0 +1,60 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Platforms; + +public interface ILivePlatformAdapter +{ + LivePlatformType PlatformType { get; } + + bool CanHandle(string input); + + Task ParseRoomAsync(string input, CancellationToken cancellationToken = default); + + Task GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default); + + Task GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default); +} + +public interface ILivePlatformAdapterFactory +{ + ILivePlatformAdapter GetByPlatform(LivePlatformType platformType); + + ILivePlatformAdapter GetByInput(string input); +} + +public sealed record ParsedLiveRoom( + LivePlatformType PlatformType, + string RoomId, + string SourceUrl, + string NormalizedUrl); + +public sealed record LiveStatusSnapshot( + bool IsLive, + string? Title, + string? AnchorName, + string? CoverUrl, + int? StatusCode, + string? RawStatus); + +public sealed record StreamQualityOption( + string QualityKey, + string QualityName, + string Url, + string Protocol, + int Rank); + +public sealed record StreamInputHeaders( + string? UserAgent, + string? Referer, + string? Cookie, + IReadOnlyDictionary? AdditionalHeaders = null); + +public sealed record StreamUrlResult( + string SelectedQuality, + string SelectedProtocol, + string SelectedUrl, + StreamInputHeaders? InputHeaders, + IReadOnlyList AvailableQualities); diff --git a/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs new file mode 100644 index 0000000..09645b1 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs @@ -0,0 +1,32 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Entities; + +namespace LiveRecorder.Application.Abstractions.Recording; + +public interface IFfmpegService +{ + Task StartAsync( + RecordSession recordSession, + RecordTask initialTask, + StreamUrlResult streamUrlResult, + CancellationToken cancellationToken = default); + + Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + Task StopAndWaitAsync( + Guid recordSessionId, + bool markAsCompletedOnExit, + TimeSpan timeout, + CancellationToken cancellationToken = default); + + Task KillAndWaitAsync( + Guid recordSessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default); + + Task TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + bool IsRunning(Guid recordSessionId); +} diff --git a/src/LiveRecorder.Application/Abstractions/Recording/IRecordMediaService.cs b/src/LiveRecorder.Application/Abstractions/Recording/IRecordMediaService.cs new file mode 100644 index 0000000..2c4d57f --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Recording/IRecordMediaService.cs @@ -0,0 +1,17 @@ +namespace LiveRecorder.Application.Abstractions.Recording; + +public interface IRecordMediaService +{ + Task CreatePreviewTicketAsync(Guid recordTaskId, CancellationToken cancellationToken = default); + + Task ResolvePreviewAsync(string ticket, CancellationToken cancellationToken = default); +} + +public sealed record RecordPreviewTicketGrant( + string Ticket, + DateTimeOffset ExpiresAt); + +public sealed record RecordMediaFile( + Guid RecordTaskId, + string FilePath, + string ContentType); diff --git a/src/LiveRecorder.Application/Abstractions/Settings/ISystemSettingsService.cs b/src/LiveRecorder.Application/Abstractions/Settings/ISystemSettingsService.cs new file mode 100644 index 0000000..757aaf2 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Settings/ISystemSettingsService.cs @@ -0,0 +1,10 @@ +using LiveRecorder.Application.Models.Settings; + +namespace LiveRecorder.Application.Abstractions.Settings; + +public interface ISystemSettingsService +{ + Task GetAsync(CancellationToken cancellationToken = default); + + Task UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Common/PasswordHasher.cs b/src/LiveRecorder.Application/Common/PasswordHasher.cs new file mode 100644 index 0000000..a81fc89 --- /dev/null +++ b/src/LiveRecorder.Application/Common/PasswordHasher.cs @@ -0,0 +1,39 @@ +using System.Security.Cryptography; + +namespace LiveRecorder.Application.Common; + +public static class PasswordHasher +{ + public static string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(16); + var hash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + 100_000, + HashAlgorithmName.SHA256, + 32); + + return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + } + + public static bool Verify(string password, string passwordHash) + { + var parts = passwordHash.Split('.', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2) + { + return false; + } + + var salt = Convert.FromBase64String(parts[0]); + var storedHash = Convert.FromBase64String(parts[1]); + var computedHash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + 100_000, + HashAlgorithmName.SHA256, + storedHash.Length); + + return CryptographicOperations.FixedTimeEquals(storedHash, computedHash); + } +} diff --git a/src/LiveRecorder.Application/LiveRecorder.Application.csproj b/src/LiveRecorder.Application/LiveRecorder.Application.csproj new file mode 100644 index 0000000..2b88a54 --- /dev/null +++ b/src/LiveRecorder.Application/LiveRecorder.Application.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net8.0 + enable + enable + + + diff --git a/src/LiveRecorder.Application/Models/Auth/AuthModels.cs b/src/LiveRecorder.Application/Models/Auth/AuthModels.cs new file mode 100644 index 0000000..e9e5a81 --- /dev/null +++ b/src/LiveRecorder.Application/Models/Auth/AuthModels.cs @@ -0,0 +1,30 @@ +namespace LiveRecorder.Application.Models.Auth; + +public sealed class LoginRequest +{ + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; +} + +public sealed class AuthenticatedUser +{ + public required Guid UserId { get; init; } + + public required string Username { get; init; } + + public required string DisplayName { get; init; } + + public required string Token { get; init; } + + public required DateTimeOffset ExpiresAt { get; init; } +} + +public sealed class LoginResponse +{ + public required string Token { get; init; } + + public required DateTimeOffset ExpiresAt { get; init; } + + public required AuthenticatedUser User { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs new file mode 100644 index 0000000..69d2f7c --- /dev/null +++ b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs @@ -0,0 +1,46 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.LiveRooms; + +public sealed class CreateLiveRoomRequest +{ + public string Url { get; set; } = string.Empty; + + public LivePlatformType? PlatformOverride { get; set; } +} + +public sealed class LiveRoomDto +{ + public Guid Id { get; init; } + + public required LivePlatformType Platform { get; init; } + + public required string PlatformName { get; init; } + + public required string SourceUrl { get; init; } + + public required string RoomId { get; init; } + + public required string NormalizedUrl { get; init; } + + public string? Title { get; init; } + + public string? AnchorName { get; init; } + + public string? CoverUrl { get; init; } + + public bool IsEnabled { get; init; } + + public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; } + + public DateTimeOffset? LastCheckedAt { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } +} + +public sealed class SetLiveRoomEnabledRequest +{ + public bool IsEnabled { get; set; } +} diff --git a/src/LiveRecorder.Application/Models/Logs/SystemLogDto.cs b/src/LiveRecorder.Application/Models/Logs/SystemLogDto.cs new file mode 100644 index 0000000..669d41f --- /dev/null +++ b/src/LiveRecorder.Application/Models/Logs/SystemLogDto.cs @@ -0,0 +1,24 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.Logs; + +public sealed class SystemLogDto +{ + public Guid Id { get; init; } + + public required SystemLogLevel Level { get; init; } + + public required string Category { get; init; } + + public required string Message { get; init; } + + public string? Detail { get; init; } + + public Guid? LiveRoomId { get; init; } + + public Guid? RecordSessionId { get; init; } + + public Guid? RecordTaskId { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs new file mode 100644 index 0000000..f9f0011 --- /dev/null +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs @@ -0,0 +1,59 @@ +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.RecordTasks; + +public sealed class RecordSessionDto +{ + public Guid Id { get; init; } + + public Guid LiveRoomId { get; init; } + + public required string LiveRoomTitle { get; init; } + + public required LivePlatformType Platform { get; init; } + + public required string RoomId { get; init; } + + public required RecordSessionStatus Status { get; init; } + + public required string PreferredQuality { get; init; } + + public required RecordOutputFormat OutputFormat { get; init; } + + public required RecordSaveMode SaveMode { get; init; } + + public int ActiveSegmentIndex { get; init; } + + public int SegmentCount { get; init; } + + public int? RecorderProcessId { get; init; } + + public string? ErrorMessage { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? StartedAt { get; init; } + + public DateTimeOffset? EndedAt { get; init; } + + public long TotalFileSizeBytes { get; init; } + + public int TotalDanmakuMessageCount { get; init; } + + public required IReadOnlyList Tasks { get; init; } +} + +public sealed class RecordSessionDetailDto +{ + public required RecordSessionDto Session { get; init; } + + public required IReadOnlyList Logs { get; init; } +} + +public sealed class DeleteRecordSessionsRequest +{ + public IReadOnlyList SessionIds { get; set; } = []; + + public bool DeleteFiles { get; set; } +} diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs new file mode 100644 index 0000000..7bcb4f9 --- /dev/null +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs @@ -0,0 +1,109 @@ +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.RecordTasks; + +public sealed class StartRecordTaskRequest +{ + public Guid LiveRoomId { get; set; } + + public string? PreferredQuality { get; set; } + + public RecordOutputFormat? OutputFormat { get; set; } +} + +public sealed class RecordResultDto +{ + public Guid Id { get; init; } + + public required string FilePath { get; init; } + + public long? FileSizeBytes { get; init; } + + public double? DurationSeconds { get; init; } + + public string? DanmakuFilePath { get; init; } + + public int DanmakuMessageCount { get; init; } + + public required RecordTaskStatus FinalStatus { get; init; } + + public string? ErrorMessage { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} + +public sealed class RecordTaskDto +{ + public Guid Id { get; init; } + + public Guid LiveRoomId { get; init; } + + public Guid RecordSessionId { get; init; } + + public int SegmentIndex { get; init; } + + public required string LiveRoomTitle { get; init; } + + public required LivePlatformType Platform { get; init; } + + public required string RoomId { get; init; } + + public required RecordTaskStatus Status { get; init; } + + public required string PreferredQuality { get; init; } + + public required RecordOutputFormat OutputFormat { get; init; } + + public string? StreamUrl { get; init; } + + public string? OutputFilePath { get; init; } + + public int? RecorderProcessId { get; init; } + + public string? ErrorMessage { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? StartedAt { get; init; } + + public DateTimeOffset? EndedAt { get; init; } + + public double? DurationSeconds { get; init; } +} + +public sealed class RecordTaskDetailDto +{ + public required RecordTaskDto Task { get; init; } + + public RecordResultDto? Result { get; init; } + + public required IReadOnlyList Logs { get; init; } +} + +public sealed class DeleteCompletedRecordTasksRequest +{ + public IReadOnlyList TaskIds { get; set; } = []; + + public bool DeleteFiles { get; set; } +} + +public sealed class DeleteCompletedRecordTasksResultDto +{ + public required IReadOnlyList DeletedTaskIds { get; init; } + + public required IReadOnlyList DeletedFilePaths { get; init; } + + public required IReadOnlyList DeletedDanmakuPaths { get; init; } + + public required IReadOnlyList DeletedSessionIds { get; init; } + + public required IReadOnlyList Warnings { get; init; } +} + +public sealed class RecordPreviewTicketDto +{ + public required string Url { get; init; } + + public DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs new file mode 100644 index 0000000..20c707d --- /dev/null +++ b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs @@ -0,0 +1,267 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.Settings; + +public sealed class SystemSettingsDto +{ + public string FfmpegPath { get; set; } = "ffmpeg"; + + public string OutputRoot { get; set; } = "records"; + + public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}"; + + public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}"; + + public string DefaultQuality { get; set; } = "origin"; + + public RecordOutputFormat DefaultOutputFormat { get; set; } = RecordOutputFormat.Mp4; + + public RecordSaveMode SaveMode { get; set; } = RecordSaveMode.SingleFile; + + public RecordingTemplateType RecordingTemplate { get; set; } = RecordingTemplateType.StreamCopy; + + public int SegmentDurationMinutes { get; set; } = 30; + + public bool EnableAutoReconnect { get; set; } = true; + + public int ReconnectDelayMaxSeconds { get; set; } = 5; + + public int ReadWriteTimeoutMilliseconds { get; set; } = 15000000; + + public bool EnableDanmakuRecording { get; set; } = true; + + public bool DanmakuIncludeNonChatEvents { get; set; } = true; + + public int DanmakuMinPollIntervalMilliseconds { get; set; } = 1000; + + public int DanmakuRetryDelayMaxSeconds { get; set; } = 15; + + public bool EnableBackgroundPolling { get; set; } = true; + + public bool AutoStartRecordingOnLive { get; set; } = true; + + public int PollingIntervalSeconds { get; set; } = 60; + + public bool EnableEmailNotification { get; set; } = false; + + public string EmailSmtpHost { get; set; } = string.Empty; + + public int EmailSmtpPort { get; set; } = 587; + + public bool EmailUseSsl { get; set; } = true; + + public string EmailUsername { get; set; } = string.Empty; + + public string EmailPassword { get; set; } = string.Empty; + + public string EmailFromAddress { get; set; } = string.Empty; + + public string EmailFromDisplayName { get; set; } = "Live Recorder"; + + public string EmailToAddresses { get; set; } = string.Empty; + + public bool NotifyOnLiveStarted { get; set; } = true; + + public bool NotifyOnException { get; set; } = true; + + public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"; + + public string EmailLiveStartedBodyTemplateHtml { get; set; } = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+"""; + + public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}"; + + public string EmailExceptionBodyTemplateHtml { get; set; } = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+"""; + + public string DouyinUserAgent { get; set; } = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; + + public string DouyinReferer { get; set; } = "https://live.douyin.com/"; + + public string DouyinCookie { get; set; } = string.Empty; +} + +public sealed class UpdateSystemSettingsRequest +{ + public string FfmpegPath { get; set; } = "ffmpeg"; + + public string OutputRoot { get; set; } = "records"; + + public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}"; + + public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}"; + + public string DefaultQuality { get; set; } = "origin"; + + public RecordOutputFormat DefaultOutputFormat { get; set; } = RecordOutputFormat.Mp4; + + public RecordSaveMode SaveMode { get; set; } = RecordSaveMode.SingleFile; + + public RecordingTemplateType RecordingTemplate { get; set; } = RecordingTemplateType.StreamCopy; + + public int SegmentDurationMinutes { get; set; } = 30; + + public bool EnableAutoReconnect { get; set; } = true; + + public int ReconnectDelayMaxSeconds { get; set; } = 5; + + public int ReadWriteTimeoutMilliseconds { get; set; } = 15000000; + + public bool EnableDanmakuRecording { get; set; } = true; + + public bool DanmakuIncludeNonChatEvents { get; set; } = true; + + public int DanmakuMinPollIntervalMilliseconds { get; set; } = 1000; + + public int DanmakuRetryDelayMaxSeconds { get; set; } = 15; + + public bool EnableBackgroundPolling { get; set; } = true; + + public bool AutoStartRecordingOnLive { get; set; } = true; + + public int PollingIntervalSeconds { get; set; } = 60; + + public bool EnableEmailNotification { get; set; } = false; + + public string EmailSmtpHost { get; set; } = string.Empty; + + public int EmailSmtpPort { get; set; } = 587; + + public bool EmailUseSsl { get; set; } = true; + + public string EmailUsername { get; set; } = string.Empty; + + public string EmailPassword { get; set; } = string.Empty; + + public string EmailFromAddress { get; set; } = string.Empty; + + public string EmailFromDisplayName { get; set; } = "Live Recorder"; + + public string EmailToAddresses { get; set; } = string.Empty; + + public bool NotifyOnLiveStarted { get; set; } = true; + + public bool NotifyOnException { get; set; } = true; + + public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"; + + public string EmailLiveStartedBodyTemplateHtml { get; set; } = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+"""; + + public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}"; + + public string EmailExceptionBodyTemplateHtml { get; set; } = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+"""; + + public string DouyinUserAgent { get; set; } = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; + + public string DouyinReferer { get; set; } = "https://live.douyin.com/"; + + public string DouyinCookie { get; set; } = string.Empty; +} + +public sealed class SendTestEmailRequest +{ + public string EmailSmtpHost { get; set; } = string.Empty; + + public int EmailSmtpPort { get; set; } = 587; + + public bool EmailUseSsl { get; set; } = true; + + public string EmailUsername { get; set; } = string.Empty; + + public string EmailPassword { get; set; } = string.Empty; + + public string EmailFromAddress { get; set; } = string.Empty; + + public string EmailFromDisplayName { get; set; } = "Live Recorder"; + + public string EmailToAddresses { get; set; } = string.Empty; + + public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"; + + public string EmailLiveStartedBodyTemplateHtml { get; set; } = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+"""; + + public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}"; + + public string EmailExceptionBodyTemplateHtml { get; set; } = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+"""; +} diff --git a/src/LiveRecorder.Application/Services/AuthService.cs b/src/LiveRecorder.Application/Services/AuthService.cs new file mode 100644 index 0000000..7e9dcf1 --- /dev/null +++ b/src/LiveRecorder.Application/Services/AuthService.cs @@ -0,0 +1,115 @@ +using System.Security.Cryptography; +using LiveRecorder.Application.Abstractions.Auth; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Common; +using LiveRecorder.Application.Models.Auth; +using LiveRecorder.Domain.Entities; + +namespace LiveRecorder.Application.Services; + +public sealed class AuthService : IAuthService +{ + private readonly IUserAccountRepository _userAccountRepository; + private readonly IUserSessionRepository _userSessionRepository; + private readonly IUnitOfWork _unitOfWork; + + public AuthService( + IUserAccountRepository userAccountRepository, + IUserSessionRepository userSessionRepository, + IUnitOfWork unitOfWork) + { + _userAccountRepository = userAccountRepository; + _userSessionRepository = userSessionRepository; + _unitOfWork = unitOfWork; + } + + public async Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var username = request.Username.Trim(); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) + { + throw new InvalidOperationException("用户名和密码不能为空。"); + } + + var user = await _userAccountRepository.GetByUsernameAsync(username, cancellationToken) + ?? throw new InvalidOperationException("用户名或密码错误。"); + + if (!user.IsActive || !PasswordHasher.Verify(request.Password, user.PasswordHash)) + { + throw new InvalidOperationException("用户名或密码错误。"); + } + + var now = DateTimeOffset.UtcNow; + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var expiresAt = now.AddHours(12); + var session = new UserSession(user.Id, token, expiresAt, now); + + await _userSessionRepository.AddAsync(session, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var authenticatedUser = new AuthenticatedUser + { + UserId = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Token = token, + ExpiresAt = expiresAt + }; + + return new LoginResponse + { + Token = token, + ExpiresAt = expiresAt, + User = authenticatedUser + }; + } + + public async Task LogoutAsync(string token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + { + return; + } + + var session = await _userSessionRepository.GetByTokenAsync(token, cancellationToken); + if (session is null) + { + return; + } + + session.Revoke(DateTimeOffset.UtcNow); + _userSessionRepository.Update(session); + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + public async Task ValidateTokenAsync(string token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + var session = await _userSessionRepository.GetByTokenAsync(token, cancellationToken); + if (session is null || !session.IsValid(DateTimeOffset.UtcNow)) + { + return null; + } + + var user = await _userAccountRepository.GetByIdAsync(session.UserAccountId, cancellationToken); + if (user is null || !user.IsActive) + { + return null; + } + + return new AuthenticatedUser + { + UserId = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Token = session.Token, + ExpiresAt = session.ExpiresAt + }; + } +} diff --git a/src/LiveRecorder.Application/Services/LiveDanmakuAdapterFactory.cs b/src/LiveRecorder.Application/Services/LiveDanmakuAdapterFactory.cs new file mode 100644 index 0000000..4d47326 --- /dev/null +++ b/src/LiveRecorder.Application/Services/LiveDanmakuAdapterFactory.cs @@ -0,0 +1,24 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LiveDanmakuAdapterFactory : ILiveDanmakuAdapterFactory +{ + private readonly IReadOnlyDictionary _adapterByPlatform; + + public LiveDanmakuAdapterFactory(IEnumerable adapters) + { + _adapterByPlatform = adapters.ToDictionary(static item => item.PlatformType); + } + + public ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType) + { + if (!_adapterByPlatform.TryGetValue(platformType, out var adapter)) + { + throw new NotSupportedException($"No danmaku adapter is registered for platform {platformType}."); + } + + return adapter; + } +} diff --git a/src/LiveRecorder.Application/Services/LivePlatformAdapterFactory.cs b/src/LiveRecorder.Application/Services/LivePlatformAdapterFactory.cs new file mode 100644 index 0000000..ed9b488 --- /dev/null +++ b/src/LiveRecorder.Application/Services/LivePlatformAdapterFactory.cs @@ -0,0 +1,37 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LivePlatformAdapterFactory : ILivePlatformAdapterFactory +{ + private readonly IReadOnlyDictionary _adapterByPlatform; + private readonly IReadOnlyList _adapters; + + public LivePlatformAdapterFactory(IEnumerable adapters) + { + _adapters = adapters.ToList(); + _adapterByPlatform = _adapters.ToDictionary(static item => item.PlatformType); + } + + public ILivePlatformAdapter GetByPlatform(LivePlatformType platformType) + { + if (!_adapterByPlatform.TryGetValue(platformType, out var adapter)) + { + throw new NotSupportedException($"未找到平台适配器: {platformType}。"); + } + + return adapter; + } + + public ILivePlatformAdapter GetByInput(string input) + { + var adapter = _adapters.FirstOrDefault(item => item.CanHandle(input)); + if (adapter is null) + { + throw new NotSupportedException("无法根据输入识别直播平台,请指定平台后重试。"); + } + + return adapter; + } +} diff --git a/src/LiveRecorder.Application/Services/LiveRoomService.cs b/src/LiveRecorder.Application/Services/LiveRoomService.cs new file mode 100644 index 0000000..ba2670f --- /dev/null +++ b/src/LiveRecorder.Application/Services/LiveRoomService.cs @@ -0,0 +1,175 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Models.LiveRooms; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LiveRoomService +{ + private readonly ILiveRoomRepository _liveRoomRepository; + private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory; + private readonly LiveRoomStatusService _liveRoomStatusService; + private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService; + private readonly IUnitOfWork _unitOfWork; + private readonly ISystemLogService _systemLogService; + + public LiveRoomService( + ILiveRoomRepository liveRoomRepository, + ILivePlatformAdapterFactory livePlatformAdapterFactory, + LiveRoomStatusService liveRoomStatusService, + StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService, + IUnitOfWork unitOfWork, + ISystemLogService systemLogService) + { + _liveRoomRepository = liveRoomRepository; + _livePlatformAdapterFactory = livePlatformAdapterFactory; + _liveRoomStatusService = liveRoomStatusService; + _stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService; + _unitOfWork = unitOfWork; + _systemLogService = systemLogService; + } + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var rooms = await _liveRoomRepository.ListAsync(cancellationToken); + return rooms + .OrderByDescending(static item => item.UpdatedAt) + .Select(Map) + .ToList(); + } + + public async Task GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken); + return room is null ? null : Map(room); + } + + public async Task CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var input = request.Url.Trim(); + if (string.IsNullOrWhiteSpace(input)) + { + throw new InvalidOperationException("Live room URL is required."); + } + + var adapter = request.PlatformOverride.HasValue && request.PlatformOverride.Value != LivePlatformType.Unknown + ? _livePlatformAdapterFactory.GetByPlatform(request.PlatformOverride.Value) + : _livePlatformAdapterFactory.GetByInput(input); + + var parsedRoom = await adapter.ParseRoomAsync(input, cancellationToken); + var liveStatus = await adapter.GetLiveStatusAsync(parsedRoom.RoomId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + var liveRoom = await _liveRoomRepository.GetByPlatformRoomIdAsync(parsedRoom.PlatformType, parsedRoom.RoomId, cancellationToken); + if (liveRoom is null) + { + liveRoom = new LiveRoom(parsedRoom.PlatformType, parsedRoom.SourceUrl, parsedRoom.RoomId, parsedRoom.NormalizedUrl, now); + await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + + await _liveRoomRepository.AddAsync(liveRoom, cancellationToken); + } + else + { + liveRoom.UpdateSource(parsedRoom.SourceUrl, parsedRoom.NormalizedUrl, now); + liveRoom.UpdateRoomId(parsedRoom.RoomId, now); + await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "LiveRoom", + $"Live room resolved: {liveRoom.RoomId} ({liveRoom.Platform}).", + liveRoomId: liveRoom.Id, + cancellationToken: cancellationToken); + + return Map(liveRoom); + } + + public async Task RefreshStatusAsync(Guid id, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + var adapter = _livePlatformAdapterFactory.GetByPlatform(room.Platform); + var liveStatus = await adapter.GetLiveStatusAsync(room.RoomId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + await _liveRoomStatusService.ApplySnapshotAsync(room, liveStatus, now, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "LiveRoom", + $"Live status refreshed for room {room.RoomId}.", + detail: $"status={liveStatus.StatusCode}, rawStatus={liveStatus.RawStatus}", + liveRoomId: room.Id, + cancellationToken: cancellationToken); + + return Map(room); + } + + public async Task SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + room.SetEnabled(isEnabled, DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "LiveRoom", + isEnabled + ? $"Live room {room.RoomId} was enabled." + : $"Live room {room.RoomId} was disabled.", + liveRoomId: room.Id, + cancellationToken: cancellationToken); + + return Map(room); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + await _stoppedOrphanRecordSessionCleanupService.CleanupAsync( + liveRoomId: room.Id, + treatMatchingLiveRoomAsMissing: true, + cancellationToken: cancellationToken); + + _liveRoomRepository.Remove(room); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "LiveRoom", + $"Live room {room.RoomId} was deleted.", + liveRoomId: room.Id, + cancellationToken: cancellationToken); + } + + private static LiveRoomDto Map(LiveRoom room) => new() + { + Id = room.Id, + Platform = room.Platform, + PlatformName = room.Platform.ToString(), + SourceUrl = room.SourceUrl, + RoomId = room.RoomId, + NormalizedUrl = room.NormalizedUrl, + Title = room.Title, + AnchorName = room.AnchorName, + CoverUrl = room.CoverUrl, + IsEnabled = room.IsEnabled, + AvailabilityStatus = room.AvailabilityStatus, + LastCheckedAt = room.LastCheckedAt, + CreatedAt = room.CreatedAt, + UpdatedAt = room.UpdatedAt + }; +} diff --git a/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs b/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs new file mode 100644 index 0000000..3a5cc51 --- /dev/null +++ b/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs @@ -0,0 +1,39 @@ +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LiveRoomStatusService +{ + private readonly IEmailNotificationService _emailNotificationService; + + public LiveRoomStatusService(IEmailNotificationService emailNotificationService) + { + _emailNotificationService = emailNotificationService; + } + + public async Task ApplySnapshotAsync( + LiveRoom liveRoom, + LiveStatusSnapshot liveStatus, + DateTimeOffset observedAt, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(liveRoom); + ArgumentNullException.ThrowIfNull(liveStatus); + + liveRoom.UpdateMetadata(liveStatus.Title, liveStatus.AnchorName, liveStatus.CoverUrl, observedAt); + liveRoom.UpdateAvailability( + liveStatus.IsLive ? LiveRoomAvailabilityStatus.Live : LiveRoomAvailabilityStatus.Offline, + observedAt); + + if (!liveStatus.IsLive || liveRoom.HasSentLiveNotificationForCurrentSession) + { + return; + } + + await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken); + liveRoom.MarkLiveNotificationSent(observedAt); + } +} diff --git a/src/LiveRecorder.Application/Services/RecordModelMapper.cs b/src/LiveRecorder.Application/Services/RecordModelMapper.cs new file mode 100644 index 0000000..d73e47c --- /dev/null +++ b/src/LiveRecorder.Application/Services/RecordModelMapper.cs @@ -0,0 +1,106 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +internal static class RecordModelMapper +{ + public static RecordTaskDto MapTask(RecordTask recordTask) => new() + { + Id = recordTask.Id, + LiveRoomId = recordTask.LiveRoomId, + RecordSessionId = recordTask.RecordSessionId, + SegmentIndex = recordTask.SegmentIndex, + LiveRoomTitle = recordTask.LiveRoom?.Title ?? recordTask.LiveRoom?.AnchorName ?? recordTask.LiveRoom?.RoomId ?? "Unknown Room", + Platform = recordTask.LiveRoom?.Platform ?? LivePlatformType.Unknown, + RoomId = recordTask.LiveRoom?.RoomId ?? string.Empty, + Status = recordTask.Status, + PreferredQuality = recordTask.PreferredQuality, + OutputFormat = recordTask.OutputFormat, + StreamUrl = recordTask.StreamUrl, + OutputFilePath = recordTask.OutputFilePath, + RecorderProcessId = recordTask.RecorderProcessId, + ErrorMessage = recordTask.ErrorMessage, + CreatedAt = recordTask.CreatedAt, + StartedAt = recordTask.StartedAt, + EndedAt = recordTask.EndedAt, + DurationSeconds = recordTask.DurationSeconds + }; + + public static RecordResultDto MapResult(RecordResult recordResult) => new() + { + Id = recordResult.Id, + FilePath = recordResult.FilePath, + FileSizeBytes = recordResult.FileSizeBytes, + DurationSeconds = recordResult.DurationSeconds, + DanmakuFilePath = recordResult.DanmakuFilePath, + DanmakuMessageCount = recordResult.DanmakuMessageCount, + FinalStatus = recordResult.FinalStatus, + ErrorMessage = recordResult.ErrorMessage, + CreatedAt = recordResult.CreatedAt + }; + + public static RecordSessionDto MapSession(RecordSession recordSession) + { + var orderedTasks = recordSession.RecordTasks + .OrderBy(static item => item.SegmentIndex) + .ThenBy(static item => item.CreatedAt) + .ToList(); + var totalFileSizeBytes = orderedTasks + .Select(static item => item.Result?.FileSizeBytes ?? 0L) + .Sum(); + var totalDanmakuMessageCount = orderedTasks + .Select(static item => item.Result?.DanmakuMessageCount ?? 0) + .Sum(); + + return new RecordSessionDto + { + Id = recordSession.Id, + LiveRoomId = recordSession.LiveRoomId, + LiveRoomTitle = recordSession.LiveRoom?.Title ?? recordSession.LiveRoom?.AnchorName ?? recordSession.LiveRoom?.RoomId ?? "Unknown Room", + Platform = recordSession.LiveRoom?.Platform ?? LivePlatformType.Unknown, + RoomId = recordSession.LiveRoom?.RoomId ?? string.Empty, + Status = recordSession.Status, + PreferredQuality = recordSession.PreferredQuality, + OutputFormat = recordSession.OutputFormat, + SaveMode = recordSession.SaveMode, + ActiveSegmentIndex = recordSession.ActiveSegmentIndex, + SegmentCount = Math.Max(recordSession.SegmentCount, orderedTasks.Count), + RecorderProcessId = recordSession.RecorderProcessId, + ErrorMessage = recordSession.ErrorMessage, + CreatedAt = recordSession.CreatedAt, + StartedAt = recordSession.StartedAt, + EndedAt = recordSession.EndedAt, + TotalFileSizeBytes = totalFileSizeBytes, + TotalDanmakuMessageCount = totalDanmakuMessageCount, + Tasks = orderedTasks.Select(item => MapTaskWithFallback(item, recordSession)).ToList() + }; + } + + private static RecordTaskDto MapTaskWithFallback(RecordTask recordTask, RecordSession recordSession) + { + var liveRoom = recordTask.LiveRoom ?? recordSession.LiveRoom; + return new RecordTaskDto + { + Id = recordTask.Id, + LiveRoomId = recordTask.LiveRoomId, + RecordSessionId = recordTask.RecordSessionId, + SegmentIndex = recordTask.SegmentIndex, + LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room", + Platform = liveRoom?.Platform ?? LivePlatformType.Unknown, + RoomId = liveRoom?.RoomId ?? string.Empty, + Status = recordTask.Status, + PreferredQuality = recordTask.PreferredQuality, + OutputFormat = recordTask.OutputFormat, + StreamUrl = recordTask.StreamUrl, + OutputFilePath = recordTask.OutputFilePath, + RecorderProcessId = recordTask.RecorderProcessId, + ErrorMessage = recordTask.ErrorMessage, + CreatedAt = recordTask.CreatedAt, + StartedAt = recordTask.StartedAt, + EndedAt = recordTask.EndedAt, + DurationSeconds = recordTask.DurationSeconds + }; + } +} diff --git a/src/LiveRecorder.Application/Services/RecordService.cs b/src/LiveRecorder.Application/Services/RecordService.cs new file mode 100644 index 0000000..17f5f50 --- /dev/null +++ b/src/LiveRecorder.Application/Services/RecordService.cs @@ -0,0 +1,670 @@ +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class RecordService +{ + private readonly ILiveRoomRepository _liveRoomRepository; + private readonly IRecordSessionRepository _recordSessionRepository; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + private readonly ISystemLogRepository _systemLogRepository; + private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory; + private readonly IRecordMediaService _recordMediaService; + private readonly IFfmpegService _ffmpegService; + private readonly ISystemSettingsService _systemSettingsService; + private readonly ISystemLogService _systemLogService; + private readonly IEmailNotificationService _emailNotificationService; + private readonly LiveRoomStatusService _liveRoomStatusService; + private readonly IUnitOfWork _unitOfWork; + + public RecordService( + ILiveRoomRepository liveRoomRepository, + IRecordSessionRepository recordSessionRepository, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository, + ISystemLogRepository systemLogRepository, + ILivePlatformAdapterFactory livePlatformAdapterFactory, + IRecordMediaService recordMediaService, + IFfmpegService ffmpegService, + ISystemSettingsService systemSettingsService, + ISystemLogService systemLogService, + IEmailNotificationService emailNotificationService, + LiveRoomStatusService liveRoomStatusService, + IUnitOfWork unitOfWork) + { + _liveRoomRepository = liveRoomRepository; + _recordSessionRepository = recordSessionRepository; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + _systemLogRepository = systemLogRepository; + _livePlatformAdapterFactory = livePlatformAdapterFactory; + _recordMediaService = recordMediaService; + _ffmpegService = ffmpegService; + _systemSettingsService = systemSettingsService; + _systemLogService = systemLogService; + _emailNotificationService = emailNotificationService; + _liveRoomStatusService = liveRoomStatusService; + _unitOfWork = unitOfWork; + } + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken); + + var tasks = await _recordTaskRepository.ListAsync(liveRoomId, cancellationToken); + return tasks + .OrderByDescending(static item => item.CreatedAt) + .Select(RecordModelMapper.MapTask) + .ToList(); + } + + public async Task GetDetailAsync(Guid id, CancellationToken cancellationToken = default) + { + var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken); + if (recordTask is null) + { + return null; + } + + if (recordTask.RecordSessionId != Guid.Empty) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(recordTask.RecordSessionId, cancellationToken); + recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken); + if (recordTask is null) + { + return null; + } + } + + var recordResult = await _recordResultRepository.GetByTaskIdAsync(id, cancellationToken); + var logs = await _systemLogService.ListAsync( + recordSessionId: recordTask.RecordSessionId, + recordTaskId: id, + take: 300, + cancellationToken: cancellationToken); + + return new RecordTaskDetailDto + { + Task = RecordModelMapper.MapTask(recordTask), + Result = recordResult is null ? null : RecordModelMapper.MapResult(recordResult), + Logs = logs + }; + } + + public async Task StartAsync(StartRecordTaskRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var liveRoom = await _liveRoomRepository.GetByIdAsync(request.LiveRoomId, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + if (!liveRoom.IsEnabled) + { + throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording."); + } + + var activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken); + if (activeSession is not null) + { + throw new InvalidOperationException("An active recording session already exists for the live room."); + } + + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var adapter = _livePlatformAdapterFactory.GetByPlatform(liveRoom.Platform); + var preferredQuality = string.IsNullOrWhiteSpace(request.PreferredQuality) ? settings.DefaultQuality : request.PreferredQuality.Trim(); + var outputFormat = request.OutputFormat ?? settings.DefaultOutputFormat; + var saveMode = settings.SaveMode; + var now = DateTimeOffset.UtcNow; + + var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now); + await _recordSessionRepository.AddAsync(recordSession, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var initialTask = new RecordTask(liveRoom.Id, recordSession.Id, 1, preferredQuality, outputFormat, now); + await _recordTaskRepository.AddAsync(initialTask, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + try + { + var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken); + await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + + if (!liveStatus.IsLive) + { + initialTask.MarkFailed("The live room is currently offline.", now); + recordSession.MarkFailed("The live room is currently offline.", now); + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordSession", + "Recording start skipped because the live room is offline.", + liveRoomId: liveRoom.Id, + recordSessionId: recordSession.Id, + recordTaskId: initialTask.Id, + cancellationToken: cancellationToken); + + return RecordModelMapper.MapTask(initialTask); + } + + var streamResult = await adapter.GetStreamUrlAsync(liveRoom.RoomId, preferredQuality, cancellationToken); + var outputPattern = BuildOutputPathPattern( + settings.OutputRoot, + settings.OutputDirectoryTemplate, + settings.OutputFileNameTemplate, + liveRoom.Platform, + liveRoom.RoomId, + liveRoom.AnchorName, + liveRoom.Title, + outputFormat, + saveMode, + now); + var initialOutputPath = ResolveSegmentOutputPath(outputPattern, outputFormat, saveMode, 1); + + recordSession.MarkStarting(streamResult.SelectedUrl, outputPattern, now); + recordSession.ActivateSegment(1, now); + initialTask.MarkStarting(streamResult.SelectedUrl, initialOutputPath, now); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _ffmpegService.StartAsync(recordSession, initialTask, streamResult, cancellationToken); + + recordSession.MarkRunning(DateTimeOffset.UtcNow); + initialTask.MarkRunning(DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "RecordSession", + $"Recording session started with quality={streamResult.SelectedQuality}, protocol={streamResult.SelectedProtocol}.", + detail: outputPattern, + liveRoomId: liveRoom.Id, + recordSessionId: recordSession.Id, + recordTaskId: initialTask.Id, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + initialTask.MarkFailed(ex.Message, DateTimeOffset.UtcNow); + recordSession.MarkFailed(ex.Message, DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Error, + "RecordSession", + "Recording session startup failed.", + ex.ToString(), + liveRoom.Id, + recordSession.Id, + initialTask.Id, + cancellationToken); + + await _emailNotificationService.SendExceptionAsync( + "RecordSession", + "Recording session startup failed.", + ex.ToString(), + liveRoom, + initialTask, + cancellationToken); + } + + return RecordModelMapper.MapTask(initialTask); + } + + public Task DeleteCompletedAsync( + DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken = default) => + DeleteTasksAsync(request, cancellationToken); + + public async Task DeleteTasksAsync( + DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var taskIds = request.TaskIds + .Where(static item => item != Guid.Empty) + .Distinct() + .ToArray(); + + if (taskIds.Length == 0) + { + return CreateEmptyDeleteResult(); + } + + var recordTasks = await _recordTaskRepository.GetByIdsAsync(taskIds, cancellationToken); + var tasksById = recordTasks.ToDictionary(static item => item.Id); + var warnings = new List(); + var deletedTaskIds = new List(); + var deletedFilePaths = new List(); + var deletedDanmakuPaths = new List(); + var affectedSessionIds = new HashSet(); + + foreach (var taskId in taskIds) + { + if (!tasksById.TryGetValue(taskId, out var recordTask)) + { + warnings.Add($"Task {taskId} was not found."); + continue; + } + + if (IsActiveTaskStatus(recordTask.Status)) + { + warnings.Add($"Task {recordTask.Id} is active and cannot be deleted."); + continue; + } + + if (request.DeleteFiles) + { + TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths); + } + + if (recordTask.Result is not null) + { + _recordResultRepository.Remove(recordTask.Result); + } + + var relatedLogs = await _systemLogRepository.ListByRecordTaskIdsAsync([recordTask.Id], cancellationToken); + if (relatedLogs.Count > 0) + { + _systemLogRepository.RemoveRange(relatedLogs); + } + + _recordTaskRepository.Remove(recordTask); + deletedTaskIds.Add(recordTask.Id); + affectedSessionIds.Add(recordTask.RecordSessionId); + } + + var deletedSessionIds = new List(); + if (deletedTaskIds.Count > 0) + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + if (affectedSessionIds.Count > 0) + { + var sessions = await _recordSessionRepository.ListAsync(cancellationToken: cancellationToken); + foreach (var session in sessions.Where(item => affectedSessionIds.Contains(item.Id))) + { + var remainingTasks = await _recordTaskRepository.ListBySessionIdAsync(session.Id, cancellationToken); + if (remainingTasks.Count != 0 || IsActiveSessionStatus(session.Status)) + { + continue; + } + + var sessionLogs = await _systemLogRepository.ListByRecordSessionIdsAsync([session.Id], cancellationToken); + if (sessionLogs.Count > 0) + { + _systemLogRepository.RemoveRange(sessionLogs); + } + + _recordSessionRepository.Remove(session); + deletedSessionIds.Add(session.Id); + } + } + + if (deletedSessionIds.Count > 0) + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + if (deletedTaskIds.Count > 0 || deletedSessionIds.Count > 0) + { + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordTask", + $"Deleted {deletedTaskIds.Count} recording task(s).", + detail: request.DeleteFiles + ? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; sessions={deletedSessionIds.Count}" + : $"video-files=0; danmaku-files=0; sessions={deletedSessionIds.Count}", + cancellationToken: cancellationToken); + } + + return new DeleteCompletedRecordTasksResultDto + { + DeletedTaskIds = deletedTaskIds, + DeletedFilePaths = deletedFilePaths, + DeletedDanmakuPaths = deletedDanmakuPaths, + DeletedSessionIds = deletedSessionIds, + Warnings = warnings + }; + } + + public async Task CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default) + { + var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken); + + return new RecordPreviewTicketDto + { + Url = $"{mediaBaseUrl.TrimEnd('/')}/{previewTicket.Ticket}", + ExpiresAt = previewTicket.ExpiresAt + }; + } + + public async Task StopAsync(Guid id, CancellationToken cancellationToken = default) + { + var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Recording task was not found."); + + if (!IsActiveTaskStatus(recordTask.Status)) + { + throw new InvalidOperationException("The recording task is not running."); + } + + var session = await _recordSessionRepository.GetByIdAsync(recordTask.RecordSessionId, cancellationToken) + ?? throw new KeyNotFoundException("Recording session was not found."); + + recordTask.MarkStopping(DateTimeOffset.UtcNow); + session.MarkStopping(DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _ffmpegService.StopAsync(session.Id, cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "RecordSession", + "Stop signal sent to the active recording session.", + liveRoomId: recordTask.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: recordTask.Id, + cancellationToken: cancellationToken); + + return RecordModelMapper.MapTask(recordTask); + } + + private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken) + { + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + var activeSessionIds = sessions + .Where(item => IsActiveSessionStatus(item.Status)) + .Select(item => item.Id) + .Distinct() + .ToArray(); + + foreach (var activeSessionId in activeSessionIds) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(activeSessionId, cancellationToken); + } + } + + internal static DeleteCompletedRecordTasksResultDto CreateEmptyDeleteResult() => new() + { + DeletedTaskIds = [], + DeletedFilePaths = [], + DeletedDanmakuPaths = [], + DeletedSessionIds = [], + Warnings = [] + }; + + private static bool IsActiveTaskStatus(RecordTaskStatus status) => + status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping; + + private static bool IsActiveSessionStatus(RecordSessionStatus status) => + status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; + + private static string BuildOutputPathPattern( + string outputRoot, + string outputDirectoryTemplate, + string outputFileNameTemplate, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + DateTimeOffset now) + { + var safeRoomId = SanitizeFileName(roomId, "room"); + var effectiveFileNameTemplate = EnsureSegmentSuffixTemplate(outputFileNameTemplate, saveMode); + var baseFileStem = BuildFileNameStem( + effectiveFileNameTemplate, + platform, + safeRoomId, + anchorName, + title, + now, + segmentSuffix: string.Empty); + var directoryPath = BuildDirectoryPath( + outputDirectoryTemplate, + platform, + safeRoomId, + anchorName, + title, + now, + baseFileStem); + var fileNameStem = BuildFileNameStem( + effectiveFileNameTemplate, + platform, + safeRoomId, + anchorName, + title, + now, + saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty); + var folder = Path.Combine(outputRoot, directoryPath); + var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4"; + return Path.Combine(folder, $"{fileNameStem}.{extension}"); + } + + internal static string ResolveSegmentOutputPath( + string outputPathPattern, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + int segmentIndex) + { + if (saveMode != RecordSaveMode.Segmented) + { + return outputPathPattern; + } + + return outputPathPattern.Replace("%05d", $"{Math.Max(1, segmentIndex):D5}", StringComparison.OrdinalIgnoreCase); + } + + private static string BuildDirectoryPath( + string template, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + DateTimeOffset now, + string fileStem) + { + var raw = ApplyOutputTemplate( + template, + platform, + roomId, + anchorName, + title, + now, + forPathSegment: true, + fileStem, + segmentSuffix: string.Empty); + var parts = raw + .Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static part => SanitizeFileName(part, "untitled")) + .Where(static part => !string.IsNullOrWhiteSpace(part)) + .ToArray(); + + return parts.Length == 0 ? now.ToString("yyyy/MM/dd") : Path.Combine(parts); + } + + private static string BuildFileNameStem( + string template, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + DateTimeOffset now, + string segmentSuffix) + { + var raw = ApplyOutputTemplate( + template, + platform, + roomId, + anchorName, + title, + now, + forPathSegment: false, + fileStem: string.Empty, + segmentSuffix); + return SanitizeFileName(raw, $"{now:yyyyMMdd_HHmmss}_{roomId}"); + } + + private static string EnsureSegmentSuffixTemplate(string template, RecordSaveMode saveMode) + { + var effectiveTemplate = string.IsNullOrWhiteSpace(template) + ? "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}" + : template.Trim(); + + if (saveMode != RecordSaveMode.Segmented) + { + return effectiveTemplate; + } + + if (effectiveTemplate.Contains("{segmentSuffix}", StringComparison.OrdinalIgnoreCase) || + effectiveTemplate.Contains("%05d", StringComparison.OrdinalIgnoreCase)) + { + return effectiveTemplate; + } + + return $"{effectiveTemplate}{{segmentSuffix}}"; + } + + private static string ApplyOutputTemplate( + string template, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + DateTimeOffset now, + bool forPathSegment, + string fileStem, + string segmentSuffix) + { + var effectiveTemplate = string.IsNullOrWhiteSpace(template) + ? (forPathSegment ? "{platform}/{yyyy}/{MM}/{dd}/{anchor}" : "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}") + : template; + + var tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["platform"] = platform.ToString(), + ["roomId"] = roomId, + ["anchor"] = NormalizeTokenValue(anchorName, "unknown-anchor"), + ["title"] = NormalizeTokenValue(title, "untitled"), + ["fileStem"] = fileStem, + ["segmentSuffix"] = segmentSuffix, + ["yyyy"] = now.ToString("yyyy"), + ["MM"] = now.ToString("MM"), + ["dd"] = now.ToString("dd"), + ["HH"] = now.ToString("HH"), + ["mm"] = now.ToString("mm"), + ["ss"] = now.ToString("ss"), + ["yyyyMMdd"] = now.ToString("yyyyMMdd"), + ["HHmmss"] = now.ToString("HHmmss"), + ["date"] = now.ToString("yyyyMMdd"), + ["time"] = now.ToString("HHmmss") + }; + + return Regex.Replace( + effectiveTemplate, + "{(?[a-zA-Z0-9]+)}", + match => + { + var name = match.Groups["name"].Value; + return tokens.TryGetValue(name, out var value) ? value : string.Empty; + }); + } + + private static string NormalizeTokenValue(string? value, string fallback) => + string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + + private static string SanitizeFileName(string? value, string fallback) + { + var candidate = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(candidate.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim(); + sanitized = sanitized.Trim('.', ' '); + + if (string.IsNullOrWhiteSpace(sanitized)) + { + sanitized = fallback; + } + + return sanitized.Length <= 96 ? sanitized : sanitized[..96].Trim(); + } + + internal static void TryDeleteRecordOutput( + RecordTask recordTask, + List warnings, + List deletedFilePaths, + List deletedDanmakuPaths) + { + var outputPath = recordTask.Result?.FilePath ?? recordTask.OutputFilePath; + if (!string.IsNullOrWhiteSpace(outputPath)) + { + TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}"); + } + + var danmakuPath = recordTask.Result?.DanmakuFilePath; + if (!string.IsNullOrWhiteSpace(danmakuPath)) + { + TryDeletePath(danmakuPath, warnings, deletedDanmakuPaths, $"danmaku for task {recordTask.Id}"); + } + } + + private static void TryDeletePath( + string path, + List warnings, + List deletedPaths, + string label) + { + var absolutePath = Path.IsPathRooted(path) + ? path + : Path.GetFullPath(path, AppContext.BaseDirectory); + + try + { + if (IsUnsafeDeletionTarget(absolutePath)) + { + warnings.Add($"Skipped deleting suspicious path: {absolutePath}"); + return; + } + + if (File.Exists(absolutePath)) + { + File.Delete(absolutePath); + deletedPaths.Add(absolutePath); + return; + } + + if (Directory.Exists(absolutePath)) + { + Directory.Delete(absolutePath, true); + deletedPaths.Add(absolutePath); + return; + } + + warnings.Add($"Path not found for {label}: {absolutePath}"); + } + catch (Exception ex) + { + warnings.Add($"Failed to delete {label}: {ex.Message}"); + } + } + + private static bool IsUnsafeDeletionTarget(string absolutePath) + { + var normalized = absolutePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var root = Path.GetPathRoot(normalized)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.IsNullOrWhiteSpace(normalized) || + normalized.Length < 4 || + string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/LiveRecorder.Application/Services/RecordSessionService.cs b/src/LiveRecorder.Application/Services/RecordSessionService.cs new file mode 100644 index 0000000..f2b974e --- /dev/null +++ b/src/LiveRecorder.Application/Services/RecordSessionService.cs @@ -0,0 +1,285 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.DependencyInjection; + +namespace LiveRecorder.Application.Services; + +public sealed class RecordSessionService +{ + private static readonly TimeSpan GracefulDeleteTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan ForcedKillTimeout = TimeSpan.FromSeconds(8); + + private readonly IRecordSessionRepository _recordSessionRepository; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + private readonly ISystemLogRepository _systemLogRepository; + private readonly IFfmpegService _ffmpegService; + private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService; + private readonly ISystemLogService _systemLogService; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IUnitOfWork _unitOfWork; + + public RecordSessionService( + IRecordSessionRepository recordSessionRepository, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository, + ISystemLogRepository systemLogRepository, + IFfmpegService ffmpegService, + StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService, + ISystemLogService systemLogService, + IServiceScopeFactory serviceScopeFactory, + IUnitOfWork unitOfWork) + { + _recordSessionRepository = recordSessionRepository; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + _systemLogRepository = systemLogRepository; + _ffmpegService = ffmpegService; + _stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService; + _systemLogService = systemLogService; + _serviceScopeFactory = serviceScopeFactory; + _unitOfWork = unitOfWork; + } + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken); + await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken); + + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + return sessions + .OrderByDescending(static item => item.CreatedAt) + .Select(RecordModelMapper.MapSession) + .ToList(); + } + + public async Task GetDetailAsync(Guid id, CancellationToken cancellationToken = default) + { + var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken); + if (session is null) + { + return null; + } + + if (IsActiveStatus(session.Status)) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(id, cancellationToken); + session = await LoadSessionSnapshotAsync(id, cancellationToken); + if (session is null) + { + return null; + } + } + + var logs = await _systemLogService.ListAsync(recordSessionId: id, take: 500, cancellationToken: cancellationToken); + return new RecordSessionDetailDto + { + Session = RecordModelMapper.MapSession(session), + Logs = logs + }; + } + + public async Task StopAsync(Guid id, CancellationToken cancellationToken = default) + { + var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Recording session was not found."); + + if (!IsActiveStatus(session.Status)) + { + throw new InvalidOperationException("The recording session is not running."); + } + + session.MarkStopping(DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _ffmpegService.StopAsync(id, cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "RecordSession", + "Stop signal sent to the recording session.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + cancellationToken: cancellationToken); + + return RecordModelMapper.MapSession(session); + } + + public async Task DeleteAsync( + DeleteRecordSessionsRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var sessionIds = request.SessionIds + .Where(static item => item != Guid.Empty) + .Distinct() + .ToArray(); + + if (sessionIds.Length == 0) + { + return RecordService.CreateEmptyDeleteResult(); + } + + var warnings = new List(); + var deletedSessionIds = new List(); + var deletedTaskIds = new List(); + var deletedFilePaths = new List(); + var deletedDanmakuPaths = new List(); + + foreach (var sessionId in sessionIds) + { + var session = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken); + if (session is null) + { + warnings.Add($"Session {sessionId} was not found."); + continue; + } + + if (IsActiveStatus(session.Status)) + { + var stopped = await _ffmpegService.StopAndWaitAsync( + session.Id, + markAsCompletedOnExit: false, + GracefulDeleteTimeout, + cancellationToken); + + if (!stopped) + { + warnings.Add($"Session {session.Id} did not stop gracefully in time. Force killing the ffmpeg process."); + var killed = await _ffmpegService.KillAndWaitAsync(session.Id, ForcedKillTimeout, cancellationToken); + if (!killed) + { + warnings.Add($"Session {session.Id} is still shutting down. It was not deleted."); + continue; + } + } + + await _ffmpegService.TryReconcileInactiveSessionAsync(session.Id, cancellationToken); + var sessionSnapshot = await LoadSessionSnapshotAsync(sessionId, cancellationToken); + if (sessionSnapshot is null) + { + deletedSessionIds.Add(sessionId); + continue; + } + + session = sessionSnapshot; + } + + if (_ffmpegService.IsRunning(session.Id) || IsActiveStatus(session.Status)) + { + warnings.Add($"Session {session.Id} is still active and could not be deleted."); + continue; + } + + session = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken); + if (session is null) + { + deletedSessionIds.Add(sessionId); + continue; + } + + var taskIds = session.RecordTasks + .Select(static item => item.Id) + .Distinct() + .ToArray(); + + if (request.DeleteFiles) + { + foreach (var recordTask in session.RecordTasks) + { + RecordService.TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths); + } + } + + var relatedLogs = await ListRelatedLogsAsync(session.Id, taskIds, cancellationToken); + if (relatedLogs.Count > 0) + { + _systemLogRepository.RemoveRange(relatedLogs); + } + + var results = session.RecordTasks + .Select(static item => item.Result) + .OfType() + .ToArray(); + if (results.Length > 0) + { + _recordResultRepository.RemoveRange(results); + } + + if (session.RecordTasks.Count > 0) + { + _recordTaskRepository.RemoveRange(session.RecordTasks); + deletedTaskIds.AddRange(taskIds); + } + + _recordSessionRepository.Remove(session); + await _unitOfWork.SaveChangesAsync(cancellationToken); + deletedSessionIds.Add(session.Id); + } + + if (deletedSessionIds.Count > 0) + { + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordSession", + $"Deleted {deletedSessionIds.Count} recording session(s).", + detail: request.DeleteFiles + ? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; tasks={deletedTaskIds.Count}" + : $"video-files=0; danmaku-files=0; tasks={deletedTaskIds.Count}", + cancellationToken: cancellationToken); + } + + return new DeleteCompletedRecordTasksResultDto + { + DeletedTaskIds = deletedTaskIds, + DeletedFilePaths = deletedFilePaths, + DeletedDanmakuPaths = deletedDanmakuPaths, + DeletedSessionIds = deletedSessionIds, + Warnings = warnings + }; + } + + private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken) + { + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + var activeIds = sessions + .Where(item => IsActiveStatus(item.Status)) + .Select(item => item.Id) + .Distinct() + .ToArray(); + + foreach (var activeId in activeIds) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(activeId, cancellationToken); + } + } + + private static bool IsActiveStatus(RecordSessionStatus status) => + status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; + + private async Task> ListRelatedLogsAsync( + Guid recordSessionId, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + var bySession = await _systemLogRepository.ListByRecordSessionIdsAsync([recordSessionId], cancellationToken); + var byTask = await _systemLogRepository.ListByRecordTaskIdsAsync(taskIds, cancellationToken); + + return bySession + .Concat(byTask) + .GroupBy(static item => item.Id) + .Select(static group => group.First()) + .ToArray(); + } + + private async Task LoadSessionSnapshotAsync(Guid id, CancellationToken cancellationToken) + { + using var scope = _serviceScopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return await repository.GetByIdAsync(id, cancellationToken); + } +} diff --git a/src/LiveRecorder.Application/Services/StoppedOrphanRecordSessionCleanupService.cs b/src/LiveRecorder.Application/Services/StoppedOrphanRecordSessionCleanupService.cs new file mode 100644 index 0000000..41adbcd --- /dev/null +++ b/src/LiveRecorder.Application/Services/StoppedOrphanRecordSessionCleanupService.cs @@ -0,0 +1,184 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class StoppedOrphanRecordSessionCleanupService +{ + private readonly IRecordSessionRepository _recordSessionRepository; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + private readonly ISystemLogRepository _systemLogRepository; + private readonly ISystemLogService _systemLogService; + private readonly IUnitOfWork _unitOfWork; + + public StoppedOrphanRecordSessionCleanupService( + IRecordSessionRepository recordSessionRepository, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository, + ISystemLogRepository systemLogRepository, + ISystemLogService systemLogService, + IUnitOfWork unitOfWork) + { + _recordSessionRepository = recordSessionRepository; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + _systemLogRepository = systemLogRepository; + _systemLogService = systemLogService; + _unitOfWork = unitOfWork; + } + + public async Task> CleanupAsync( + Guid? liveRoomId = null, + bool treatMatchingLiveRoomAsMissing = false, + CancellationToken cancellationToken = default) + { + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + var candidates = sessions + .Where(item => ShouldCleanup(item, liveRoomId, treatMatchingLiveRoomAsMissing)) + .Select(static item => item.Id) + .Distinct() + .ToArray(); + + if (candidates.Length == 0) + { + return []; + } + + var cleanedSessionIds = new List(); + foreach (var sessionId in candidates) + { + var trackedSession = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken); + if (trackedSession is null || !ShouldCleanup(trackedSession, liveRoomId, treatMatchingLiveRoomAsMissing)) + { + continue; + } + + var taskIds = trackedSession.RecordTasks + .Select(static item => item.Id) + .Distinct() + .ToArray(); + var results = trackedSession.RecordTasks + .Select(static item => item.Result) + .OfType() + .ToArray(); + var relatedLogs = await ListRelatedLogsAsync(trackedSession.Id, taskIds, cancellationToken); + var taskCount = trackedSession.RecordTasks.Count; + var relatedLiveRoomId = trackedSession.LiveRoomId; + + if (relatedLogs.Count > 0) + { + _systemLogRepository.RemoveRange(relatedLogs); + } + + if (results.Length > 0) + { + _recordResultRepository.RemoveRange(results); + } + + if (trackedSession.RecordTasks.Count > 0) + { + _recordTaskRepository.RemoveRange(trackedSession.RecordTasks); + } + + _recordSessionRepository.Remove(trackedSession); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + cleanedSessionIds.Add(trackedSession.Id); + + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordSession", + "Automatically cleaned a stopped orphan session without valid video segments.", + detail: $"sessionId={trackedSession.Id}; taskCount={taskCount}; liveRoomId={relatedLiveRoomId}", + cancellationToken: cancellationToken); + } + + return cleanedSessionIds; + } + + private async Task> ListRelatedLogsAsync( + Guid recordSessionId, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + var bySession = await _systemLogRepository.ListByRecordSessionIdsAsync([recordSessionId], cancellationToken); + var byTask = await _systemLogRepository.ListByRecordTaskIdsAsync(taskIds, cancellationToken); + + return bySession + .Concat(byTask) + .GroupBy(static item => item.Id) + .Select(static group => group.First()) + .ToArray(); + } + + private static bool ShouldCleanup( + RecordSession session, + Guid? liveRoomId, + bool treatMatchingLiveRoomAsMissing) + { + if (session.Status != RecordSessionStatus.Stopped) + { + return false; + } + + var liveRoomMissing = session.LiveRoom is null || + (treatMatchingLiveRoomAsMissing && + liveRoomId.HasValue && + session.LiveRoomId == liveRoomId.Value); + + if (!liveRoomMissing) + { + return false; + } + + return !HasAnyValidVideoSegments(session.RecordTasks); + } + + private static bool HasAnyValidVideoSegments(IEnumerable tasks) + { + foreach (var task in tasks) + { + if (!TryResolveVideoPath(task, out var absolutePath)) + { + continue; + } + + try + { + var fileInfo = new FileInfo(absolutePath); + if (fileInfo.Exists && fileInfo.Length > 0) + { + return true; + } + } + catch + { + // Ignore malformed or inaccessible paths and continue scanning the session. + } + } + + return false; + } + + private static bool TryResolveVideoPath(RecordTask task, out string absolutePath) + { + var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath) + ? task.Result!.FilePath + : task.OutputFilePath; + + if (string.IsNullOrWhiteSpace(candidatePath) || candidatePath.Contains('%')) + { + absolutePath = string.Empty; + return false; + } + + absolutePath = Path.IsPathRooted(candidatePath) + ? candidatePath + : Path.GetFullPath(candidatePath, AppContext.BaseDirectory); + + return true; + } +} diff --git a/src/LiveRecorder.Application/Services/SystemLogService.cs b/src/LiveRecorder.Application/Services/SystemLogService.cs new file mode 100644 index 0000000..b9b210c --- /dev/null +++ b/src/LiveRecorder.Application/Services/SystemLogService.cs @@ -0,0 +1,65 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.DependencyInjection; + +namespace LiveRecorder.Application.Services; + +public sealed class SystemLogService : ISystemLogService +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ISystemLogRepository _systemLogRepository; + + public SystemLogService( + IServiceScopeFactory serviceScopeFactory, + ISystemLogRepository systemLogRepository) + { + _serviceScopeFactory = serviceScopeFactory; + _systemLogRepository = systemLogRepository; + } + + public async Task WriteAsync( + SystemLogLevel level, + string category, + string message, + string? detail = null, + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + CancellationToken cancellationToken = default) + { + var entry = new SystemLogEntry(level, category, message, detail, liveRoomId, recordSessionId, recordTaskId, DateTimeOffset.UtcNow); + + using var scope = _serviceScopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + await repository.AddAsync(entry, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + } + + public async Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default) + { + var entries = await _systemLogRepository.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken); + return entries + .Select(static item => new SystemLogDto + { + Id = item.Id, + Level = item.Level, + Category = item.Category, + Message = item.Message, + Detail = item.Detail, + LiveRoomId = item.LiveRoomId, + RecordSessionId = item.RecordSessionId, + RecordTaskId = item.RecordTaskId, + CreatedAt = item.CreatedAt + }) + .ToList(); + } +} diff --git a/src/LiveRecorder.Application/Services/SystemSettingsService.cs b/src/LiveRecorder.Application/Services/SystemSettingsService.cs new file mode 100644 index 0000000..fad6bc2 --- /dev/null +++ b/src/LiveRecorder.Application/Services/SystemSettingsService.cs @@ -0,0 +1,226 @@ +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class SystemSettingsService : ISystemSettingsService +{ + private const string FfmpegPathKey = "ffmpeg.path"; + private const string OutputRootKey = "recording.output_root"; + private const string OutputDirectoryTemplateKey = "recording.output_directory_template"; + private const string OutputFileNameTemplateKey = "recording.output_file_name_template"; + private const string DefaultQualityKey = "recording.default_quality"; + private const string DefaultOutputFormatKey = "recording.default_output_format"; + private const string SaveModeKey = "recording.save_mode"; + private const string RecordingTemplateKey = "recording.template"; + private const string SegmentDurationMinutesKey = "recording.segment_duration_minutes"; + private const string EnableReconnectKey = "recording.enable_auto_reconnect"; + private const string ReconnectDelayMaxSecondsKey = "recording.reconnect_delay_max_seconds"; + private const string ReadWriteTimeoutMillisecondsKey = "recording.read_write_timeout_milliseconds"; + private const string EnableDanmakuRecordingKey = "recording.enable_danmaku_recording"; + private const string DanmakuIncludeNonChatEventsKey = "recording.danmaku_include_non_chat_events"; + private const string DanmakuMinPollIntervalMillisecondsKey = "recording.danmaku_min_poll_interval_milliseconds"; + private const string DanmakuRetryDelayMaxSecondsKey = "recording.danmaku_retry_delay_max_seconds"; + private const string EnableBackgroundPollingKey = "scheduler.enable_background_polling"; + private const string AutoStartRecordingOnLiveKey = "scheduler.auto_start_recording_on_live"; + private const string PollingIntervalSecondsKey = "scheduler.polling_interval_seconds"; + private const string EnableEmailNotificationKey = "notification.email.enabled"; + private const string EmailSmtpHostKey = "notification.email.smtp_host"; + private const string EmailSmtpPortKey = "notification.email.smtp_port"; + private const string EmailUseSslKey = "notification.email.use_ssl"; + private const string EmailUsernameKey = "notification.email.username"; + private const string EmailPasswordKey = "notification.email.password"; + private const string EmailFromAddressKey = "notification.email.from_address"; + private const string EmailFromDisplayNameKey = "notification.email.from_display_name"; + private const string EmailToAddressesKey = "notification.email.to_addresses"; + private const string NotifyOnLiveStartedKey = "notification.email.notify_live_started"; + private const string NotifyOnExceptionKey = "notification.email.notify_exception"; + private const string EmailLiveStartedSubjectTemplateKey = "notification.email.live_started.subject_template"; + private const string EmailLiveStartedBodyTemplateHtmlKey = "notification.email.live_started.body_template_html"; + private const string EmailExceptionSubjectTemplateKey = "notification.email.exception.subject_template"; + private const string EmailExceptionBodyTemplateHtmlKey = "notification.email.exception.body_template_html"; + private const string DouyinUserAgentKey = "douyin.user_agent"; + private const string DouyinRefererKey = "douyin.referer"; + private const string DouyinCookieKey = "douyin.cookie"; + + private readonly IAppSettingRepository _appSettingRepository; + private readonly IUnitOfWork _unitOfWork; + + public SystemSettingsService(IAppSettingRepository appSettingRepository, IUnitOfWork unitOfWork) + { + _appSettingRepository = appSettingRepository; + _unitOfWork = unitOfWork; + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var settings = await _appSettingRepository.ListAsync(cancellationToken); + var lookup = settings.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.OrdinalIgnoreCase); + + return new SystemSettingsDto + { + FfmpegPath = GetValue(lookup, FfmpegPathKey, "ffmpeg"), + OutputRoot = GetValue(lookup, OutputRootKey, "records"), + OutputDirectoryTemplate = GetValue(lookup, OutputDirectoryTemplateKey, "{platform}/{yyyy}/{MM}/{dd}/{anchor}"), + OutputFileNameTemplate = GetValue(lookup, OutputFileNameTemplateKey, "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"), + DefaultQuality = GetValue(lookup, DefaultQualityKey, "origin"), + DefaultOutputFormat = Enum.TryParse(GetValue(lookup, DefaultOutputFormatKey, "Mp4"), true, out RecordOutputFormat outputFormat) + ? outputFormat + : RecordOutputFormat.Mp4, + SaveMode = Enum.TryParse(GetValue(lookup, SaveModeKey, "SingleFile"), true, out RecordSaveMode saveMode) + ? saveMode + : RecordSaveMode.SingleFile, + RecordingTemplate = Enum.TryParse(GetValue(lookup, RecordingTemplateKey, "StreamCopy"), true, out RecordingTemplateType recordingTemplate) + ? recordingTemplate + : RecordingTemplateType.StreamCopy, + SegmentDurationMinutes = GetIntValue(lookup, SegmentDurationMinutesKey, 30, 1, 720), + EnableAutoReconnect = bool.TryParse(GetValue(lookup, EnableReconnectKey, "true"), out var enableReconnect) && enableReconnect, + ReconnectDelayMaxSeconds = GetIntValue(lookup, ReconnectDelayMaxSecondsKey, 5, 1, 300), + ReadWriteTimeoutMilliseconds = GetIntValue(lookup, ReadWriteTimeoutMillisecondsKey, 15000000, 1000, 60000000), + EnableDanmakuRecording = bool.TryParse(GetValue(lookup, EnableDanmakuRecordingKey, "true"), out var enableDanmakuRecording) && enableDanmakuRecording, + DanmakuIncludeNonChatEvents = bool.TryParse(GetValue(lookup, DanmakuIncludeNonChatEventsKey, "true"), out var danmakuIncludeNonChatEvents) && danmakuIncludeNonChatEvents, + DanmakuMinPollIntervalMilliseconds = GetIntValue(lookup, DanmakuMinPollIntervalMillisecondsKey, 1000, 100, 60000), + DanmakuRetryDelayMaxSeconds = GetIntValue(lookup, DanmakuRetryDelayMaxSecondsKey, 15, 1, 300), + EnableBackgroundPolling = bool.TryParse(GetValue(lookup, EnableBackgroundPollingKey, "true"), out var enableBackgroundPolling) && enableBackgroundPolling, + AutoStartRecordingOnLive = bool.TryParse(GetValue(lookup, AutoStartRecordingOnLiveKey, "true"), out var autoStartRecordingOnLive) && autoStartRecordingOnLive, + PollingIntervalSeconds = GetIntValue(lookup, PollingIntervalSecondsKey, 60, 10, 3600), + EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification, + EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty), + EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535), + EmailUseSsl = bool.TryParse(GetValue(lookup, EmailUseSslKey, "true"), out var emailUseSsl) && emailUseSsl, + EmailUsername = GetValue(lookup, EmailUsernameKey, string.Empty), + EmailPassword = GetValue(lookup, EmailPasswordKey, string.Empty), + EmailFromAddress = GetValue(lookup, EmailFromAddressKey, string.Empty), + EmailFromDisplayName = GetValue(lookup, EmailFromDisplayNameKey, "Live Recorder"), + EmailToAddresses = GetValue(lookup, EmailToAddressesKey, string.Empty), + NotifyOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyOnLiveStartedKey, "true"), out var notifyOnLiveStarted) && notifyOnLiveStarted, + NotifyOnException = bool.TryParse(GetValue(lookup, NotifyOnExceptionKey, "true"), out var notifyOnException) && notifyOnException, + EmailLiveStartedSubjectTemplate = GetValue(lookup, EmailLiveStartedSubjectTemplateKey, "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"), + EmailLiveStartedBodyTemplateHtml = GetValue( + lookup, + EmailLiveStartedBodyTemplateHtmlKey, + """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+ """), + EmailExceptionSubjectTemplate = GetValue(lookup, EmailExceptionSubjectTemplateKey, "[{{appName}}] Exception: {{source}}"), + EmailExceptionBodyTemplateHtml = GetValue( + lookup, + EmailExceptionBodyTemplateHtmlKey, + """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+ """), + DouyinUserAgent = GetValue( + lookup, + DouyinUserAgentKey, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"), + DouyinReferer = GetValue(lookup, DouyinRefererKey, "https://live.douyin.com/"), + DouyinCookie = GetValue(lookup, DouyinCookieKey, string.Empty) + }; + } + + public async Task UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var now = DateTimeOffset.UtcNow; + + await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken); + await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken); + await UpsertAsync(OutputDirectoryTemplateKey, request.OutputDirectoryTemplate.Trim(), now, cancellationToken); + await UpsertAsync(OutputFileNameTemplateKey, request.OutputFileNameTemplate.Trim(), now, cancellationToken); + await UpsertAsync(DefaultQualityKey, request.DefaultQuality.Trim(), now, cancellationToken); + await UpsertAsync(DefaultOutputFormatKey, request.DefaultOutputFormat.ToString(), now, cancellationToken); + await UpsertAsync(SaveModeKey, request.SaveMode.ToString(), now, cancellationToken); + await UpsertAsync(RecordingTemplateKey, request.RecordingTemplate.ToString(), now, cancellationToken); + await UpsertAsync(SegmentDurationMinutesKey, request.SegmentDurationMinutes.ToString(), now, cancellationToken); + await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken); + await UpsertAsync(ReconnectDelayMaxSecondsKey, request.ReconnectDelayMaxSeconds.ToString(), now, cancellationToken); + await UpsertAsync(ReadWriteTimeoutMillisecondsKey, request.ReadWriteTimeoutMilliseconds.ToString(), now, cancellationToken); + await UpsertAsync(EnableDanmakuRecordingKey, request.EnableDanmakuRecording.ToString(), now, cancellationToken); + await UpsertAsync(DanmakuIncludeNonChatEventsKey, request.DanmakuIncludeNonChatEvents.ToString(), now, cancellationToken); + await UpsertAsync(DanmakuMinPollIntervalMillisecondsKey, request.DanmakuMinPollIntervalMilliseconds.ToString(), now, cancellationToken); + await UpsertAsync(DanmakuRetryDelayMaxSecondsKey, request.DanmakuRetryDelayMaxSeconds.ToString(), now, cancellationToken); + await UpsertAsync(EnableBackgroundPollingKey, request.EnableBackgroundPolling.ToString(), now, cancellationToken); + await UpsertAsync(AutoStartRecordingOnLiveKey, request.AutoStartRecordingOnLive.ToString(), now, cancellationToken); + await UpsertAsync(PollingIntervalSecondsKey, request.PollingIntervalSeconds.ToString(), now, cancellationToken); + await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken); + await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken); + await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken); + await UpsertAsync(EmailUseSslKey, request.EmailUseSsl.ToString(), now, cancellationToken); + await UpsertAsync(EmailUsernameKey, request.EmailUsername.Trim(), now, cancellationToken); + await UpsertAsync(EmailPasswordKey, request.EmailPassword, now, cancellationToken); + await UpsertAsync(EmailFromAddressKey, request.EmailFromAddress.Trim(), now, cancellationToken); + await UpsertAsync(EmailFromDisplayNameKey, request.EmailFromDisplayName.Trim(), now, cancellationToken); + await UpsertAsync(EmailToAddressesKey, request.EmailToAddresses.Trim(), now, cancellationToken); + await UpsertAsync(NotifyOnLiveStartedKey, request.NotifyOnLiveStarted.ToString(), now, cancellationToken); + await UpsertAsync(NotifyOnExceptionKey, request.NotifyOnException.ToString(), now, cancellationToken); + await UpsertAsync(EmailLiveStartedSubjectTemplateKey, request.EmailLiveStartedSubjectTemplate.Trim(), now, cancellationToken); + await UpsertAsync(EmailLiveStartedBodyTemplateHtmlKey, request.EmailLiveStartedBodyTemplateHtml.Trim(), now, cancellationToken); + await UpsertAsync(EmailExceptionSubjectTemplateKey, request.EmailExceptionSubjectTemplate.Trim(), now, cancellationToken); + await UpsertAsync(EmailExceptionBodyTemplateHtmlKey, request.EmailExceptionBodyTemplateHtml.Trim(), now, cancellationToken); + await UpsertAsync(DouyinUserAgentKey, request.DouyinUserAgent.Trim(), now, cancellationToken); + await UpsertAsync(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken); + await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + return await GetAsync(cancellationToken); + } + + private static string GetValue(IReadOnlyDictionary lookup, string key, string fallback) => + lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; + + private static int GetIntValue( + IReadOnlyDictionary lookup, + string key, + int fallback, + int minimum, + int maximum) + { + var raw = GetValue(lookup, key, fallback.ToString()); + if (!int.TryParse(raw, out var parsedValue)) + { + return fallback; + } + + return Math.Clamp(parsedValue, minimum, maximum); + } + + private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken) + { + var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken); + if (existing is null) + { + await _appSettingRepository.AddAsync(new AppSetting(key, value, updatedAt), cancellationToken); + return; + } + + existing.Update(value, updatedAt); + _appSettingRepository.Update(existing); + } +} diff --git a/src/LiveRecorder.Domain/Entities/AppSetting.cs b/src/LiveRecorder.Domain/Entities/AppSetting.cs new file mode 100644 index 0000000..8db3539 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/AppSetting.cs @@ -0,0 +1,30 @@ +namespace LiveRecorder.Domain.Entities; + +public class AppSetting +{ + private AppSetting() + { + } + + public AppSetting(string key, string value, DateTimeOffset updatedAt) + { + Id = Guid.NewGuid(); + Key = key; + Value = value; + UpdatedAt = updatedAt; + } + + public Guid Id { get; private set; } + + public string Key { get; private set; } = string.Empty; + + public string Value { get; private set; } = string.Empty; + + public DateTimeOffset UpdatedAt { get; private set; } + + public void Update(string value, DateTimeOffset updatedAt) + { + Value = value; + UpdatedAt = updatedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/LiveRoom.cs b/src/LiveRecorder.Domain/Entities/LiveRoom.cs new file mode 100644 index 0000000..77c7895 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/LiveRoom.cs @@ -0,0 +1,103 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class LiveRoom +{ + private LiveRoom() + { + } + + public LiveRoom( + LivePlatformType platform, + string sourceUrl, + string roomId, + string normalizedUrl, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + Platform = platform; + SourceUrl = sourceUrl; + RoomId = roomId; + NormalizedUrl = normalizedUrl; + IsEnabled = true; + AvailabilityStatus = LiveRoomAvailabilityStatus.Unknown; + CreatedAt = createdAt; + UpdatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public LivePlatformType Platform { get; private set; } + + public string SourceUrl { get; private set; } = string.Empty; + + public string RoomId { get; private set; } = string.Empty; + + public string NormalizedUrl { get; private set; } = string.Empty; + + public string? Title { get; private set; } + + public string? AnchorName { get; private set; } + + public string? CoverUrl { get; private set; } + + public bool IsEnabled { get; private set; } + + public bool HasSentLiveNotificationForCurrentSession { get; private set; } + + public LiveRoomAvailabilityStatus AvailabilityStatus { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public DateTimeOffset? LastCheckedAt { get; private set; } + + public ICollection RecordTasks { get; private set; } = new List(); + + public void UpdateSource(string sourceUrl, string normalizedUrl, DateTimeOffset updatedAt) + { + SourceUrl = sourceUrl; + NormalizedUrl = normalizedUrl; + UpdatedAt = updatedAt; + } + + public void UpdateRoomId(string roomId, DateTimeOffset updatedAt) + { + RoomId = roomId; + UpdatedAt = updatedAt; + } + + public void UpdateMetadata(string? title, string? anchorName, string? coverUrl, DateTimeOffset updatedAt) + { + Title = title; + AnchorName = anchorName; + CoverUrl = coverUrl; + UpdatedAt = updatedAt; + } + + public void UpdateAvailability(LiveRoomAvailabilityStatus availabilityStatus, DateTimeOffset checkedAt) + { + AvailabilityStatus = availabilityStatus; + if (availabilityStatus != LiveRoomAvailabilityStatus.Live) + { + HasSentLiveNotificationForCurrentSession = false; + } + + LastCheckedAt = checkedAt; + UpdatedAt = checkedAt; + } + + public void SetEnabled(bool isEnabled, DateTimeOffset updatedAt) + { + IsEnabled = isEnabled; + UpdatedAt = updatedAt; + } + + public void MarkLiveNotificationSent(DateTimeOffset updatedAt) + { + HasSentLiveNotificationForCurrentSession = true; + UpdatedAt = updatedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/RecordResult.cs b/src/LiveRecorder.Domain/Entities/RecordResult.cs new file mode 100644 index 0000000..71c8f25 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/RecordResult.cs @@ -0,0 +1,73 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class RecordResult +{ + private RecordResult() + { + } + + public RecordResult( + Guid recordTaskId, + string filePath, + long? fileSizeBytes, + double? durationSeconds, + string? danmakuFilePath, + int danmakuMessageCount, + RecordTaskStatus finalStatus, + string? errorMessage, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + RecordTaskId = recordTaskId; + FilePath = filePath; + FileSizeBytes = fileSizeBytes; + DurationSeconds = durationSeconds; + DanmakuFilePath = danmakuFilePath; + DanmakuMessageCount = Math.Max(0, danmakuMessageCount); + FinalStatus = finalStatus; + ErrorMessage = errorMessage; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid RecordTaskId { get; private set; } + + public RecordTask? RecordTask { get; private set; } + + public string FilePath { get; private set; } = string.Empty; + + public long? FileSizeBytes { get; private set; } + + public double? DurationSeconds { get; private set; } + + public string? DanmakuFilePath { get; private set; } + + public int DanmakuMessageCount { get; private set; } + + public RecordTaskStatus FinalStatus { get; private set; } + + public string? ErrorMessage { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public void Update( + string filePath, + long? fileSizeBytes, + double? durationSeconds, + string? danmakuFilePath, + int danmakuMessageCount, + RecordTaskStatus finalStatus, + string? errorMessage) + { + FilePath = filePath; + FileSizeBytes = fileSizeBytes; + DurationSeconds = durationSeconds; + DanmakuFilePath = danmakuFilePath; + DanmakuMessageCount = Math.Max(0, danmakuMessageCount); + FinalStatus = finalStatus; + ErrorMessage = errorMessage; + } +} diff --git a/src/LiveRecorder.Domain/Entities/RecordSession.cs b/src/LiveRecorder.Domain/Entities/RecordSession.cs new file mode 100644 index 0000000..3d5ba2d --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/RecordSession.cs @@ -0,0 +1,140 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class RecordSession +{ + private RecordSession() + { + } + + public RecordSession( + Guid liveRoomId, + string preferredQuality, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + LiveRoomId = liveRoomId; + PreferredQuality = preferredQuality; + OutputFormat = outputFormat; + SaveMode = saveMode; + Status = RecordSessionStatus.Pending; + ActiveSegmentIndex = 0; + SegmentCount = 0; + CreatedAt = createdAt; + UpdatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid LiveRoomId { get; private set; } + + public LiveRoom? LiveRoom { get; private set; } + + public RecordSessionStatus Status { get; private set; } + + public string PreferredQuality { get; private set; } = "origin"; + + public RecordOutputFormat OutputFormat { get; private set; } = RecordOutputFormat.Mp4; + + public RecordSaveMode SaveMode { get; private set; } = RecordSaveMode.SingleFile; + + public string? StreamUrl { get; private set; } + + public string? OutputPathPattern { get; private set; } + + public int ActiveSegmentIndex { get; private set; } + + public int SegmentCount { get; private set; } + + public int? RecorderProcessId { get; private set; } + + public string? ErrorMessage { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public DateTimeOffset? StartedAt { get; private set; } + + public DateTimeOffset? EndedAt { get; private set; } + + public ICollection RecordTasks { get; private set; } = new List(); + + public void MarkStarting(string streamUrl, string outputPathPattern, DateTimeOffset startedAt) + { + StreamUrl = streamUrl; + OutputPathPattern = outputPathPattern; + StartedAt = startedAt; + Status = RecordSessionStatus.Starting; + ErrorMessage = null; + UpdatedAt = startedAt; + } + + public void MarkRunning(DateTimeOffset updatedAt) + { + Status = RecordSessionStatus.Running; + StartedAt ??= updatedAt; + ErrorMessage = null; + UpdatedAt = updatedAt; + } + + public void MarkStopping(DateTimeOffset updatedAt) + { + Status = RecordSessionStatus.Stopping; + UpdatedAt = updatedAt; + } + + public void MarkCompleted(DateTimeOffset endedAt) + { + Status = RecordSessionStatus.Completed; + EndedAt = endedAt; + RecorderProcessId = null; + ErrorMessage = null; + UpdatedAt = endedAt; + } + + public void MarkStopped(DateTimeOffset endedAt, string? errorMessage = null) + { + Status = RecordSessionStatus.Stopped; + EndedAt = endedAt; + RecorderProcessId = null; + ErrorMessage = errorMessage; + UpdatedAt = endedAt; + } + + public void MarkFailed(string errorMessage, DateTimeOffset endedAt) + { + Status = RecordSessionStatus.Failed; + ErrorMessage = errorMessage; + EndedAt = endedAt; + RecorderProcessId = null; + UpdatedAt = endedAt; + } + + public void AttachProcess(int processId, DateTimeOffset updatedAt) + { + RecorderProcessId = processId; + UpdatedAt = updatedAt; + } + + public void ActivateSegment(int segmentIndex, DateTimeOffset updatedAt) + { + ActiveSegmentIndex = Math.Max(1, segmentIndex); + SegmentCount = Math.Max(SegmentCount, ActiveSegmentIndex); + UpdatedAt = updatedAt; + } + + public void SyncSegmentCount(int segmentCount, DateTimeOffset updatedAt) + { + SegmentCount = Math.Max(0, segmentCount); + if (ActiveSegmentIndex > SegmentCount) + { + ActiveSegmentIndex = SegmentCount; + } + + UpdatedAt = updatedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/RecordTask.cs b/src/LiveRecorder.Domain/Entities/RecordTask.cs new file mode 100644 index 0000000..f81798f --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/RecordTask.cs @@ -0,0 +1,140 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class RecordTask +{ + private RecordTask() + { + } + + public RecordTask( + Guid liveRoomId, + Guid recordSessionId, + int segmentIndex, + string preferredQuality, + RecordOutputFormat outputFormat, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + LiveRoomId = liveRoomId; + RecordSessionId = recordSessionId; + SegmentIndex = Math.Max(1, segmentIndex); + PreferredQuality = preferredQuality; + OutputFormat = outputFormat; + Status = RecordTaskStatus.Pending; + CreatedAt = createdAt; + UpdatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid LiveRoomId { get; private set; } + + public LiveRoom? LiveRoom { get; private set; } + + public Guid RecordSessionId { get; private set; } + + public RecordSession? RecordSession { get; private set; } + + public int SegmentIndex { get; private set; } + + public RecordTaskStatus Status { get; private set; } + + public string PreferredQuality { get; private set; } = "origin"; + + public RecordOutputFormat OutputFormat { get; private set; } = RecordOutputFormat.Mp4; + + public string? StreamUrl { get; private set; } + + public string? OutputFilePath { get; private set; } + + public int? RecorderProcessId { get; private set; } + + public string? ErrorMessage { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public DateTimeOffset? StartedAt { get; private set; } + + public DateTimeOffset? EndedAt { get; private set; } + + public double? DurationSeconds { get; private set; } + + public RecordResult? Result { get; private set; } + + public void AssignToSession(Guid recordSessionId, int segmentIndex, DateTimeOffset updatedAt) + { + RecordSessionId = recordSessionId; + SegmentIndex = Math.Max(1, segmentIndex); + UpdatedAt = updatedAt; + } + + public void MarkStarting(string streamUrl, string outputFilePath, DateTimeOffset startedAt) + { + StreamUrl = streamUrl; + OutputFilePath = outputFilePath; + StartedAt = startedAt; + Status = RecordTaskStatus.Starting; + ErrorMessage = null; + UpdatedAt = startedAt; + } + + public void AttachProcess(int processId, DateTimeOffset updatedAt) + { + RecorderProcessId = processId; + UpdatedAt = updatedAt; + } + + public void DetachProcess(DateTimeOffset updatedAt) + { + RecorderProcessId = null; + UpdatedAt = updatedAt; + } + + public void MarkRunning(DateTimeOffset updatedAt) + { + Status = RecordTaskStatus.Running; + StartedAt ??= updatedAt; + ErrorMessage = null; + UpdatedAt = updatedAt; + } + + public void MarkStopping(DateTimeOffset updatedAt) + { + Status = RecordTaskStatus.Stopping; + UpdatedAt = updatedAt; + } + + public void MarkCompleted(DateTimeOffset endedAt, double? durationSeconds) + { + Status = RecordTaskStatus.Completed; + EndedAt = endedAt; + DurationSeconds = durationSeconds; + ErrorMessage = null; + RecorderProcessId = null; + UpdatedAt = endedAt; + } + + public void MarkStopped(DateTimeOffset endedAt, double? durationSeconds, string? errorMessage = null) + { + Status = RecordTaskStatus.Stopped; + EndedAt = endedAt; + DurationSeconds = durationSeconds; + ErrorMessage = errorMessage; + RecorderProcessId = null; + UpdatedAt = endedAt; + } + + public void MarkFailed(string errorMessage, DateTimeOffset endedAt) + { + Status = RecordTaskStatus.Failed; + ErrorMessage = errorMessage; + EndedAt = endedAt; + DurationSeconds = StartedAt.HasValue ? Math.Max(0, (endedAt - StartedAt.Value).TotalSeconds) : null; + RecorderProcessId = null; + UpdatedAt = endedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/SystemLogEntry.cs b/src/LiveRecorder.Domain/Entities/SystemLogEntry.cs new file mode 100644 index 0000000..fedfc3e --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/SystemLogEntry.cs @@ -0,0 +1,49 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class SystemLogEntry +{ + private SystemLogEntry() + { + } + + public SystemLogEntry( + SystemLogLevel level, + string category, + string message, + string? detail, + Guid? liveRoomId, + Guid? recordSessionId, + Guid? recordTaskId, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + Level = level; + Category = category; + Message = message; + Detail = detail; + LiveRoomId = liveRoomId; + RecordSessionId = recordSessionId; + RecordTaskId = recordTaskId; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public SystemLogLevel Level { get; private set; } + + public string Category { get; private set; } = string.Empty; + + public string Message { get; private set; } = string.Empty; + + public string? Detail { get; private set; } + + public Guid? LiveRoomId { get; private set; } + + public Guid? RecordSessionId { get; private set; } + + public Guid? RecordTaskId { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } +} diff --git a/src/LiveRecorder.Domain/Entities/UserAccount.cs b/src/LiveRecorder.Domain/Entities/UserAccount.cs new file mode 100644 index 0000000..c15292a --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/UserAccount.cs @@ -0,0 +1,35 @@ +namespace LiveRecorder.Domain.Entities; + +public class UserAccount +{ + private UserAccount() + { + } + + public UserAccount(string username, string displayName, string passwordHash, DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + Username = username; + DisplayName = displayName; + PasswordHash = passwordHash; + IsActive = true; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public string Username { get; private set; } = string.Empty; + + public string DisplayName { get; private set; } = string.Empty; + + public string PasswordHash { get; private set; } = string.Empty; + + public bool IsActive { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public void UpdatePassword(string passwordHash) + { + PasswordHash = passwordHash; + } +} diff --git a/src/LiveRecorder.Domain/Entities/UserSession.cs b/src/LiveRecorder.Domain/Entities/UserSession.cs new file mode 100644 index 0000000..7fb17b0 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/UserSession.cs @@ -0,0 +1,38 @@ +namespace LiveRecorder.Domain.Entities; + +public class UserSession +{ + private UserSession() + { + } + + public UserSession(Guid userAccountId, string token, DateTimeOffset expiresAt, DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + UserAccountId = userAccountId; + Token = token; + ExpiresAt = expiresAt; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid UserAccountId { get; private set; } + + public UserAccount? UserAccount { get; private set; } + + public string Token { get; private set; } = string.Empty; + + public DateTimeOffset ExpiresAt { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset? RevokedAt { get; private set; } + + public bool IsValid(DateTimeOffset now) => RevokedAt is null && ExpiresAt > now; + + public void Revoke(DateTimeOffset revokedAt) + { + RevokedAt = revokedAt; + } +} diff --git a/src/LiveRecorder.Domain/Enums/LivePlatformType.cs b/src/LiveRecorder.Domain/Enums/LivePlatformType.cs new file mode 100644 index 0000000..62f9836 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/LivePlatformType.cs @@ -0,0 +1,11 @@ +namespace LiveRecorder.Domain.Enums; + +public enum LivePlatformType +{ + Unknown = 0, + Douyin = 1, + Bilibili = 2, + Huya = 3, + Douyu = 4, + Kuaishou = 5 +} diff --git a/src/LiveRecorder.Domain/Enums/LiveRoomAvailabilityStatus.cs b/src/LiveRecorder.Domain/Enums/LiveRoomAvailabilityStatus.cs new file mode 100644 index 0000000..b8fc80a --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/LiveRoomAvailabilityStatus.cs @@ -0,0 +1,8 @@ +namespace LiveRecorder.Domain.Enums; + +public enum LiveRoomAvailabilityStatus +{ + Unknown = 0, + Offline = 1, + Live = 2 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordOutputFormat.cs b/src/LiveRecorder.Domain/Enums/RecordOutputFormat.cs new file mode 100644 index 0000000..39201cc --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordOutputFormat.cs @@ -0,0 +1,7 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordOutputFormat +{ + Mp4 = 0, + Ts = 1 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordSaveMode.cs b/src/LiveRecorder.Domain/Enums/RecordSaveMode.cs new file mode 100644 index 0000000..158ba1d --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordSaveMode.cs @@ -0,0 +1,7 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordSaveMode +{ + SingleFile = 0, + Segmented = 1 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordSessionStatus.cs b/src/LiveRecorder.Domain/Enums/RecordSessionStatus.cs new file mode 100644 index 0000000..a49899c --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordSessionStatus.cs @@ -0,0 +1,12 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordSessionStatus +{ + Pending = 0, + Starting = 1, + Running = 2, + Stopping = 3, + Completed = 4, + Failed = 5, + Stopped = 6 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordTaskStatus.cs b/src/LiveRecorder.Domain/Enums/RecordTaskStatus.cs new file mode 100644 index 0000000..3860ff4 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordTaskStatus.cs @@ -0,0 +1,12 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordTaskStatus +{ + Pending = 0, + Starting = 1, + Running = 2, + Stopping = 3, + Completed = 4, + Failed = 5, + Stopped = 6 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordingTemplateType.cs b/src/LiveRecorder.Domain/Enums/RecordingTemplateType.cs new file mode 100644 index 0000000..9d50485 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordingTemplateType.cs @@ -0,0 +1,8 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordingTemplateType +{ + StreamCopy = 0, + BalancedMp4 = 1, + ArchiveTs = 2 +} diff --git a/src/LiveRecorder.Domain/Enums/SystemLogLevel.cs b/src/LiveRecorder.Domain/Enums/SystemLogLevel.cs new file mode 100644 index 0000000..11941d4 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/SystemLogLevel.cs @@ -0,0 +1,9 @@ +namespace LiveRecorder.Domain.Enums; + +public enum SystemLogLevel +{ + Trace = 0, + Info = 1, + Warning = 2, + Error = 3 +} diff --git a/src/LiveRecorder.Domain/LiveRecorder.Domain.csproj b/src/LiveRecorder.Domain/LiveRecorder.Domain.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/src/LiveRecorder.Domain/LiveRecorder.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj b/src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj new file mode 100644 index 0000000..974f92c --- /dev/null +++ b/src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + net8.0 + enable + enable + + + diff --git a/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs new file mode 100644 index 0000000..645be40 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs @@ -0,0 +1,312 @@ +using LiveRecorder.Application.Common; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace LiveRecorder.Infrastructure.Persistence; + +public sealed class DatabaseInitializer +{ + private readonly LiveRecorderDbContext _dbContext; + + public DatabaseInitializer(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + await _dbContext.Database.EnsureCreatedAsync(cancellationToken); + await EnsureSchemaAsync(cancellationToken); + await BackfillRecordSessionsAsync(cancellationToken); + + if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken)) + { + var now = DateTimeOffset.UtcNow; + var admin = new UserAccount("admin", "Administrator", PasswordHasher.Hash("Admin@123"), now); + await _dbContext.UserAccounts.AddAsync(admin, cancellationToken); + } + + var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ffmpeg.path"] = "ffmpeg", + ["recording.output_root"] = "records", + ["recording.output_directory_template"] = "{platform}/{yyyy}/{MM}/{dd}/{anchor}", + ["recording.output_file_name_template"] = "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}", + ["recording.default_quality"] = "origin", + ["recording.default_output_format"] = "Mp4", + ["recording.save_mode"] = "SingleFile", + ["recording.template"] = "StreamCopy", + ["recording.segment_duration_minutes"] = "30", + ["recording.enable_auto_reconnect"] = "True", + ["recording.reconnect_delay_max_seconds"] = "5", + ["recording.read_write_timeout_milliseconds"] = "15000000", + ["recording.enable_danmaku_recording"] = "True", + ["recording.danmaku_include_non_chat_events"] = "True", + ["recording.danmaku_min_poll_interval_milliseconds"] = "1000", + ["recording.danmaku_retry_delay_max_seconds"] = "15", + ["scheduler.enable_background_polling"] = "True", + ["scheduler.auto_start_recording_on_live"] = "True", + ["scheduler.polling_interval_seconds"] = "60", + ["notification.email.enabled"] = "False", + ["notification.email.smtp_host"] = string.Empty, + ["notification.email.smtp_port"] = "587", + ["notification.email.use_ssl"] = "True", + ["notification.email.username"] = string.Empty, + ["notification.email.password"] = string.Empty, + ["notification.email.from_address"] = string.Empty, + ["notification.email.from_display_name"] = "Live Recorder", + ["notification.email.to_addresses"] = string.Empty, + ["notification.email.notify_live_started"] = "True", + ["notification.email.notify_exception"] = "True", + ["notification.email.live_started.subject_template"] = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})", + ["notification.email.live_started.body_template_html"] = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+""", + ["notification.email.exception.subject_template"] = "[{{appName}}] Exception: {{source}}", + ["notification.email.exception.body_template_html"] = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+""", + ["douyin.user_agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + ["douyin.referer"] = "https://live.douyin.com/", + ["douyin.cookie"] = string.Empty + }; + + var existingKeys = await _dbContext.AppSettings + .Select(static item => item.Key) + .ToListAsync(cancellationToken); + + foreach (var pair in defaults.Where(pair => !existingKeys.Contains(pair.Key, StringComparer.OrdinalIgnoreCase))) + { + await _dbContext.AppSettings.AddAsync(new AppSetting(pair.Key, pair.Value, DateTimeOffset.UtcNow), cancellationToken); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task EnsureSchemaAsync(CancellationToken cancellationToken) + { + try + { + await _dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE LiveRooms ADD COLUMN IsEnabled INTEGER NOT NULL DEFAULT 1;", + cancellationToken); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 1 && + ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + } + + try + { + await _dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE LiveRooms ADD COLUMN HasSentLiveNotificationForCurrentSession INTEGER NOT NULL DEFAULT 0;", + cancellationToken); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 1 && + ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + } + + await _dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS RecordSessions ( + Id TEXT NOT NULL CONSTRAINT PK_RecordSessions PRIMARY KEY, + LiveRoomId TEXT NOT NULL, + Status INTEGER NOT NULL, + PreferredQuality TEXT NOT NULL, + OutputFormat INTEGER NOT NULL, + SaveMode INTEGER NOT NULL, + StreamUrl TEXT NULL, + OutputPathPattern TEXT NULL, + ActiveSegmentIndex INTEGER NOT NULL, + SegmentCount INTEGER NOT NULL, + RecorderProcessId INTEGER NULL, + ErrorMessage TEXT NULL, + CreatedAt TEXT NOT NULL, + UpdatedAt TEXT NOT NULL, + StartedAt TEXT NULL, + EndedAt TEXT NULL + ); + """, + cancellationToken); + + await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN SegmentIndex INTEGER NOT NULL DEFAULT 1;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuFilePath TEXT NULL;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuMessageCount INTEGER NOT NULL DEFAULT 0;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE SystemLogEntries ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken); + + await _dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_RecordTasks_RecordSessionId_SegmentIndex ON RecordTasks (RecordSessionId, SegmentIndex);", + cancellationToken); + await _dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_RecordSessions_LiveRoomId_CreatedAt ON RecordSessions (LiveRoomId, CreatedAt);", + cancellationToken); + await _dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_SystemLogEntries_RecordSessionId ON SystemLogEntries (RecordSessionId);", + cancellationToken); + } + + private async Task ExecuteAddColumnAsync(string sql, CancellationToken cancellationToken) + { + try + { + await _dbContext.Database.ExecuteSqlRawAsync(sql, cancellationToken); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 1 && + ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + } + } + + private async Task BackfillRecordSessionsAsync(CancellationToken cancellationToken) + { + await using var connection = (SqliteConnection)_dbContext.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + var selectCommand = connection.CreateCommand(); + selectCommand.CommandText = + """ + SELECT Id, LiveRoomId, Status, PreferredQuality, OutputFormat, StreamUrl, OutputFilePath, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt + FROM RecordTasks + WHERE RecordSessionId IS NULL OR RecordSessionId = ''; + """; + + var orphanTasks = new List(); + await using (var reader = await selectCommand.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + orphanTasks.Add(new LegacyTaskRow( + reader.GetString(0), + reader.GetString(1), + reader.GetInt32(2), + reader.IsDBNull(3) ? "origin" : reader.GetString(3), + reader.GetInt32(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6), + reader.IsDBNull(7) ? null : reader.GetString(7), + reader.GetString(8), + reader.GetString(9), + reader.IsDBNull(10) ? null : reader.GetString(10), + reader.IsDBNull(11) ? null : reader.GetString(11))); + } + } + + foreach (var orphanTask in orphanTasks) + { + var sessionId = Guid.NewGuid().ToString(); + var saveMode = !string.IsNullOrWhiteSpace(orphanTask.OutputFilePath) && orphanTask.OutputFilePath.Contains('%') + ? (int)RecordSaveMode.Segmented + : (int)RecordSaveMode.SingleFile; + var segmentCount = 1; + var activeSegmentIndex = orphanTask.Status is (int)RecordTaskStatus.Starting or (int)RecordTaskStatus.Running or (int)RecordTaskStatus.Stopping + ? 1 + : 0; + var insertSessionCommand = connection.CreateCommand(); + insertSessionCommand.CommandText = + """ + INSERT INTO RecordSessions ( + Id, LiveRoomId, Status, PreferredQuality, OutputFormat, SaveMode, StreamUrl, OutputPathPattern, + ActiveSegmentIndex, SegmentCount, RecorderProcessId, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt + ) VALUES ( + $id, $liveRoomId, $status, $preferredQuality, $outputFormat, $saveMode, $streamUrl, $outputPathPattern, + $activeSegmentIndex, $segmentCount, NULL, $errorMessage, $createdAt, $updatedAt, $startedAt, $endedAt + ); + """; + insertSessionCommand.Parameters.AddWithValue("$id", sessionId); + insertSessionCommand.Parameters.AddWithValue("$liveRoomId", orphanTask.LiveRoomId); + insertSessionCommand.Parameters.AddWithValue("$status", MapLegacyTaskStatusToSessionStatus(orphanTask.Status)); + insertSessionCommand.Parameters.AddWithValue("$preferredQuality", orphanTask.PreferredQuality); + insertSessionCommand.Parameters.AddWithValue("$outputFormat", orphanTask.OutputFormat); + insertSessionCommand.Parameters.AddWithValue("$saveMode", saveMode); + insertSessionCommand.Parameters.AddWithValue("$streamUrl", (object?)orphanTask.StreamUrl ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$outputPathPattern", (object?)orphanTask.OutputFilePath ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$activeSegmentIndex", activeSegmentIndex); + insertSessionCommand.Parameters.AddWithValue("$segmentCount", segmentCount); + insertSessionCommand.Parameters.AddWithValue("$errorMessage", (object?)orphanTask.ErrorMessage ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$createdAt", orphanTask.CreatedAt); + insertSessionCommand.Parameters.AddWithValue("$updatedAt", orphanTask.UpdatedAt); + insertSessionCommand.Parameters.AddWithValue("$startedAt", (object?)orphanTask.StartedAt ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$endedAt", (object?)orphanTask.EndedAt ?? DBNull.Value); + await insertSessionCommand.ExecuteNonQueryAsync(cancellationToken); + + var updateTaskCommand = connection.CreateCommand(); + updateTaskCommand.CommandText = + """ + UPDATE RecordTasks + SET RecordSessionId = $recordSessionId, SegmentIndex = COALESCE(SegmentIndex, 1) + WHERE Id = $taskId; + """; + updateTaskCommand.Parameters.AddWithValue("$recordSessionId", sessionId); + updateTaskCommand.Parameters.AddWithValue("$taskId", orphanTask.Id); + await updateTaskCommand.ExecuteNonQueryAsync(cancellationToken); + + var updateLogsCommand = connection.CreateCommand(); + updateLogsCommand.CommandText = + """ + UPDATE SystemLogEntries + SET RecordSessionId = $recordSessionId + WHERE RecordTaskId = $taskId AND (RecordSessionId IS NULL OR RecordSessionId = ''); + """; + updateLogsCommand.Parameters.AddWithValue("$recordSessionId", sessionId); + updateLogsCommand.Parameters.AddWithValue("$taskId", orphanTask.Id); + await updateLogsCommand.ExecuteNonQueryAsync(cancellationToken); + } + } + + private static int MapLegacyTaskStatusToSessionStatus(int taskStatus) => + taskStatus switch + { + (int)RecordTaskStatus.Pending => (int)RecordSessionStatus.Pending, + (int)RecordTaskStatus.Starting => (int)RecordSessionStatus.Starting, + (int)RecordTaskStatus.Running => (int)RecordSessionStatus.Running, + (int)RecordTaskStatus.Stopping => (int)RecordSessionStatus.Stopping, + (int)RecordTaskStatus.Completed => (int)RecordSessionStatus.Completed, + (int)RecordTaskStatus.Failed => (int)RecordSessionStatus.Failed, + (int)RecordTaskStatus.Stopped => (int)RecordSessionStatus.Stopped, + _ => (int)RecordSessionStatus.Stopped + }; + + private sealed record LegacyTaskRow( + string Id, + string LiveRoomId, + int Status, + string PreferredQuality, + int OutputFormat, + string? StreamUrl, + string? OutputFilePath, + string? ErrorMessage, + string CreatedAt, + string UpdatedAt, + string? StartedAt, + string? EndedAt); +} diff --git a/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs b/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs new file mode 100644 index 0000000..161dcfe --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs @@ -0,0 +1,145 @@ +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace LiveRecorder.Infrastructure.Persistence; + +public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork +{ + public LiveRecorderDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet LiveRooms => Set(); + + public DbSet RecordSessions => Set(); + + public DbSet RecordTasks => Set(); + + public DbSet RecordResults => Set(); + + public DbSet SystemLogEntries => Set(); + + public DbSet AppSettings => Set(); + + public DbSet UserAccounts => Set(); + + public DbSet UserSessions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(builder => + { + builder.ToTable("LiveRooms"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Platform).HasConversion(); + builder.Property(static x => x.AvailabilityStatus).HasConversion(); + builder.HasIndex(static x => new { x.Platform, x.RoomId }).IsUnique(); + builder.Property(static x => x.SourceUrl).HasMaxLength(512); + builder.Property(static x => x.NormalizedUrl).HasMaxLength(512); + builder.Property(static x => x.RoomId).HasMaxLength(128); + builder.Property(static x => x.Title).HasMaxLength(256); + builder.Property(static x => x.AnchorName).HasMaxLength(128); + builder.Property(static x => x.CoverUrl).HasMaxLength(512); + builder.Property(static x => x.IsEnabled).HasDefaultValue(true); + builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("RecordTasks"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Status).HasConversion(); + builder.Property(static x => x.OutputFormat).HasConversion(); + builder.Property(static x => x.PreferredQuality).HasMaxLength(64); + builder.Property(static x => x.StreamUrl).HasMaxLength(2048); + builder.Property(static x => x.OutputFilePath).HasMaxLength(2048); + builder.Property(static x => x.ErrorMessage).HasMaxLength(2048); + builder.HasOne(static x => x.LiveRoom) + .WithMany(static x => x.RecordTasks) + .HasForeignKey(static x => x.LiveRoomId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne(static x => x.RecordSession) + .WithMany(static x => x.RecordTasks) + .HasForeignKey(static x => x.RecordSessionId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(static x => new { x.RecordSessionId, x.SegmentIndex }); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("RecordSessions"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Status).HasConversion(); + builder.Property(static x => x.OutputFormat).HasConversion(); + builder.Property(static x => x.SaveMode).HasConversion(); + builder.Property(static x => x.PreferredQuality).HasMaxLength(64); + builder.Property(static x => x.StreamUrl).HasMaxLength(2048); + builder.Property(static x => x.OutputPathPattern).HasMaxLength(2048); + builder.Property(static x => x.ErrorMessage).HasMaxLength(2048); + builder.HasOne(static x => x.LiveRoom) + .WithMany() + .HasForeignKey(static x => x.LiveRoomId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("RecordResults"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.FinalStatus).HasConversion(); + builder.Property(static x => x.FilePath).HasMaxLength(2048); + builder.Property(static x => x.DanmakuFilePath).HasMaxLength(2048); + builder.Property(static x => x.ErrorMessage).HasMaxLength(2048); + builder.HasIndex(static x => x.RecordTaskId).IsUnique(); + builder.HasOne(static x => x.RecordTask) + .WithOne(static x => x.Result) + .HasForeignKey(static x => x.RecordTaskId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("SystemLogEntries"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Level).HasConversion(); + builder.Property(static x => x.Category).HasMaxLength(128); + builder.Property(static x => x.Message).HasMaxLength(512); + builder.Property(static x => x.Detail).HasMaxLength(4000); + builder.HasIndex(static x => x.CreatedAt); + builder.HasIndex(static x => x.RecordSessionId); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("AppSettings"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Key).HasMaxLength(128); + builder.Property(static x => x.Value).HasMaxLength(4000); + builder.HasIndex(static x => x.Key).IsUnique(); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("UserAccounts"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Username).HasMaxLength(64); + builder.Property(static x => x.DisplayName).HasMaxLength(64); + builder.Property(static x => x.PasswordHash).HasMaxLength(512); + builder.HasIndex(static x => x.Username).IsUnique(); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("UserSessions"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Token).HasMaxLength(128); + builder.HasIndex(static x => x.Token).IsUnique(); + builder.HasOne(static x => x.UserAccount) + .WithMany() + .HasForeignKey(static x => x.UserAccountId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs new file mode 100644 index 0000000..2ed117f --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs @@ -0,0 +1,325 @@ +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace LiveRecorder.Infrastructure.Persistence.Repositories; + +public sealed class AppSettingRepository : IAppSettingRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public AppSettingRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByKeyAsync(string key, CancellationToken cancellationToken = default) => + _dbContext.AppSettings.FirstOrDefaultAsync(item => item.Key == key, cancellationToken); + + public async Task> ListAsync(CancellationToken cancellationToken = default) => + await _dbContext.AppSettings.OrderBy(static item => item.Key).ToListAsync(cancellationToken); + + public Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default) => + _dbContext.AppSettings.AddAsync(setting, cancellationToken).AsTask(); + + public void Update(AppSetting setting) => _dbContext.AppSettings.Update(setting); +} + +public sealed class LiveRoomRepository : ILiveRoomRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public LiveRoomRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public Task GetByPlatformRoomIdAsync( + LivePlatformType platformType, + string roomId, + CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.FirstOrDefaultAsync( + item => item.Platform == platformType && item.RoomId == roomId, + cancellationToken); + + public async Task> ListAsync(CancellationToken cancellationToken = default) => + (await _dbContext.LiveRooms.AsNoTracking().ToListAsync(cancellationToken)) + .OrderByDescending(static item => item.UpdatedAt) + .ToList(); + + public Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask(); + + public void Remove(LiveRoom liveRoom) => _dbContext.LiveRooms.Remove(liveRoom); +} + +public sealed class RecordTaskRepository : IRecordTaskRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public RecordTaskRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public async Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) + { + return []; + } + + return await _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.Result) + .Where(item => ids.Contains(item.Id)) + .ToListAsync(cancellationToken); + } + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + IQueryable query = _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.RecordSession) + .Include(item => item.Result) + .AsNoTracking(); + + if (liveRoomId.HasValue) + { + query = query.Where(item => item.LiveRoomId == liveRoomId.Value); + } + + var items = await query.ToListAsync(cancellationToken); + return items + .OrderByDescending(static item => item.CreatedAt) + .ToList(); + } + + public async Task> ListBySessionIdAsync(Guid recordSessionId, CancellationToken cancellationToken = default) + { + var items = await _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.RecordSession) + .Include(item => item.Result) + .AsNoTracking() + .Where(item => item.RecordSessionId == recordSessionId) + .ToListAsync(cancellationToken); + + return items + .OrderBy(static item => item.SegmentIndex) + .ThenBy(static item => item.CreatedAt) + .ToList(); + } + + public Task GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.RecordSession) + .FirstOrDefaultAsync( + item => item.LiveRoomId == liveRoomId && + (item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running), + cancellationToken); + + public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask(); + + public void Remove(RecordTask recordTask) => _dbContext.RecordTasks.Remove(recordTask); + + public void RemoveRange(IEnumerable recordTasks) => _dbContext.RecordTasks.RemoveRange(recordTasks); +} + +public sealed class RecordSessionRepository : IRecordSessionRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public RecordSessionRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex)) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + IQueryable query = _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex)) + .ThenInclude(item => item.Result) + .AsNoTracking(); + + if (liveRoomId.HasValue) + { + query = query.Where(item => item.LiveRoomId == liveRoomId.Value); + } + + var items = await query.ToListAsync(cancellationToken); + return items + .OrderByDescending(static item => item.CreatedAt) + .ToList(); + } + + public Task GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) => + _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync( + item => item.LiveRoomId == liveRoomId && + (item.Status == RecordSessionStatus.Starting || + item.Status == RecordSessionStatus.Running || + item.Status == RecordSessionStatus.Stopping), + cancellationToken); + + public Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default) => + _dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask(); + + public void Remove(RecordSession recordSession) => _dbContext.RecordSessions.Remove(recordSession); +} + +public sealed class RecordResultRepository : IRecordResultRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public RecordResultRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) => + _dbContext.RecordResults.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken); + + public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) => + _dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask(); + + public void Update(RecordResult recordResult) => _dbContext.RecordResults.Update(recordResult); + + public void Remove(RecordResult recordResult) => _dbContext.RecordResults.Remove(recordResult); + + public void RemoveRange(IEnumerable recordResults) => _dbContext.RecordResults.RemoveRange(recordResults); +} + +public sealed class SystemLogRepository : ISystemLogRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public SystemLogRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default) => + _dbContext.SystemLogEntries.AddAsync(entry, cancellationToken).AsTask(); + + public async Task> ListByRecordTaskIdsAsync( + IReadOnlyCollection recordTaskIds, + CancellationToken cancellationToken = default) + { + if (recordTaskIds.Count == 0) + { + return []; + } + + return await _dbContext.SystemLogEntries + .Where(item => item.RecordTaskId.HasValue && recordTaskIds.Contains(item.RecordTaskId.Value)) + .ToListAsync(cancellationToken); + } + + public async Task> ListByRecordSessionIdsAsync( + IReadOnlyCollection recordSessionIds, + CancellationToken cancellationToken = default) + { + if (recordSessionIds.Count == 0) + { + return []; + } + + return await _dbContext.SystemLogEntries + .Where(item => item.RecordSessionId.HasValue && recordSessionIds.Contains(item.RecordSessionId.Value)) + .ToListAsync(cancellationToken); + } + + public async Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default) + { + IQueryable query = _dbContext.SystemLogEntries.AsNoTracking(); + + if (liveRoomId.HasValue) + { + query = query.Where(item => item.LiveRoomId == liveRoomId.Value); + } + + if (recordTaskId.HasValue) + { + query = query.Where(item => item.RecordTaskId == recordTaskId.Value); + } + + if (recordSessionId.HasValue) + { + query = query.Where(item => item.RecordSessionId == recordSessionId.Value); + } + + var items = await query.ToListAsync(cancellationToken); + return items + .OrderByDescending(static item => item.CreatedAt) + .Take(Math.Clamp(take, 1, 500)) + .ToList(); + } + + public void RemoveRange(IEnumerable entries) => _dbContext.SystemLogEntries.RemoveRange(entries); +} + +public sealed class UserAccountRepository : IUserAccountRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public UserAccountRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.UserAccounts.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default) => + _dbContext.UserAccounts.FirstOrDefaultAsync(item => item.Username == username, cancellationToken); + + public Task AddAsync(UserAccount userAccount, CancellationToken cancellationToken = default) => + _dbContext.UserAccounts.AddAsync(userAccount, cancellationToken).AsTask(); +} + +public sealed class UserSessionRepository : IUserSessionRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public UserSessionRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByTokenAsync(string token, CancellationToken cancellationToken = default) => + _dbContext.UserSessions.FirstOrDefaultAsync(item => item.Token == token, cancellationToken); + + public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) => + _dbContext.UserSessions.AddAsync(session, cancellationToken).AsTask(); + + public void Update(UserSession session) => _dbContext.UserSessions.Update(session); +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliLivePlatformAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliLivePlatformAdapter.cs new file mode 100644 index 0000000..602a2da --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliLivePlatformAdapter.cs @@ -0,0 +1,25 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Platforms.Bilibili; + +public sealed class BilibiliLivePlatformAdapter : ILivePlatformAdapter +{ + public LivePlatformType PlatformType => LivePlatformType.Bilibili; + + public bool CanHandle(string input) => + !string.IsNullOrWhiteSpace(input) && + input.Contains("bilibili.com", StringComparison.OrdinalIgnoreCase); + + public Task ParseRoomAsync(string input, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Bilibili 适配器尚未实现。"); + + public Task GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Bilibili 适配器尚未实现。"); + + public Task GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("Bilibili 适配器尚未实现。"); +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuAdapter.cs new file mode 100644 index 0000000..ee7b727 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuAdapter.cs @@ -0,0 +1,147 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku; + +public sealed class DouyinDanmakuAdapter : ILiveDanmakuAdapter +{ + private readonly DouyinHttpClient _douyinHttpClient; + private readonly ILoggerFactory _loggerFactory; + + public DouyinDanmakuAdapter( + DouyinHttpClient douyinHttpClient, + ILoggerFactory loggerFactory) + { + _douyinHttpClient = douyinHttpClient; + _loggerFactory = loggerFactory; + } + + public LivePlatformType PlatformType => LivePlatformType.Douyin; + + public bool CanHandle(LivePlatformType platformType) => platformType == LivePlatformType.Douyin; + + public Task ConnectAsync(DanmakuConnectionContext context, CancellationToken cancellationToken = default) => + Task.FromResult(new DouyinDanmakuConnection(_douyinHttpClient, _loggerFactory.CreateLogger(), context)); +} + +internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection +{ + private readonly DouyinHttpClient _douyinHttpClient; + private readonly ILogger _logger; + private readonly DanmakuConnectionContext _context; + + public DouyinDanmakuConnection( + DouyinHttpClient douyinHttpClient, + ILogger logger, + DanmakuConnectionContext context) + { + _douyinHttpClient = douyinHttpClient; + _logger = logger; + _context = context; + } + + public async Task StartAsync(Func onEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(onEvent); + + var bootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken); + var danmakuRoomId = bootstrap.DanmakuRoomId; + var cursor = bootstrap.Cursor ?? string.Empty; + var internalExt = bootstrap.InternalExt ?? string.Empty; + var userUniqueId = bootstrap.UserUniqueId ?? throw new InvalidOperationException("Douyin danmaku bootstrap did not provide a user unique id."); + var backoff = TimeSpan.FromSeconds(1); + var minPollInterval = TimeSpan.FromMilliseconds(Math.Max(100, _context.MinPollIntervalMilliseconds)); + var maxBackoff = TimeSpan.FromSeconds(Math.Max(1, _context.RetryDelayMaxSeconds)); + var consecutiveEmptyPolls = 0; + + _logger.LogInformation( + "Douyin danmaku bootstrap resolved web room {WebRoomId} to im room {DanmakuRoomId}. CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}; UserUniqueId={UserUniqueId}", + _context.RoomId, + danmakuRoomId, + !string.IsNullOrWhiteSpace(cursor), + !string.IsNullOrWhiteSpace(internalExt), + userUniqueId); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var bytes = await _douyinHttpClient.GetLiveImAsync( + danmakuRoomId, + userUniqueId, + cursor, + internalExt, + cancellationToken); + var envelope = DouyinDanmakuProtocol.Parse(bytes); + + cursor = envelope.Cursor ?? cursor; + internalExt = envelope.InternalExt ?? internalExt; + + foreach (var danmakuEvent in envelope.Events) + { + await onEvent(danmakuEvent); + } + + if (envelope.Events.Count == 0) + { + consecutiveEmptyPolls++; + if (consecutiveEmptyPolls == 1 || consecutiveEmptyPolls % 30 == 0) + { + _logger.LogWarning( + "Douyin danmaku polling returned no events for web room {WebRoomId}. ImRoomId={DanmakuRoomId}; EmptyPolls={EmptyPolls}; CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}", + _context.RoomId, + danmakuRoomId, + consecutiveEmptyPolls, + !string.IsNullOrWhiteSpace(cursor), + !string.IsNullOrWhiteSpace(internalExt)); + } + + if (consecutiveEmptyPolls % 30 == 0) + { + var refreshedBootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken); + if (!string.Equals(refreshedBootstrap.DanmakuRoomId, danmakuRoomId, StringComparison.Ordinal)) + { + _logger.LogInformation( + "Douyin danmaku bootstrap refreshed im room id from {OldDanmakuRoomId} to {NewDanmakuRoomId} for web room {WebRoomId}.", + danmakuRoomId, + refreshedBootstrap.DanmakuRoomId, + _context.RoomId); + danmakuRoomId = refreshedBootstrap.DanmakuRoomId; + } + + if (!string.IsNullOrWhiteSpace(refreshedBootstrap.Cursor)) + { + cursor = refreshedBootstrap.Cursor; + } + + if (!string.IsNullOrWhiteSpace(refreshedBootstrap.InternalExt)) + { + internalExt = refreshedBootstrap.InternalExt; + } + } + } + else + { + consecutiveEmptyPolls = 0; + } + + backoff = TimeSpan.FromSeconds(1); + var pollDelay = envelope.PollInterval < minPollInterval ? minPollInterval : envelope.PollInterval; + await Task.Delay(pollDelay, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Douyin danmaku polling failed for room {RoomId}. Retrying.", _context.RoomId); + await Task.Delay(backoff, cancellationToken); + backoff = TimeSpan.FromSeconds(Math.Min(maxBackoff.TotalSeconds, backoff.TotalSeconds * 2)); + } + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuProtocol.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuProtocol.cs new file mode 100644 index 0000000..d469828 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuProtocol.cs @@ -0,0 +1,480 @@ +using System.IO.Compression; +using System.Text; +using LiveRecorder.Application.Abstractions.Platforms; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku; + +internal static class DouyinDanmakuProtocol +{ + public static DouyinDanmakuEnvelope Parse(byte[] payload) + { + if (payload.Length == 0) + { + return new DouyinDanmakuEnvelope([], null, null, TimeSpan.FromSeconds(1)); + } + + var responseBytes = payload.AsSpan(); + var pushFrame = TryParsePushFrame(responseBytes); + if (pushFrame.Payload.Length > 0) + { + responseBytes = pushFrame.Payload; + } + + if (LooksLikeGzip(responseBytes)) + { + responseBytes = Decompress(responseBytes); + } + + var response = ParseResponse(responseBytes); + return response; + } + + private static DouyinPushFrame ParsePushFrame(ReadOnlySpan data) + { + var payload = ReadOnlySpan.Empty; + var payloadEncoding = string.Empty; + var payloadType = string.Empty; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 5 when wireType == 2: + var headerBytes = ReadLengthDelimited(data, ref index); + var (key, value) = ParseHeader(headerBytes); + if (string.Equals(key, "compress_type", StringComparison.OrdinalIgnoreCase)) + { + payloadEncoding = value; + } + break; + case 7 when wireType == 2: + payloadType = ReadString(data, ref index); + break; + case 8 when wireType == 2: + payload = ReadLengthDelimited(data, ref index).ToArray(); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + if (payload.Length > 0 && + (LooksLikeGzip(payload) || + payloadEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase) || + payloadType.Contains("response", StringComparison.OrdinalIgnoreCase))) + { + payload = Decompress(payload); + } + + return new DouyinPushFrame(payload.ToArray()); + } + + private static DouyinPushFrame TryParsePushFrame(ReadOnlySpan data) + { + try + { + return ParsePushFrame(data); + } + catch + { + return new DouyinPushFrame(Array.Empty()); + } + } + + private static DouyinDanmakuEnvelope ParseResponse(ReadOnlySpan data) + { + var events = new List(); + string? cursor = null; + string? internalExt = null; + TimeSpan pollInterval = TimeSpan.FromSeconds(1); + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 2: + var messageBytes = ReadLengthDelimited(data, ref index); + var danmakuEvent = ParseEnvelopeMessage(messageBytes); + if (danmakuEvent is not null) + { + events.Add(danmakuEvent); + } + break; + case 2 when wireType == 2: + cursor = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 5 when wireType == 2: + internalExt = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 3 when wireType == 0: + case 8 when wireType == 0: + pollInterval = TimeSpan.FromMilliseconds(Math.Max(500, (long)ReadVarint(data, ref index))); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DouyinDanmakuEnvelope(events, cursor, internalExt, pollInterval); + } + + private static DanmakuEvent? ParseEnvelopeMessage(ReadOnlySpan data) + { + string? method = null; + ReadOnlySpan payload = []; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 2: + method = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 2 when wireType == 2: + payload = ReadLengthDelimited(data, ref index).ToArray(); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + if (string.IsNullOrWhiteSpace(method) || payload.Length == 0) + { + return null; + } + + var occurredAt = DateTimeOffset.UtcNow; + return method switch + { + "WebcastChatMessage" => ParseChat(payload, occurredAt), + "WebcastGiftMessage" => ParseGift(payload, occurredAt), + "WebcastLikeMessage" => ParseLike(payload, occurredAt), + "WebcastMemberMessage" => ParseMember(payload, occurredAt), + "WebcastSocialMessage" => ParseSocial(payload, occurredAt), + _ => new DanmakuEvent("other", null, null, method, occurredAt, Convert.ToBase64String(payload.ToArray())) + }; + } + + private static DanmakuEvent ParseChat(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + string? content = null; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + case 3 when wireType == 2: + content = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent("chat", user, userId, content, occurredAt, Convert.ToBase64String(data.ToArray())); + } + + private static DanmakuEvent ParseGift(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + long giftId = 0; + long repeatCount = 0; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 0: + giftId = (long)ReadVarint(data, ref index); + break; + case 5 when wireType == 0: + repeatCount = (long)ReadVarint(data, ref index); + break; + case 7 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + var extra = new Dictionary + { + ["giftId"] = giftId.ToString(), + ["repeatCount"] = repeatCount.ToString() + }; + return new DanmakuEvent("gift", user, userId, $"gift:{giftId} x{Math.Max(1, repeatCount)}", occurredAt, Convert.ToBase64String(data.ToArray()), extra); + } + + private static DanmakuEvent ParseLike(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + long count = 0; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 0: + count = (long)ReadVarint(data, ref index); + break; + case 5 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent( + "like", + user, + userId, + $"like x{Math.Max(1, count)}", + occurredAt, + Convert.ToBase64String(data.ToArray()), + new Dictionary { ["count"] = count.ToString() }); + } + + private static DanmakuEvent ParseMember(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent("member", user, userId, "member joined", occurredAt, Convert.ToBase64String(data.ToArray())); + } + + private static DanmakuEvent ParseSocial(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + long action = 0; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + case 4 when wireType == 0: + action = (long)ReadVarint(data, ref index); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent( + "enter", + user, + userId, + $"action:{action}", + occurredAt, + Convert.ToBase64String(data.ToArray()), + new Dictionary { ["action"] = action.ToString() }); + } + + private static (string? UserId, string? UserName) ParseUser(ReadOnlySpan data) + { + string? userId = null; + string? userName = null; + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 0: + userId = ReadVarint(data, ref index).ToString(); + break; + case 3 when wireType == 2: + userName = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return (userId, userName); + } + + private static (string Key, string Value) ParseHeader(ReadOnlySpan data) + { + string key = string.Empty; + string value = string.Empty; + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 2: + key = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 2 when wireType == 2: + value = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return (key, value); + } + + private static ulong ReadVarint(ReadOnlySpan data, ref int index) + { + ulong result = 0; + var shift = 0; + while (index < data.Length) + { + var value = data[index++]; + result |= (ulong)(value & 0x7F) << shift; + if ((value & 0x80) == 0) + { + return result; + } + + shift += 7; + if (shift > 63) + { + throw new InvalidDataException("Invalid protobuf varint."); + } + } + + throw new EndOfStreamException(); + } + + private static ReadOnlySpan ReadLengthDelimited(ReadOnlySpan data, ref int index) + { + var length = checked((int)ReadVarint(data, ref index)); + if (length < 0 || index + length > data.Length) + { + throw new InvalidDataException("Invalid protobuf length-delimited field."); + } + + var result = data.Slice(index, length); + index += length; + return result; + } + + private static string ReadString(ReadOnlySpan data, ref int index) => + Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + + private static void SkipField(ReadOnlySpan data, ref int index, int wireType) + { + switch (wireType) + { + case 0: + ReadVarint(data, ref index); + return; + case 1: + index += 8; + return; + case 2: + _ = ReadLengthDelimited(data, ref index); + return; + case 5: + index += 4; + return; + default: + throw new InvalidDataException($"Unsupported protobuf wire type: {wireType}"); + } + } + + private static bool LooksLikeGzip(ReadOnlySpan data) => + data.Length >= 2 && data[0] == 0x1F && data[1] == 0x8B; + + private static byte[] Decompress(ReadOnlySpan data) + { + using var input = new MemoryStream(data.ToArray()); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } + + internal sealed record DouyinDanmakuEnvelope( + IReadOnlyList Events, + string? Cursor, + string? InternalExt, + TimeSpan PollInterval); + + private readonly record struct DouyinPushFrame(byte[] Payload); +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs new file mode 100644 index 0000000..9ae6aff --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs @@ -0,0 +1,681 @@ +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Infrastructure.Platforms.Douyin.Signing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin; + +public sealed class DouyinHttpClient +{ + private const int MaxAttempts = 3; + private const int MsTokenLength = 184; + private static readonly Regex PaceStateRegex = new( + "self\\.__pace_f\\.push\\(\\[1,\"[a-z]?:\\[\\\\\"\\$\\\\\",\\\\\"\\$L\\d+\\\\\",null,(?[\\s\\S]+?state[\\s\\S]+?)\\]\\\\n\"\\]\\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex EscapedQuoteRegex = new( + "\\\\{1,7}\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex HtmlRoomIdRegex = new( + "\"roomId\":\"(?\\d{8,})\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlRoomIdNewRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"roomId\":\"(?\\d{8,})\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlTitleRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"room\":{[\\s\\S]*?\"title\":\"(?[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlAnchorRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ISystemSettingsService _systemSettingsService; + private readonly DouyinXBogusSigner _xBogusSigner; + private readonly ILogger<DouyinHttpClient> _logger; + + public DouyinHttpClient( + IHttpClientFactory httpClientFactory, + ISystemSettingsService systemSettingsService, + DouyinXBogusSigner xBogusSigner, + ILogger<DouyinHttpClient> logger) + { + _httpClientFactory = httpClientFactory; + _systemSettingsService = systemSettingsService; + _xBogusSigner = xBogusSigner; + _logger = logger; + } + + public async Task<(Uri? FinalUri, string Body)> ResolvePageAsync(string input, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, input); + ApplyDefaultHeaders(request, settings, refererRoomId: null); + + if (!string.IsNullOrWhiteSpace(cookieHeader)) + { + request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); + } + + return request; + }, + HttpCompletionOption.ResponseContentRead, + cancellationToken); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return (response.RequestMessage?.RequestUri, body); + } + + public async Task<JsonDocument> GetRoomEnterAsync(string roomId, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, BuildRoomEnterUri(roomId)); + ApplyDefaultHeaders(request, settings, roomId); + + if (!string.IsNullOrWhiteSpace(cookieHeader)) + { + request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); + } + + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + } + + public async Task<StreamInputHeaders> GetStreamInputHeadersAsync(string roomId, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + var additionalHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["Origin"] = "https://live.douyin.com" + }; + + return new StreamInputHeaders( + settings.DouyinUserAgent, + string.IsNullOrWhiteSpace(roomId) ? settings.DouyinReferer : $"https://live.douyin.com/{roomId}", + string.IsNullOrWhiteSpace(cookieHeader) ? null : cookieHeader, + additionalHeaders); + } + + public async Task<byte[]> GetLiveImAsync( + string roomId, + string userUniqueId, + string? cursor, + string? internalExt, + CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + var requestUri = await BuildSignedLiveImUriAsync( + roomId, + userUniqueId, + cursor, + internalExt, + settings.DouyinUserAgent, + cancellationToken); + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + ApplyLiveImHeaders(request, settings, roomId); + request.Headers.Accept.ParseAdd("application/protobuf, application/octet-stream, */*"); + + if (!string.IsNullOrWhiteSpace(cookieHeader)) + { + request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); + } + + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + var body = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (body.Length == 0) + { + throw new InvalidOperationException($"Douyin live im returned an empty body for room {roomId}."); + } + + return body; + } + + public async Task<DouyinDanmakuBootstrap> GetDanmakuBootstrapAsync( + string webRoomId, + CancellationToken cancellationToken = default) + { + var (_, pageHtml) = await ResolvePageAsync($"https://live.douyin.com/{webRoomId}", cancellationToken); + using var document = await GetRoomEnterAsync(webRoomId, cancellationToken); + var root = document.RootElement; + var room = GetRoomNode(root); + + var danmakuRoomId = + TryExtractDanmakuRoomIdFromHtml(pageHtml) ?? + ReadNumericLikeString(room, "id_str") ?? + ReadNumericLikeString(room, "id") ?? + ReadNumericLikeString(room, "room_id") ?? + ReadNumericLikeString(room, "roomId") ?? + FindNumericLikeValue(room, "id_str", "room_id", "roomId", "id") ?? + webRoomId; + + var (anchor, title) = TryExtractAnchorAndTitleFromHtml(pageHtml); + + return new DouyinDanmakuBootstrap( + webRoomId, + danmakuRoomId, + null, + null, + CreateAnonymousUserUniqueId(), + anchor, + title); + } + + private async Task<string> BuildCookieHeaderAsync(SystemSettingsDto settings, CancellationToken cancellationToken) + { + var configuredCookie = settings.DouyinCookie?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(configuredCookie) && + configuredCookie.Contains("ttwid=", StringComparison.OrdinalIgnoreCase)) + { + return configuredCookie; + } + + var ttwidCookie = await FetchTtwidCookieAsync(settings, cancellationToken); + if (string.IsNullOrWhiteSpace(ttwidCookie)) + { + return configuredCookie; + } + + if (string.IsNullOrWhiteSpace(configuredCookie)) + { + return ttwidCookie; + } + + return $"{configuredCookie.TrimEnd(';')}; {ttwidCookie}"; + } + + private async Task<string?> FetchTtwidCookieAsync(SystemSettingsDto settings, CancellationToken cancellationToken) + { + try + { + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, settings.DouyinReferer); + ApplyDefaultHeaders(request, settings, refererRoomId: null); + + if (!string.IsNullOrWhiteSpace(settings.DouyinCookie)) + { + request.Headers.TryAddWithoutValidation("Cookie", settings.DouyinCookie.Trim()); + } + + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) + { + return null; + } + + foreach (var cookieLine in setCookieHeaders) + { + var part = cookieLine + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(item => item.StartsWith("ttwid=", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(part)) + { + return part; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fetch ttwid cookie failed."); + } + + return null; + } + + private async Task<HttpResponseMessage> SendWithRetryAsync( + Func<HttpRequestMessage> requestFactory, + HttpCompletionOption completionOption, + CancellationToken cancellationToken) + { + var client = _httpClientFactory.CreateClient("douyin"); + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + using var request = requestFactory(); + + try + { + var response = await client.SendAsync(request, completionOption, cancellationToken); + if (response.IsSuccessStatusCode) + { + return response; + } + + if (attempt < MaxAttempts && IsTransientStatusCode(response.StatusCode)) + { + response.Dispose(); + await DelayForRetryAsync(attempt, cancellationToken); + continue; + } + + response.EnsureSuccessStatusCode(); + return response; + } + catch (Exception ex) when (attempt < MaxAttempts && IsTransientTransportException(ex, cancellationToken)) + { + _logger.LogWarning(ex, "Douyin request transient failure on attempt {Attempt}. Retrying.", attempt); + await DelayForRetryAsync(attempt, cancellationToken); + } + } + + using var lastRequest = requestFactory(); + return await client.SendAsync(lastRequest, completionOption, cancellationToken); + } + + private static void ApplyDefaultHeaders(HttpRequestMessage request, SystemSettingsDto settings, string? refererRoomId) + { + request.Version = HttpVersion.Version11; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + request.Headers.Accept.ParseAdd("application/json, text/plain, */*"); + request.Headers.ConnectionClose = true; + request.Headers.UserAgent.ParseAdd(settings.DouyinUserAgent); + request.Headers.TryAddWithoutValidation("Origin", "https://live.douyin.com"); + request.Headers.Referrer = new Uri( + string.IsNullOrWhiteSpace(refererRoomId) + ? settings.DouyinReferer + : $"https://live.douyin.com/{refererRoomId}"); + } + + private static void ApplyLiveImHeaders(HttpRequestMessage request, SystemSettingsDto settings, string roomId) + { + ApplyDefaultHeaders(request, settings, roomId); + request.Headers.TryAddWithoutValidation("Host", "live.douyin.com"); + } + + private static string BuildRoomEnterUri(string roomId) + { + var parameters = new Dictionary<string, string?> + { + ["aid"] = "6383", + ["app_name"] = "douyin_web", + ["device_platform"] = "web", + ["language"] = "zh-CN", + ["browser_language"] = "zh-CN", + ["browser_platform"] = "Win32", + ["browser_name"] = "Mozilla", + ["browser_version"] = "5.0", + ["enter_from"] = "web_live", + ["cookie_enabled"] = "true", + ["screen_width"] = "1920", + ["screen_height"] = "1080", + ["is_need_double_stream"] = "true", + ["web_rid"] = roomId + }; + + return QueryHelpers.AddQueryString("https://live.douyin.com/webcast/room/web/enter/", parameters); + } + + private async Task<string> BuildSignedLiveImUriAsync( + string roomId, + string userUniqueId, + string? cursor, + string? internalExt, + string userAgent, + CancellationToken cancellationToken) + { + var parameters = BuildLiveImParameters(roomId, userUniqueId, cursor, internalExt, userAgent); + var unencodedQuery = string.Join("&", parameters.Select(p => $"{p.Key}={p.Value}")); + var signedValue = await _xBogusSigner.SignAsync(unencodedQuery, userAgent, cancellationToken); + parameters["a_bogus"] = signedValue; + return QueryHelpers.AddQueryString("https://live.douyin.com/webcast/im/fetch/", parameters); + } + + private static Dictionary<string, string?> BuildLiveImParameters( + string roomId, + string userUniqueId, + string? cursor, + string? internalExt, + string userAgent) + { + return new Dictionary<string, string?> + { + ["aid"] = "6383", + ["app_name"] = "douyin_web", + ["browser_language"] = "zh-CN", + ["browser_name"] = "Mozilla", + ["browser_online"] = "true", + ["browser_platform"] = "Win32", + ["browser_version"] = string.IsNullOrWhiteSpace(userAgent) ? "5.0" : userAgent, + ["compress"] = "gzip", + ["cookie_enabled"] = "true", + ["cursor"] = cursor ?? string.Empty, + ["device_id"] = string.Empty, + ["device_platform"] = "web", + ["did_rule"] = "3", + ["endpoint"] = "live_pc", + ["fetch_rule"] = "1", + ["host"] = "https://live.douyin.com", + ["identity"] = "audience", + ["insert_task_id"] = string.Empty, + ["internal_ext"] = internalExt ?? string.Empty, + ["last_rtt"] = "0", + ["live_id"] = "1", + ["live_pc"] = roomId, + ["live_reason"] = string.Empty, + ["msToken"] = CreateMsToken(), + ["need_persist_msg_count"] = "15", + ["room_id"] = roomId, + ["room_id_str"] = roomId, + ["resp_content_type"] = "protobuf", + ["screen_height"] = "1080", + ["screen_width"] = "1920", + ["support_wrds"] = "1", + ["tz_name"] = "Asia/Shanghai", + ["user_unique_id"] = userUniqueId, + ["version_code"] = "180800", + ["webcast_sdk_version"] = "1.0.15" + }; + } + + private static bool IsTransientStatusCode(HttpStatusCode statusCode) => + statusCode == HttpStatusCode.RequestTimeout || + statusCode == (HttpStatusCode)429 || + (int)statusCode >= 500; + + private static bool IsTransientTransportException(Exception exception, CancellationToken cancellationToken) + { + if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested) + { + return false; + } + + if (exception is HttpRequestException or IOException or TimeoutException) + { + return true; + } + + return exception.InnerException is not null && + IsTransientTransportException(exception.InnerException, cancellationToken); + } + + private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) => + Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken); + + private static JsonElement GetRoomNode(JsonElement root) + { + if (!TryGetProperty(root, "data", out var data)) + { + throw new InvalidOperationException("Douyin response does not contain a data node."); + } + + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + return data[0]; + } + + if (data.ValueKind == JsonValueKind.Object) + { + if (TryGetProperty(data, "data", out var innerData)) + { + if (innerData.ValueKind == JsonValueKind.Array && innerData.GetArrayLength() > 0) + { + return innerData[0]; + } + + if (innerData.ValueKind == JsonValueKind.Object) + { + return innerData; + } + } + + if (TryGetProperty(data, "room", out var room)) + { + return room; + } + + return data; + } + + throw new InvalidOperationException("Douyin response data node format is not supported."); + } + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty(name, out value)) + { + return true; + } + + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + + value = default; + return false; + } + + private static string? ReadNumericLikeString(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + { + return null; + } + + return NormalizeNumericLikeString(ReadScalarString(value)); + } + + private static string? FindNumericLikeValue(JsonElement element, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + var value = FindScalarStringByPropertyName(element, propertyName); + var normalized = NormalizeNumericLikeString(value); + if (!string.IsNullOrWhiteSpace(normalized)) + { + return normalized; + } + } + + return null; + } + + private static string? FindScalarString(JsonElement element, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + var value = FindScalarStringByPropertyName(element, propertyName); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private static string? FindScalarStringByPropertyName(JsonElement element, string propertyName) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + var scalar = ReadScalarString(property.Value); + if (!string.IsNullOrWhiteSpace(scalar)) + { + return scalar; + } + } + + var nested = FindScalarStringByPropertyName(property.Value, propertyName); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + var nested = FindScalarStringByPropertyName(item, propertyName); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + + return null; + } + + private static string? ReadScalarString(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.True => bool.TrueString.ToLowerInvariant(), + JsonValueKind.False => bool.FalseString.ToLowerInvariant(), + _ => null + }; + } + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string? NormalizeNumericLikeString(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.All(char.IsDigit) ? trimmed : null; + } + + private static string CreateMsToken() + { + const string characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_="; + Span<char> buffer = stackalloc char[MsTokenLength]; + for (var index = 0; index < buffer.Length; index++) + { + buffer[index] = characters[Random.Shared.Next(0, characters.Length)]; + } + + return new string(buffer); + } + + private static string CreateAnonymousUserUniqueId() + { + Span<char> buffer = stackalloc char[19]; + buffer[0] = '7'; + for (var index = 1; index < buffer.Length; index++) + { + buffer[index] = (char)('0' + Random.Shared.Next(0, 10)); + } + + return new string(buffer); + } + + private static string? TryExtractDanmakuRoomIdFromHtml(string html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return null; + } + + var stateMatch = PaceStateRegex.Match(html); + if (!stateMatch.Success) + { + return null; + } + + var statePayload = stateMatch.Groups["state"].Value; + if (string.IsNullOrWhiteSpace(statePayload)) + { + return null; + } + + var normalized = Regex.Unescape(statePayload); + var roomIdMatch = HtmlRoomIdNewRegex.Match(normalized); + if (!roomIdMatch.Success) + { + roomIdMatch = HtmlRoomIdRegex.Match(normalized); + } + return roomIdMatch.Success ? roomIdMatch.Groups["id"].Value : null; + } + private static (string? Anchor, string? Title) TryExtractAnchorAndTitleFromHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return (null, null); + } + + var stateMatch = PaceStateRegex.Match(html); + if (!stateMatch.Success) + { + return (null, null); + } + + var statePayload = stateMatch.Groups["state"].Value; + if (string.IsNullOrWhiteSpace(statePayload)) + { + return (null, null); + } + + var normalized = Regex.Unescape(statePayload); + + var anchorMatch = HtmlAnchorRegex.Match(normalized); + var titleMatch = HtmlTitleRegex.Match(normalized); + + return ( + anchorMatch.Success ? Regex.Unescape(anchorMatch.Groups["nickname"].Value) : null, + titleMatch.Success ? Regex.Unescape(titleMatch.Groups["title"].Value) : null + ); + } +} + +public sealed record DouyinDanmakuBootstrap( + string WebRoomId, + string DanmakuRoomId, + string? Cursor, + string? InternalExt, + string? UserUniqueId, + string? Anchor, + string? Title); diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinLivePlatformAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinLivePlatformAdapter.cs new file mode 100644 index 0000000..1aae5bd --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinLivePlatformAdapter.cs @@ -0,0 +1,424 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin; + +public sealed class DouyinLivePlatformAdapter : ILivePlatformAdapter +{ + private static readonly Regex RoomIdRegex = new( + @"(?<id>\d{8,})", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlTitleRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"room\":{[\\s\\S]*?\"title\":\"(?<title>[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlAnchorRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly DouyinHttpClient _douyinHttpClient; + + public DouyinLivePlatformAdapter(DouyinHttpClient douyinHttpClient) + { + _douyinHttpClient = douyinHttpClient; + } + + public LivePlatformType PlatformType => LivePlatformType.Douyin; + + public bool CanHandle(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + return input.Contains("douyin.com", StringComparison.OrdinalIgnoreCase) || + input.Contains("iesdouyin.com", StringComparison.OrdinalIgnoreCase); + } + + public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) + { + var trimmedInput = input.Trim(); + if (RoomIdRegex.FullMatch(trimmedInput)) + { + return new ParsedLiveRoom(PlatformType, trimmedInput, trimmedInput, $"https://live.douyin.com/{trimmedInput}"); + } + + var (finalUri, body) = await _douyinHttpClient.ResolvePageAsync(trimmedInput, cancellationToken); + var roomId = ExtractRoomId(finalUri?.AbsoluteUri) ?? ExtractRoomId(body); + if (string.IsNullOrWhiteSpace(roomId)) + { + throw new InvalidOperationException("Unable to extract the Douyin live room id from the input."); + } + + var normalizedUrl = finalUri?.AbsoluteUri ?? $"https://live.douyin.com/{roomId}"; + return new ParsedLiveRoom(PlatformType, roomId, trimmedInput, normalizedUrl); + } + + private static (string? Anchor, string? Title) TryExtractAnchorAndTitleFromHtml(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object || !TryGetProperty(root, "data", out var data)) + { + return (null, null); + } + + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + data = data[0]; + } + + var json = data.ValueKind == JsonValueKind.String ? data.GetString() : data.GetRawText(); + if (string.IsNullOrWhiteSpace(json)) + { + return (null, null); + } + + var normalized = Regex.Replace(json, "\\\\{1,7}\"", "\""); + + var anchorMatch = HtmlAnchorRegex.Match(normalized); + var titleMatch = HtmlTitleRegex.Match(normalized); + + return ( + anchorMatch.Success ? anchorMatch.Groups["nickname"].Value : null, + titleMatch.Success ? titleMatch.Groups["title"].Value : null + ); + } + + public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) + { + using var document = await _douyinHttpClient.GetRoomEnterAsync(roomId, cancellationToken); + var room = GetRoomNode(document.RootElement); + + var statusCode = GetInt(room, "status") ?? GetInt(room, "live_status"); + var (anchorMatch, titleMatch) = TryExtractAnchorAndTitleFromHtml(document.RootElement); + var title = titleMatch ?? GetString(room, "title"); + var anchorName = anchorMatch ?? GetNestedString(room, "owner", "nickname") + ?? GetNestedString(room, "anchor", "nickname") + ?? GetNestedString(room, "user", "nickname"); + var coverUrl = GetCoverUrl(room); + + var isLive = statusCode == 2 || GetInt(room, "live_status") is 1 or 2; + return new LiveStatusSnapshot(isLive, title, anchorName, coverUrl, statusCode, statusCode?.ToString()); + } + + public async Task<StreamUrlResult> GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default) + { + using var document = await _douyinHttpClient.GetRoomEnterAsync(roomId, cancellationToken); + var room = GetRoomNode(document.RootElement); + var streamRoot = TryGetProperty(room, "stream_url", out var streamUrl) ? streamUrl : room; + + var options = ParseStreamDataOptions(streamRoot); + if (options.Count == 0) + { + options = ParseLegacyStreamOptions(streamRoot); + } + + if (options.Count == 0) + { + throw new InvalidOperationException("Douyin did not return any playable stream URL."); + } + + var ordered = options + .DistinctBy(static item => $"{item.QualityKey}|{item.Protocol}|{item.Url}") + .OrderByDescending(static item => item.Rank) + .ThenBy(static item => item.Protocol.Equals("flv", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ToList(); + + var selected = SelectOption(ordered, preferredQuality); + var inputHeaders = await _douyinHttpClient.GetStreamInputHeadersAsync(roomId, cancellationToken); + return new StreamUrlResult( + selected.QualityKey, + selected.Protocol, + selected.Url, + inputHeaders, + ordered); + } + + private static string? ExtractRoomId(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var liveUrlMatch = Regex.Match(text, @"live\.douyin\.com/(?<id>\d{8,})", RegexOptions.IgnoreCase); + if (liveUrlMatch.Success) + { + return liveUrlMatch.Groups["id"].Value; + } + + var roomIdFieldMatch = Regex.Match(text, @"room(?:_id|Id)\\?[""=: ]+\\?[""]?(?<id>\d{8,})", RegexOptions.IgnoreCase); + if (roomIdFieldMatch.Success) + { + return roomIdFieldMatch.Groups["id"].Value; + } + + var genericMatch = RoomIdRegex.Match(text); + return genericMatch.Success ? genericMatch.Groups["id"].Value : null; + } + + private static JsonElement GetRoomNode(JsonElement root) + { + if (!TryGetProperty(root, "data", out var data)) + { + throw new InvalidOperationException("Douyin response does not contain a data node."); + } + + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + return data[0]; + } + + if (data.ValueKind == JsonValueKind.Object) + { + if (TryGetProperty(data, "data", out var innerData)) + { + if (innerData.ValueKind == JsonValueKind.Array && innerData.GetArrayLength() > 0) + { + return innerData[0]; + } + + if (innerData.ValueKind == JsonValueKind.Object) + { + return innerData; + } + } + + if (TryGetProperty(data, "room", out var room)) + { + return room; + } + + return data; + } + + throw new InvalidOperationException("Douyin response data node format is not supported."); + } + + private static List<StreamQualityOption> ParseStreamDataOptions(JsonElement streamRoot) + { + if (!TryGetNested(streamRoot, out var streamDataElement, "live_core_sdk_data", "pull_data", "stream_data")) + { + return []; + } + + var rawJson = streamDataElement.ValueKind == JsonValueKind.String + ? streamDataElement.GetString() + : streamDataElement.GetRawText(); + + if (string.IsNullOrWhiteSpace(rawJson)) + { + return []; + } + + using var document = JsonDocument.Parse(rawJson); + if (!TryGetProperty(document.RootElement, "data", out var data) || data.ValueKind != JsonValueKind.Object) + { + return []; + } + + var result = new List<StreamQualityOption>(); + foreach (var qualityNode in data.EnumerateObject()) + { + var qualityKey = qualityNode.Name; + var rank = GetQualityRank(qualityKey); + var flvUrl = GetNestedString(qualityNode.Value, "main", "flv"); + var hlsUrl = GetNestedString(qualityNode.Value, "main", "hls"); + + if (!string.IsNullOrWhiteSpace(flvUrl)) + { + result.Add(new StreamQualityOption(qualityKey, qualityKey, flvUrl, "flv", rank)); + } + + if (!string.IsNullOrWhiteSpace(hlsUrl)) + { + result.Add(new StreamQualityOption(qualityKey, qualityKey, hlsUrl, "hls", rank)); + } + } + + return result; + } + + private static List<StreamQualityOption> ParseLegacyStreamOptions(JsonElement streamRoot) + { + var result = new List<StreamQualityOption>(); + + if (TryGetProperty(streamRoot, "flv_pull_url", out var flvMap) && flvMap.ValueKind == JsonValueKind.Object) + { + foreach (var item in flvMap.EnumerateObject()) + { + var url = item.Value.GetString(); + if (!string.IsNullOrWhiteSpace(url)) + { + result.Add(new StreamQualityOption(item.Name, item.Name, url, "flv", GetQualityRank(item.Name))); + } + } + } + + if (TryGetProperty(streamRoot, "hls_pull_url_map", out var hlsMap) && hlsMap.ValueKind == JsonValueKind.Object) + { + foreach (var item in hlsMap.EnumerateObject()) + { + var url = item.Value.GetString(); + if (!string.IsNullOrWhiteSpace(url)) + { + result.Add(new StreamQualityOption(item.Name, item.Name, url, "hls", GetQualityRank(item.Name))); + } + } + } + + return result; + } + + private static StreamQualityOption SelectOption(IReadOnlyList<StreamQualityOption> options, string? preferredQuality) + { + if (!string.IsNullOrWhiteSpace(preferredQuality)) + { + var normalized = preferredQuality.Trim(); + var matched = options + .Where(item => + item.QualityKey.Equals(normalized, StringComparison.OrdinalIgnoreCase) || + item.QualityName.Equals(normalized, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(static item => item.Rank) + .ThenBy(static item => item.Protocol.Equals("flv", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .FirstOrDefault(); + + if (matched is not null) + { + return matched; + } + } + + return options[0]; + } + + private static int GetQualityRank(string quality) + { + if (quality.Equals("origin", StringComparison.OrdinalIgnoreCase)) + { + return 100; + } + + if (quality.Contains("full", StringComparison.OrdinalIgnoreCase) || + quality.Contains("uhd", StringComparison.OrdinalIgnoreCase)) + { + return 90; + } + + if (quality.Contains("hd", StringComparison.OrdinalIgnoreCase)) + { + return 80; + } + + if (quality.Contains("sd", StringComparison.OrdinalIgnoreCase)) + { + return 70; + } + + return 60; + } + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out value)) + { + return true; + } + + value = default; + return false; + } + + private static bool TryGetNested(JsonElement element, out JsonElement value, params string[] path) + { + value = element; + foreach (var segment in path) + { + if (!TryGetProperty(value, segment, out value)) + { + return false; + } + } + + return true; + } + + private static int? GetInt(JsonElement element, string name) + { + if (!TryGetProperty(element, name, out var value)) + { + return null; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue)) + { + return intValue; + } + + if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out intValue)) + { + return intValue; + } + + return null; + } + + private static string? GetString(JsonElement element, string name) + { + if (!TryGetProperty(element, name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } + + private static string? GetNestedString(JsonElement element, params string[] path) + { + if (!TryGetNested(element, out var value, path)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } + + private static string? GetCoverUrl(JsonElement room) + { + if (!TryGetProperty(room, "cover", out var cover)) + { + return null; + } + + if (cover.ValueKind == JsonValueKind.String) + { + return cover.GetString(); + } + + if (cover.ValueKind == JsonValueKind.Object && + TryGetProperty(cover, "url_list", out var urlList) && + urlList.ValueKind == JsonValueKind.Array && + urlList.GetArrayLength() > 0 && + urlList[0].ValueKind == JsonValueKind.String) + { + return urlList[0].GetString(); + } + + return null; + } +} + +file static class RegexExtensions +{ + public static bool FullMatch(this Regex regex, string input) + { + var match = regex.Match(input); + return match.Success && match.Index == 0 && match.Length == input.Length; + } +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/DouyinXBogusSigner.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/DouyinXBogusSigner.cs new file mode 100644 index 0000000..555df53 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/DouyinXBogusSigner.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin.Signing; + +public sealed class DouyinXBogusSigner +{ + private readonly ILogger<DouyinXBogusSigner> _logger; + private readonly string _signerScriptPath; + + public DouyinXBogusSigner(ILogger<DouyinXBogusSigner> logger) + { + _logger = logger; + _signerScriptPath = Path.Combine(AppContext.BaseDirectory, "Platforms", "Douyin", "Signing", "sign-xbogus.js"); + } + + public async Task<string> SignAsync(string requestUrl, string userAgent, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(requestUrl)) + { + throw new ArgumentException("Request URL cannot be empty.", nameof(requestUrl)); + } + + if (!File.Exists(_signerScriptPath)) + { + throw new FileNotFoundException("Douyin X-Bogus signer script is missing.", _signerScriptPath); + } + + var startInfo = new ProcessStartInfo + { + FileName = "node", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add(_signerScriptPath); + startInfo.ArgumentList.Add(requestUrl); + startInfo.ArgumentList.Add(userAgent ?? string.Empty); + + using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.Start(); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + var standardOutput = (await standardOutputTask).Trim(); + var standardError = (await standardErrorTask).Trim(); + + if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(standardOutput)) + { + _logger.LogWarning( + "Douyin X-Bogus signing failed. ExitCode={ExitCode}; Error={Error}", + process.ExitCode, + standardError); + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(standardError) + ? $"Douyin X-Bogus signer exited with code {process.ExitCode}." + : $"Douyin X-Bogus signer failed: {standardError}"); + } + + return standardOutput; + } +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/abogus.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/abogus.js new file mode 100644 index 0000000..802bfd8 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/abogus.js @@ -0,0 +1,551 @@ +/** + * 生成 a_bougs 参数值 + * @param {string} query 请求的 query 参数 + * @param {string} ua 浏览器 User-Agent + * @returns + */ +module.exports = function (query, ua) { + function enc_sum(n_str) { + function ir(t) { + return ( + (ir = + 'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator + ? function (t) { + return typeof t; + } + : function (t) { + return t && 'function' == typeof Symbol && t.constructor === Symbol && t !== Symbol.prototype + ? 'symbol' + : typeof t; + }), + ir(t) + ); + } + function ur(t, r) { + for (let e = 0; e < r.length; e++) { + const n = r[e]; + ((n.enumerable = n.enumerable || !1), + (n.configurable = !0), + 'value' in n && (n.writable = !0), + Object.defineProperty(t, sr(n.key), n)); + } + } + function sr(t) { + const r = (function (t, r) { + if ('object' != ir(t) || !t) return t; + const e = t[Symbol.toPrimitive]; + if (void 0 !== e) { + const n = e.call(t, r || 'default'); + if ('object' != ir(n)) return n; + throw new TypeError('@@toPrimitive must return a primitive value.'); + } + return ('string' === r ? String : Number)(t); + })(t, 'string'); + return 'symbol' == ir(r) ? r : r + ''; + } + const gr = (function () { + function t() { + if ( + ((function (t, r) { + if (!(t instanceof r)) throw new TypeError('Cannot call a class as a function'); + })(this, t), + !(this instanceof t)) + ) + return new t(); + ((this.reg = new Array(8)), (this.chunk = []), (this.size = 0), this.reset()); + } + return ( + (function (t, r, e) { + (r && ur(t.prototype, r), + e && ur(t, e), + Object.defineProperty(t, 'prototype', { + writable: !1 + })); + })(t, [ + { + key: 'reset', + value: function () { + ((this.reg[0] = 1937774191), + (this.reg[1] = 1226093241), + (this.reg[2] = 388252375), + (this.reg[3] = 3666478592), + (this.reg[4] = 2842636476), + (this.reg[5] = 372324522), + (this.reg[6] = 3817729613), + (this.reg[7] = 2969243214), + (this.chunk = []), + (this.size = 0)); + } + }, + { + key: 'write', + value: function (t) { + const r = + 'string' == typeof t + ? (function (t) { + const r = encodeURIComponent(t).replace(/%([0-9A-F]{2})/g, function (t, r) { + return String.fromCharCode('0x' + r); + }), + e = new Array(r.length); + return ( + Array.prototype.forEach.call(r, function (t, r) { + e[r] = t.charCodeAt(0); + }), + e + ); + })(t) + : t; + this.size += r.length; + let e = 64 - this.chunk.length; + if (r.length < e) this.chunk = this.chunk.concat(r); + else + for (this.chunk = this.chunk.concat(r.slice(0, e)); this.chunk.length >= 64; ) + (this._compress(this.chunk), + e < r.length ? (this.chunk = r.slice(e, Math.min(e + 64, r.length))) : (this.chunk = []), + (e += 64)); + } + }, + { + key: 'sum', + value: function (t, r) { + (t && (this.reset(), this.write(t)), this._fill()); + let e = 0; + for (; e < this.chunk.length; e += 64) this._compress(this.chunk.slice(e, e + 64)); + let n, + o, + i, + u = null; + if ('hex' == r) { + u = ''; + for (e = 0; e < 8; e++) + u += + ((n = this.reg[e].toString(16)), + (o = 8), + (i = '0'), + n.length >= o ? n : i.repeat(o - n.length) + n); + } else + for (u = new Array(32), e = 0; e < 8; e++) { + let s = this.reg[e]; + ((u[4 * e + 3] = (255 & s) >>> 0), + (s >>>= 8), + (u[4 * e + 2] = (255 & s) >>> 0), + (s >>>= 8), + (u[4 * e + 1] = (255 & s) >>> 0), + (s >>>= 8), + (u[4 * e] = (255 & s) >>> 0)); + } + return (this.reset(), u); + } + }, + { + key: '_compress', + value: function (t) { + if (t < 64) console.error('compress error: not enough data'); + else { + let r = (function (t) { + const r = new Array(132); + for (let e = 0; e < 16; e++) { + r[e] = t[4 * e] << 24; + r[e] |= t[4 * e + 1] << 16; + r[e] |= t[4 * e + 2] << 8; + r[e] |= t[4 * e + 3]; + r[e] >>>= 0; + } + + for (let n = 16; n < 68; n++) { + let o = r[n - 16] ^ r[n - 9] ^ dr(r[n - 3], 15); + o = o ^ dr(o, 15) ^ dr(o, 23); + r[n] = (o ^ dr(r[n - 13], 7) ^ r[n - 6]) >>> 0; + } + for (let n = 0; n < 64; n++) r[n + 68] = (r[n] ^ r[n + 4]) >>> 0; + return r; + })(t); + let e = this.reg.slice(0); + for (let n = 0; n < 64; n++) { + let o = dr(e[0], 12) + e[4] + dr(yr(n), n), + i = ((o = dr((o = (4294967295 & o) >>> 0), 7)) ^ dr(e[0], 12)) >>> 0, + u = br(n, e[0], e[1], e[2]); + u = (4294967295 & (u = u + e[3] + i + r[n + 68])) >>> 0; + let s = mr(n, e[4], e[5], e[6]); + s = (4294967295 & (s = s + e[7] + o + r[n])) >>> 0; + e[3] = e[2]; + e[2] = dr(e[1], 9); + e[1] = e[0]; + e[0] = u; + e[7] = e[6]; + e[6] = dr(e[5], 19); + e[5] = e[4]; + e[4] = (s ^ dr(s, 9) ^ dr(s, 17)) >>> 0; + } + for (let c = 0; c < 8; c++) this.reg[c] = (this.reg[c] ^ e[c]) >>> 0; + } + } + }, + { + key: '_fill', + value: function () { + let t = 8 * this.size, + r = this.chunk.push(128) % 64; + for (64 - r < 8 && (r -= 64); r < 56; r++) this.chunk.push(0); + for (let e = 0; e < 4; e++) { + let n = Math.floor(t / 4294967296); + this.chunk.push((n >>> (8 * (3 - e))) & 255); + } + for (let e = 0; e < 4; e++) this.chunk.push((t >>> (8 * (3 - e))) & 255); + } + } + ]), + t + ); + })(); + function dr(t, r) { + return ((t << (r %= 32)) | (t >>> (32 - r))) >>> 0; + } + function yr(t) { + return 0 <= t && t < 16 + ? 2043430169 + : 16 <= t && t < 64 + ? 2055708042 + : void console.error('invalid j for constant Tj'); + } + function br(t, r, e, n) { + return 0 <= t && t < 16 + ? (r ^ e ^ n) >>> 0 + : 16 <= t && t < 64 + ? ((r & e) | (r & n) | (e & n)) >>> 0 + : (console.error('invalid j for bool function FF'), 0); + } + function mr(t, r, e, n) { + return 0 <= t && t < 16 + ? (r ^ e ^ n) >>> 0 + : 16 <= t && t < 64 + ? ((r & e) | (~r & n)) >>> 0 + : (console.error('invalid j for bool function GG'), 0); + } + const enc_ = new gr(); + return enc_.sum(n_str); + } + function generate_lm_g_EP(uat = ua) { + function get_sz256f() { + let r = [], + k = 0, + y = [0, 1, 0]; + for (let i = 255; i >= 0; i--) { + r.push(i); + } + for (let i = 0; i < r.length; i++) { + let a = r[i]; + k = (k * a + k + y[i % 3]) % 256; + let b = r[k]; + ((r[i] = b), (r[k] = a)); + } + return r; + } + const sz256f = [ + 233, 5, 1, 249, 162, 140, 57, 143, 19, 203, 254, 236, 99, 248, 93, 213, 79, 149, 216, 50, 145, 123, 240, 92, 23, + 113, 130, 53, 235, 220, 201, 136, 223, 155, 190, 242, 243, 42, 52, 214, 151, 232, 97, 187, 163, 222, 30, 78, 47, + 71, 49, 170, 247, 196, 25, 156, 183, 182, 217, 180, 147, 124, 208, 69, 215, 200, 161, 154, 91, 60, 133, 224, 119, + 164, 221, 45, 98, 40, 186, 120, 51, 167, 38, 90, 194, 212, 129, 56, 87, 195, 144, 44, 75, 84, 81, 13, 197, 245, + 36, 250, 115, 100, 105, 252, 206, 103, 112, 202, 114, 138, 192, 21, 116, 173, 181, 29, 82, 125, 141, 16, 211, 131, + 225, 118, 31, 101, 77, 146, 135, 150, 62, 66, 67, 176, 0, 41, 46, 59, 107, 178, 43, 26, 189, 128, 8, 207, 166, + 110, 3, 229, 85, 54, 63, 11, 32, 4, 234, 142, 72, 58, 33, 231, 12, 230, 102, 86, 70, 159, 226, 65, 237, 34, 244, + 76, 132, 122, 111, 95, 179, 152, 175, 18, 177, 6, 126, 193, 219, 74, 134, 2, 61, 251, 191, 168, 209, 241, 137, + 165, 88, 238, 160, 174, 153, 157, 199, 48, 22, 64, 246, 7, 139, 55, 27, 188, 148, 204, 127, 171, 89, 37, 172, 205, + 121, 20, 28, 17, 169, 15, 227, 117, 80, 218, 198, 10, 106, 9, 39, 210, 104, 83, 109, 24, 108, 228, 184, 96, 185, + 158, 14, 255, 239, 68, 94, 35, 73, 253 + ]; + let k = 0, + s = ''; + for (let i = 0; i < uat.length; i++) { + let t = (i + 1) % 256; + let a = sz256f[t]; + k = (k + a) % 256; + let c = sz256f[k]; + sz256f[t] = c; + sz256f[k] = a; + s += String.fromCharCode(uat.charCodeAt(i) ^ sz256f[(a + c) % 256]); + } + return s; + } + function get_str_chr_list(one_str) { + const r = []; + for (let i = 0; i < one_str.length; i++) { + r.push(one_str.charCodeAt(i)); + } + return r; + } + function generate_szenc_head8p1() { + let z = Math.random() * 65535; + let a = z & 255; + let b = (z >> 8) & 255, + d = []; + d.push((a & 170) | 1); + d.push((a & 85) | 0); + d.push((b & 170) | 0); + d.push((b & 85) | 0); + return d; + } + function generate_szenc_head8p2() { + let a = ((Math.random() * 240) >> 0) + 1; + let b = ((Math.random() * 255) >> 0) & 77, + c = [1, 4, 5, 7], + d = []; + for (let i = 0; i < c.length; i++) { + b = b | (1 << c[i]); + } + d.push((a & 170) | 1); + d.push((a & 85) | 0); + d.push((b & 170) | 0); + d.push((b & 85) | 0); + return d; + } + function get_szenc_tail(sz96) { + const zKeys = [145, 110, 66, 189, 44, 211]; + const a = []; + for (let i = 0; i < 94; i += 3) { + let b = sz96[i]; + let c = sz96[i + 1]; + let d = sz96[i + 2]; + let e = (Math.random() * 1000) & 255; + a.push((e & zKeys[0]) | (b & zKeys[1])); + a.push((e & zKeys[2]) | (c & zKeys[3])); + a.push((e & zKeys[4]) | (d & zKeys[5])); + a.push((b & zKeys[0]) | (c & zKeys[2]) | (d & zKeys[4])); + } + return a; + } + function generate_lm_g_ab_head4() { + let s = ''; + const a = (Math.random() * 65535) & 255, + b = (Math.random() * 40) >> 0; + s += String.fromCharCode((a & 170) | 1); + s += String.fromCharCode((a & 85) | 2); + s += String.fromCharCode((b & 170) | 80); + s += String.fromCharCode((b & 85) | 2); + return s; + } + function get_list_str(one_list) { + let s = ''; + for (let i = 0; i < one_list.length; i++) { + s += String.fromCharCode(one_list[i]); + } + return s; + } + function get_lm_g_ab(lm_g_lm_n) { + function getSZ256() { + const raw = []; + let z = 0; + for (let i = 255; i >= 0; i--) { + raw.push(i); + } + for (let i = 0; i < raw.length; i++) { + z += 211; + let a = z % 256; + let b = raw[i]; + let c = raw[a]; + raw[a] = b; + raw[i] = c; + z = raw[i + 1] * a + a; + } + return raw; + } + const fixedSZ256 = [ + 194, 249, 255, 165, 114, 67, 251, 187, 174, 231, 164, 237, 124, 235, 68, 83, 206, 79, 142, 167, 30, 77, 0, 93, + 118, 29, 32, 161, 2, 171, 243, 179, 42, 170, 223, 119, 98, 222, 219, 57, 245, 135, 197, 13, 186, 202, 88, 184, + 214, 12, 76, 185, 116, 74, 54, 53, 104, 208, 158, 163, 82, 173, 253, 240, 172, 63, 191, 207, 25, 15, 201, 203, + 215, 236, 183, 233, 145, 127, 72, 6, 16, 10, 228, 35, 232, 159, 66, 168, 108, 71, 217, 75, 33, 155, 112, 128, 36, + 24, 138, 50, 211, 23, 107, 14, 247, 137, 175, 242, 234, 157, 199, 49, 139, 85, 81, 17, 180, 86, 120, 78, 51, 205, + 169, 148, 181, 3, 94, 106, 252, 220, 150, 47, 151, 84, 212, 18, 149, 182, 100, 123, 121, 156, 154, 152, 126, 204, + 60, 133, 132, 248, 7, 91, 58, 59, 20, 97, 113, 117, 131, 46, 250, 224, 21, 73, 146, 31, 193, 69, 140, 125, 9, 39, + 89, 5, 65, 141, 218, 80, 1, 70, 64, 166, 87, 189, 55, 147, 22, 26, 143, 61, 144, 99, 92, 44, 129, 130, 227, 103, + 90, 192, 198, 244, 136, 101, 246, 153, 56, 38, 4, 178, 221, 162, 134, 37, 111, 28, 216, 96, 102, 210, 254, 196, + 195, 230, 241, 62, 11, 122, 52, 40, 41, 229, 226, 225, 48, 45, 160, 105, 8, 115, 34, 43, 209, 95, 239, 190, 188, + 109, 27, 19, 176, 213, 200, 238, 177, 110 + ]; + // const fixedSZ256 = getSZ256(); + let z = 0; + let st = ''; + for (let i = 0; i < lm_g_lm_n.length; i++) { + let a = (i + 1) % 256; + let c = fixedSZ256[a]; + z = (z + c) % 256; + let e = fixedSZ256[z]; + fixedSZ256[a] = e; + fixedSZ256[z] = c; + let g = (e + c) % 256; + let h = lm_g_lm_n.charCodeAt(i); + let j = fixedSZ256[g]; + let k = h ^ j; + let l = String.fromCharCode(k); + st += l; + } + return st; + } + function get_raw_ab(lm_get_ab_n, key_str = info_dic.s4) { + let s = '', + bw = 0; + for (let i = 0; i < lm_get_ab_n.length; i += 3) { + let cl = 16; + let tcz = 0; + let sof = 16515072; + for (let j = i; j < i + 3; j++) { + if (j < lm_get_ab_n.length) { + let tlcy = lm_get_ab_n.charCodeAt(j) & 255; + tcz = tcz | (tlcy << cl); + cl -= 8; + } else { + bw += 1; + } + } + for (let h = 18; h >= 6 * bw; h -= 6) { + let tsz = tcz & sof; + s += key_str[tsz >> h]; + sof = sof / 64; + } + s += '='.repeat(bw); + } + return s; + } + function get_random_number(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + const info_dic = { + s0: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=', + s1: 'Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=', + s2: 'Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=', + s3: 'ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe', + s4: 'Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe' + }; + const t1 = Date.now(); + const s = []; + + const t2 = Date.now() - 1 + get_random_number(1, 3); + + const EP = get_raw_ab(generate_lm_g_EP(ua), info_dic.s3); + const eEP = enc_sum(EP); + + s.push('env_fx_list', 'dpf_ua_dic', 1, 0, 8, 'dpf', '', 'ua', 6241, 6383, '1.0.1.19-fix.01', 'ink', 3, '0X21_dic'); // 固定即可 + + const t3 = Date.now() + get_random_number(4, 15); + const eedp = enc_sum(enc_sum(query + 'dhzx')); + + s.push(t3, 'reg_dic', 1, 0, eedp, 'eedh', EP, eEP, t2, [3, 82], 41, [1, 0, 1, 0, 1]); + + const t4 = Date.now() + get_random_number(100, 1000); + + const s1 = ((t4 - 1721836800000) / 1000 / 60 / 60 / 24 / 14) >> 0, + szenc_o95_tail41 = [ + 49, 52, 52, 49, 124, 56, 51, 56, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, + 49, 52, 52, 49, 124, 57, 54, 49, 124, 87, 105, 110, 51, 50 + ]; + + s.push( + s1, + 6, + (t3 - t1 + 3) & 255, + t3 & 255, + (t3 >> 8) & 255, + (t3 >> 16) & 255, + (t3 >> 24) & 255, + (t3 / 256 / 256 / 256 / 256) & 255 + ); + + const s2 = (t3 / 256 / 256 / 256 / 256 / 256) & 255; + + s.push( + s2, + (s2 % 256) & 255, + (s2 / 256) & 255, + [211, 2, 5, 1, 129], + 129, + 0, + 211, + 2, + 5, + 1, + 0, + 0, + 0, + 0, + eedp[9], + eedp[18], + 3, + eedp[3], + 82, + 177, + 4, + 44, + eEP[11], + eEP[21], + 5, + eEP[5], + t2 & 255, + (t2 >> 8) & 255, + (t2 >> 16) & 255, + (t2 >> 24) & 255, + (t2 / 256 / 256 / 256 / 256) & 255, + (t2 / 256 / 256 / 256 / 256 / 256) & 255, + 3, + 97, + 24, + 0, + 0, + 239, + 24, + 0, + 0, + 'screec_dic', + 'screen_str', + szenc_o95_tail41, + 41, + 41, + 0 + ); + + const s3 = ((t3 + 3) & 255) + ',', + s4 = get_str_chr_list(s3); + + s.push(s3, s4, s4.length, s4.length & 255, (s4.length >> 8) & 255); + + const szenc_head8_p1 = generate_szenc_head8p1(), + szenc_head8_p2 = generate_szenc_head8p2(), + szenc_head8 = szenc_head8_p1.concat(szenc_head8_p2), + s5 = [], + s6 = [ + 24, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 55, + 56, 57, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 79, 80, 84, 85 + ]; + for (let i = 0; i < s6.length; i++) { + s5.push(s[s6[i]]); + } + s.push(szenc_head8); + const s7 = szenc_head8.concat(s5); + + let s8 = s7[0]; + for (let i = 1; i < s7.length; i++) { + s8 = s8 ^ s7[i]; + } + s.push(s8); + + const enc_s_i = [ + 34, 44, 56, 61, 73, 29, 70, 45, 35, 49, 38, 66, 51, 68, 28, 48, 64, 47, 30, 71, 26, 55, 31, 69, 59, 40, 62, 63, + 27, 72, 41, 74, 57, 52, 42, 39, 33, 67, 53, 43, 65, 46, 36, 24, 60, 32, 79, 80, 84, 85 + ], + szenc_o95_head50 = []; + for (let i = 0; i < enc_s_i.length; i++) { + szenc_o95_head50.push(s[enc_s_i[i]]); + } + let szenc_o95 = []; + szenc_o95 = szenc_o95.concat(szenc_o95_head50, szenc_o95_tail41, s4, [s8]); + + const szenc_tail = get_szenc_tail(szenc_o95), + szenc = szenc_head8.concat(szenc_tail), + lm_get_ab_head4 = generate_lm_g_ab_head4(); + + const lm_get_lm = get_list_str(szenc); + const lm_get_ab_tail = get_lm_g_ab(lm_get_lm); + const lm_get_ab = lm_get_ab_head4 + lm_get_ab_tail; + const ab = get_raw_ab(lm_get_ab); + + return ab; +}; diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/index.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/index.js new file mode 100644 index 0000000..84a49c8 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/index.js @@ -0,0 +1,7 @@ +// index.js +const sign = require('./abogus'); + +module.exports = function (query, userAgent) { + return sign(query, userAgent); +}; + diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/sign-xbogus.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/sign-xbogus.js new file mode 100644 index 0000000..b0c2c7e --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/sign-xbogus.js @@ -0,0 +1,8 @@ +const path = require("path"); + +const sign = require(path.join(__dirname, "index.js")); + +const query = process.argv[2] || ""; +const userAgent = process.argv[3] || ""; + +process.stdout.write(sign(query, userAgent)); diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/xbogus.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/xbogus.js new file mode 100644 index 0000000..044cab5 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/xbogus.js @@ -0,0 +1,563 @@ +var window = null; + +function _0x5cd844(e) { + var b = { + exports: {} + }; + return e(b, b.exports), b.exports +} +jsvmp = function(e, b, a) { + function f(e, b, a) { + return (f = function() { + if ("undefined" == typeof Reflect || !Reflect.construct || Reflect.construct.sham) return !1; + if ("function" == typeof Proxy) return !0; + try { + return Date.prototype.toString.call(Reflect.construct(Date, [], function() {})), !0 + } catch (e) { + return !1 + } + }() ? Reflect.construct : function(e, b, a) { + var f = [null]; + f.push.apply(f, b); + var c = new(Function.bind.apply(e, f)); + return a && function(e, b) { + (Object.setPrototypeOf || function(e, b) { + return e.__proto__ = b, e + })(e, b) + }(c, a.prototype), c + }).apply(null, arguments) + } + + function c(e) { + return function(e) { + if (Array.isArray(e)) { + for (var b = 0, a = new Array(e.length); b < e.length; b++) a[b] = e[b]; + return a + } + }(e) || function(e) { + if (Symbol.iterator in Object(e) || "[object Arguments]" === Object.prototype.toString.call(e)) return Array.from(e) + }(e) || function() { + throw new TypeError("Invalid attempt to spread non-iterable instance") + }() + } + for (var r = [], t = 0, d = [], i = 0, n = function(e, b) { + var a = e[b++], + f = e[b], + c = parseInt("" + a + f, 16); + if (c >> 7 == 0) return [1, c]; + if (c >> 6 == 2) { + var r = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [2, r = (c <<= 8) + r] + } + if (c >> 6 == 3) { + var t = parseInt("" + e[++b] + e[++b], 16), + d = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [3, d = (c <<= 16) + (t <<= 8) + d] + } + }, s = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1], 16); + return a > 127 ? -256 + a : a + }, o = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16); + return a > 32767 ? -65536 + a : a + }, l = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3] + e[b + 4] + e[b + 5] + e[b + 6] + e[b + 7], 16); + return a > 2147483647 ? 0 + a : a + }, _ = function(e, b) { + return parseInt("" + e[b] + e[b + 1], 16) + }, x = function(e, b) { + return parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16) + }, u = u || this || window, h = (e.length, 0), p = "", y = h; y < h + 16; y++) { + var v = "" + e[y++] + e[y]; + v = parseInt(v, 16), p += String.fromCharCode(v) + } + if ("HNOJ@?RC" != p) throw new Error("error magic number " + p); + parseInt("" + e[h += 16] + e[h + 1], 16), h += 8, t = 0; + for (var g = 0; g < 4; g++) { + var w = h + 2 * g, + A = parseInt("" + e[w++] + e[w], 16); + t += (3 & A) << 2 * g + } + h += 16; + var C = parseInt("" + e[h += 8] + e[h + 1] + e[h + 2] + e[h + 3] + e[h + 4] + e[h + 5] + e[h + 6] + e[h + 7], 16), + m = C, + S = h += 8, + z = x(e, h += C); + z[1], h += 4, r = { + p: [], + q: [] + }; + for (var B = 0; B < z; B++) { + for (var R = n(e, h), q = h += 2 * R[0], I = r.p.length, k = 0; k < R[1]; k++) { + var j = n(e, q); + r.p.push(j[1]), q += 2 * j[0] + } + h = q, r.q.push([I, r.p.length]) + } + var O = { + 5: 1, + 6: 1, + 70: 1, + 22: 1, + 23: 1, + 37: 1, + 73: 1 + }, + U = { + 72: 1 + }, + D = { + 74: 1 + }, + N = { + 11: 1, + 12: 1, + 24: 1, + 26: 1, + 27: 1, + 31: 1 + }, + J = { + 10: 1 + }, + L = { + 2: 1, + 29: 1, + 30: 1, + 20: 1 + }, + T = [], + E = []; + + function M(e, b, a) { + for (var f = b; f < b + a;) { + var c = _(e, f); + T[f] = c, f += 2, U[c] ? (E[f] = s(e, f), f += 2) : O[c] ? (E[f] = o(e, f), f += 4) : D[c] ? (E[f] = l(e, f), f += 8) : N[c] ? (E[f] = _(e, f), f += 2) : J[c] ? (E[f] = x(e, f), f += 4) : L[c] && (E[f] = x(e, f), f += 4) + } + } + return F(e, S, m / 2, [], b, a); + + function P(e, b, a, n, h, p, y, v) { + null == p && (p = this); + var g, w, A, C, m = [], + S = 0; + y && (w = y); + var z, B, R = b, + q = R + 2 * a; + if (!v) + for (; R < q;) { + var I = parseInt("" + e[R] + e[R + 1], 16); + R += 2; + var j = 3 & (z = 13 * I % 241); + if (z >>= 2, j < 1) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = o(e, R), (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + else if (j < 2) + if (j = 3 & z, z >>= 2, j < 1) + if ((j = z) < 5) { + B = o(e, R); + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } else j < 7 ? (B = _(e, R), R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1)))) : j < 9 && (w = m[S--], m[S] = m[S] & w); + else if (j < 2) + if ((j = z) > 12) m[++S] = s(e, R), R += 2; + else if (j > 10) w = m[S--], m[S] = m[S] << w; + else if (j > 8) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j > 6 && (A = m[S--], w = delete m[S--][A]); + else if (j < 3)(j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w); + else if ((j = z) > 12) m[++S] = p; + else if (j > 5) w = m[S--], m[S] = m[S] !== w; + else if (j > 3) w = m[S--], m[S] = m[S] / w; + else if (j > 1) { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } else j > -1 && (m[S] = !m[S]); + else if (j < 3) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = o(e, R), R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = _(e, R), R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = x(e, R), R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) > 12 ? (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A) : j > 3 ? (w = m[S--], m[S] = m[S] == w) : j > 1 ? (w = m[S--], m[S] = m[S] + w) : j > -1 && (m[++S] = u); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) + if (m[S--]) R += 4; + else { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } + else if (j > 0) { + for (B = x(e, R), w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = _(e, R), R += 2, m[++S] = h["$" + B]) : j > 3 && (B = o(e, R), d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j = 3 & z, z >>= 2, j > 2)(j = z) > 13 ? (m[++S] = l(e, R), R += 8) : j > 11 ? (w = m[S--], m[S] = m[S] >>> w) : j > 9 ? m[++S] = !0 : j > 7 ? (B = _(e, R), R += 2, m[S] = m[S][B]) : j > 0 && (w = m[S--], m[S] = m[S] < w); + else if (j > 1)(j = z) > 10 ? (B = o(e, R), d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2) : j > 8 ? (w = m[S--], m[S] = m[S] ^ w) : j > 6 && (w = m[S--]); + else if (j > 0) { + if ((j = z) > 7) w = m[S--], m[S] = m[S] in w; + else if (j > 5) m[S] = ++m[S]; + else if (j > 3) B = _(e, R), R += 2, w = h[B], m[++S] = w; + else if (j > 1) { + var O = 0, + U = m[S].length, + D = m[S]; + m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + } + } + } else if ((j = z) > 13) w = m[S], m[S] = m[S - 1], m[S - 1] = w; + else if (j > 4) w = m[S--], m[S] = m[S] === w; + else if (j > 2) w = m[S--], m[S] = m[S] - w; + else if (j > 0) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } + } + if (v) + for (; R < q;) + if (I = T[R], R += 2, j = 3 & (z = 13 * I % 241), z >>= 2, j > 2) + if (j = 3 & z, z >>= 2, j > 2)(j = z) < 2 ? (w = m[S--], m[S] = m[S] < w) : j < 9 ? (B = E[R], R += 2, m[S] = m[S][B]) : j < 11 ? m[++S] = !0 : j < 13 ? (w = m[S--], m[S] = m[S] >>> w) : j < 15 && (m[++S] = E[R], R += 8); + else if (j > 1)(j = z) < 6 || (j < 8 ? w = m[S--] : j < 10 ? (w = m[S--], m[S] = m[S] ^ w) : j < 12 && (B = E[R], d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2)); + else if (j > 0)(j = z) > 7 ? (w = m[S--], m[S] = m[S] in w) : j > 5 ? m[S] = ++m[S] : j > 3 ? (B = E[R], R += 2, w = h[B], m[++S] = w) : j > 1 && (O = 0, U = m[S].length, D = m[S], m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + }); + else if ((j = z) < 2) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } else j < 4 ? (w = m[S--], m[S] = m[S] - w) : j < 6 ? (w = m[S--], m[S] = m[S] === w) : j < 15 && (w = m[S], m[S] = m[S - 1], m[S - 1] = w); + else if (j > 1) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = E[R], R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = E[R], R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = E[R], R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) < 1 ? m[++S] = u : j < 3 ? (w = m[S--], m[S] = m[S] + w) : j < 5 ? (w = m[S--], m[S] = m[S] == w) : j < 14 && (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) m[S--] ? R += 4 : R += 2 * (B = E[R]) - 2; + else if (j > 0) { + for (B = E[R], w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = E[R], R += 2, m[++S] = h["$" + B]) : j > 3 && (B = E[R], d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j > 0) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) > 9); + else if (j > 7) w = m[S--], m[S] = m[S] & w; + else if (j > 5) B = E[R], R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1))); + else if (j > 3) { + B = E[R]; + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } + } else if (j < 2) + if ((j = z) < 8) A = m[S--], w = delete m[S--][A]; + else if (j < 10) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j < 12 ? (w = m[S--], m[S] = m[S] << w) : j < 14 && (m[++S] = E[R], R += 2); + else j < 3 ? (j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w) : (j = z) > 12 ? m[++S] = p : j > 5 ? (w = m[S--], m[S] = m[S] !== w) : j > 3 ? (w = m[S--], m[S] = m[S] / w) : j > 1 ? R += 2 * (B = E[R]) - 2 : j > -1 && (m[S] = !m[S]); + else if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = E[R], (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + return [0, null] + } + + function F(e, b, a, f, c, r, t, d) { + null == r && (r = this), c && !c.d && (c.d = 0, c.$0 = c, c[1] = {}); + var i, n, s = {}, + o = s.d = c ? c.d + 1 : 0; + for (s["$" + o] = s, n = 0; n < o; n++) s[i = "$" + n] = c[i]; + for (n = 0, o = s.length = f.length; n < o; n++) s[n] = f[n]; + return d && !T[b] && M(e, b, 2 * a), T[b] ? P(e, b, a, 0, s, r, null, 1)[1] : P(e, b, a, 0, s, r, null, 0)[1] + } +}; +var _0x397dc7 = "undefined" != typeof globalThis ? globalThis : void 0 !== window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : {}, + _0x124d1a = _0x5cd844(function(_0x770f81) { + ! function() { + var _0x250d36 = "input is invalid type", + _0x4cfaee = !1, + _0x1702f9 = {}, + _0x5ccbb3 = !_0x4cfaee && "object" == typeof self, + _0x54d876 = !_0x1702f9.JS_MD5_NO_NODE_JS && "object" == typeof process && process.versions && process.versions.node, + _0x185caf; + _0x54d876 ? _0x1702f9 = _0x397dc7 : _0x5ccbb3 && (_0x1702f9 = self); + var _0x17dcbf = !_0x1702f9.JS_MD5_NO_COMMON_JS && _0x770f81.exports, + _0x554fed = !1, + _0x2de28f = !_0x1702f9.JS_MD5_NO_ARRAY_BUFFER && "undefined" != typeof ArrayBuffer, + _0x3a9a1b = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], + _0x465562 = [128, 32768, 8388608, -2147483648], + _0x20b37e = [0, 8, 16, 24], + _0x323604 = ["hex", "array", "digest", "buffer", "arrayBuffer", "base64"], + _0x2c185e = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+", "/"], + _0x4b59e0 = []; + if (_0x2de28f) { + var _0x395837 = new ArrayBuffer(68); + _0x185caf = new Uint8Array(_0x395837), _0x4b59e0 = new Uint32Array(_0x395837) + }!_0x1702f9.JS_MD5_NO_NODE_JS && Array.isArray || (Array.isArray = function(e) { + return "[object Array]" === Object.prototype.toString.call(e) + }), _0x2de28f && (_0x1702f9.JS_MD5_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView) && (ArrayBuffer.isView = function(e) { + return "object" == typeof e && e.buffer && e.buffer.constructor === ArrayBuffer + }); + var _0x4e9930 = function(e) { + return function(b) { + return new _0x5887c8(!0).update(b)[e]() + } + }, + _0x38ba77 = function() { + var e = _0x4e9930("hex"); + _0x54d876 && (e = _0x474989(e)), e.create = function() { + return new _0x5887c8 + }, e.update = function(b) { + return e.create().update(b) + }; + for (var b = 0; b < _0x323604.length; ++b) { + var a = _0x323604[b]; + e[a] = _0x4e9930(a) + } + return e + }, + _0x474989 = function(_0x57eeaa) { + var _0x114910, _0x226465 = eval("require('crypto');"), + _0x1f6ae0 = eval("require('buffer')['Buffer'];"); + return function(e) { + if ("string" == typeof e) return _0x226465.createHash("md5").update(e, "utf8").digest("hex"); + if (null == e) throw _0x250d36; + return e.constructor === ArrayBuffer && (e = new Uint8Array(e)), Array.isArray(e) || ArrayBuffer.isView(e) || e.constructor === _0x1f6ae0 ? _0x226465.createHash("md5").update(new _0x1f6ae0.from(e)).digest("hex") : _0x57eeaa(e) + } + }; + + function _0x5887c8(e) { + if (e) _0x4b59e0[0] = _0x4b59e0[16] = _0x4b59e0[1] = _0x4b59e0[2] = _0x4b59e0[3] = _0x4b59e0[4] = _0x4b59e0[5] = _0x4b59e0[6] = _0x4b59e0[7] = _0x4b59e0[8] = _0x4b59e0[9] = _0x4b59e0[10] = _0x4b59e0[11] = _0x4b59e0[12] = _0x4b59e0[13] = _0x4b59e0[14] = _0x4b59e0[15] = 0, this.blocks = _0x4b59e0, this.buffer8 = _0x185caf; + else if (_0x2de28f) { + var b = new ArrayBuffer(68); + this.buffer8 = new Uint8Array(b), this.blocks = new Uint32Array(b) + } else this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + this.h0 = this.h1 = this.h2 = this.h3 = this.start = this.bytes = this.hBytes = 0, this.finalized = this.hashed = !1, this.first = !0 + } + _0x5887c8.prototype.update = function(e) { + if (!this.finalized) { + var b, a = typeof e; + if ("string" !== a) { + if ("object" !== a || null === e) throw _0x250d36; + if (_0x2de28f && e.constructor === ArrayBuffer) e = new Uint8Array(e); + else if (!(Array.isArray(e) || _0x2de28f && ArrayBuffer.isView(e))) throw _0x250d36; + b = !0 + } + for (var f, c, r = 0, t = e.length, d = this.blocks, i = this.buffer8; r < t;) { + if (this.hashed && (this.hashed = !1, d[0] = d[16], d[16] = d[1] = d[2] = d[3] = d[4] = d[5] = d[6] = d[7] = d[8] = d[9] = d[10] = d[11] = d[12] = d[13] = d[14] = d[15] = 0), b) + if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r) i[c++] = e[r]; + else + for (c = this.start; r < t && c < 64; ++r) d[c >> 2] |= e[r] << _0x20b37e[3 & c++]; + else if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? i[c++] = f : f < 2048 ? (i[c++] = 192 | f >> 6, i[c++] = 128 | 63 & f) : f < 55296 || f >= 57344 ? (i[c++] = 224 | f >> 12, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), i[c++] = 240 | f >> 18, i[c++] = 128 | f >> 12 & 63, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f); + else + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? d[c >> 2] |= f << _0x20b37e[3 & c++] : f < 2048 ? (d[c >> 2] |= (192 | f >> 6) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : f < 55296 || f >= 57344 ? (d[c >> 2] |= (224 | f >> 12) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), d[c >> 2] |= (240 | f >> 18) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 12 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]); + this.lastByteIndex = c, this.bytes += c - this.start, c >= 64 ? (this.start = c - 64, this.hash(), this.hashed = !0) : this.start = c + } + return this.bytes > 4294967295 && (this.hBytes += this.bytes / 4294967296 << 0, this.bytes = this.bytes % 4294967296), this + } + }, _0x5887c8.prototype.finalize = function() { + if (!this.finalized) { + this.finalized = !0; + var e = this.blocks, + b = this.lastByteIndex; + e[b >> 2] |= _0x465562[3 & b], b >= 56 && (this.hashed || this.hash(), e[0] = e[16], e[16] = e[1] = e[2] = e[3] = e[4] = e[5] = e[6] = e[7] = e[8] = e[9] = e[10] = e[11] = e[12] = e[13] = e[14] = e[15] = 0), e[14] = this.bytes << 3, e[15] = this.hBytes << 3 | this.bytes >>> 29, this.hash() + } + }, _0x5887c8.prototype.hash = function() { + var e, b, a, f, c, r, t = this.blocks; + this.first ? b = ((b = ((e = ((e = t[0] - 680876937) << 7 | e >>> 25) - 271733879 << 0) ^ (a = ((a = (-271733879 ^ (f = ((f = (-1732584194 ^ 2004318071 & e) + t[1] - 117830708) << 12 | f >>> 20) + e << 0) & (-271733879 ^ e)) + t[2] - 1126478375) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1316259209) << 22 | b >>> 10) + a << 0 : (e = this.h0, b = this.h1, a = this.h2, b = ((b += ((e = ((e += ((f = this.h3) ^ b & (a ^ f)) + t[0] - 680876936) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[1] - 389564586) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[2] + 606105819) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1044525330) << 22 | b >>> 10) + a << 0), b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[4] - 176418897) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[5] + 1200080426) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[6] - 1473231341) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[7] - 45705983) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[8] + 1770035416) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[9] - 1958414417) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[10] - 42063) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[11] - 1990404162) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[12] + 1804603682) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[13] - 40341101) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[14] - 1502002290) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[15] + 1236535329) << 22 | b >>> 10) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[1] - 165796510) << 5 | e >>> 27) + b << 0) ^ b)) + t[6] - 1069501632) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[11] + 643717713) << 14 | a >>> 18) + f << 0) ^ f)) + t[0] - 373897302) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[5] - 701558691) << 5 | e >>> 27) + b << 0) ^ b)) + t[10] + 38016083) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[15] - 660478335) << 14 | a >>> 18) + f << 0) ^ f)) + t[4] - 405537848) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[9] + 568446438) << 5 | e >>> 27) + b << 0) ^ b)) + t[14] - 1019803690) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[3] - 187363961) << 14 | a >>> 18) + f << 0) ^ f)) + t[8] + 1163531501) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[13] - 1444681467) << 5 | e >>> 27) + b << 0) ^ b)) + t[2] - 51403784) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[7] + 1735328473) << 14 | a >>> 18) + f << 0) ^ f)) + t[12] - 1926607734) << 20 | b >>> 12) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[5] - 378558) << 4 | e >>> 28) + b << 0)) + t[8] - 2022574463) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[11] + 1839030562) << 16 | a >>> 16) + f << 0)) + t[14] - 35309556) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[1] - 1530992060) << 4 | e >>> 28) + b << 0)) + t[4] + 1272893353) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[7] - 155497632) << 16 | a >>> 16) + f << 0)) + t[10] - 1094730640) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[13] + 681279174) << 4 | e >>> 28) + b << 0)) + t[0] - 358537222) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[3] - 722521979) << 16 | a >>> 16) + f << 0)) + t[6] + 76029189) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[9] - 640364487) << 4 | e >>> 28) + b << 0)) + t[12] - 421815835) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[15] + 530742520) << 16 | a >>> 16) + f << 0)) + t[2] - 995338651) << 23 | b >>> 9) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[0] - 198630844) << 6 | e >>> 26) + b << 0) | ~a)) + t[7] + 1126891415) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[14] - 1416354905) << 15 | a >>> 17) + f << 0) | ~e)) + t[5] - 57434055) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[12] + 1700485571) << 6 | e >>> 26) + b << 0) | ~a)) + t[3] - 1894986606) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[10] - 1051523) << 15 | a >>> 17) + f << 0) | ~e)) + t[1] - 2054922799) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[8] + 1873313359) << 6 | e >>> 26) + b << 0) | ~a)) + t[15] - 30611744) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[6] - 1560198380) << 15 | a >>> 17) + f << 0) | ~e)) + t[13] + 1309151649) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[4] - 145523070) << 6 | e >>> 26) + b << 0) | ~a)) + t[11] - 1120210379) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[2] + 718787259) << 15 | a >>> 17) + f << 0) | ~e)) + t[9] - 343485551) << 21 | b >>> 11) + a << 0, this.first ? (this.h0 = e + 1732584193 << 0, this.h1 = b - 271733879 << 0, this.h2 = a - 1732584194 << 0, this.h3 = f + 271733878 << 0, this.first = !1) : (this.h0 = this.h0 + e << 0, this.h1 = this.h1 + b << 0, this.h2 = this.h2 + a << 0, this.h3 = this.h3 + f << 0) + }, _0x5887c8.prototype.hex = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return _0x3a9a1b[e >> 4 & 15] + _0x3a9a1b[15 & e] + _0x3a9a1b[e >> 12 & 15] + _0x3a9a1b[e >> 8 & 15] + _0x3a9a1b[e >> 20 & 15] + _0x3a9a1b[e >> 16 & 15] + _0x3a9a1b[e >> 28 & 15] + _0x3a9a1b[e >> 24 & 15] + _0x3a9a1b[b >> 4 & 15] + _0x3a9a1b[15 & b] + _0x3a9a1b[b >> 12 & 15] + _0x3a9a1b[b >> 8 & 15] + _0x3a9a1b[b >> 20 & 15] + _0x3a9a1b[b >> 16 & 15] + _0x3a9a1b[b >> 28 & 15] + _0x3a9a1b[b >> 24 & 15] + _0x3a9a1b[a >> 4 & 15] + _0x3a9a1b[15 & a] + _0x3a9a1b[a >> 12 & 15] + _0x3a9a1b[a >> 8 & 15] + _0x3a9a1b[a >> 20 & 15] + _0x3a9a1b[a >> 16 & 15] + _0x3a9a1b[a >> 28 & 15] + _0x3a9a1b[a >> 24 & 15] + _0x3a9a1b[f >> 4 & 15] + _0x3a9a1b[15 & f] + _0x3a9a1b[f >> 12 & 15] + _0x3a9a1b[f >> 8 & 15] + _0x3a9a1b[f >> 20 & 15] + _0x3a9a1b[f >> 16 & 15] + _0x3a9a1b[f >> 28 & 15] + _0x3a9a1b[f >> 24 & 15] + }, _0x5887c8.prototype.toString = _0x5887c8.prototype.hex, _0x5887c8.prototype.digest = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return [255 & e, e >> 8 & 255, e >> 16 & 255, e >> 24 & 255, 255 & b, b >> 8 & 255, b >> 16 & 255, b >> 24 & 255, 255 & a, a >> 8 & 255, a >> 16 & 255, a >> 24 & 255, 255 & f, f >> 8 & 255, f >> 16 & 255, f >> 24 & 255] + }, _0x5887c8.prototype.array = _0x5887c8.prototype.digest, _0x5887c8.prototype.arrayBuffer = function() { + this.finalize(); + var e = new ArrayBuffer(16), + b = new Uint32Array(e); + return b[0] = this.h0, b[1] = this.h1, b[2] = this.h2, b[3] = this.h3, e + }, _0x5887c8.prototype.buffer = _0x5887c8.prototype.arrayBuffer, _0x5887c8.prototype.base64 = function() { + for (var e, b, a, f = "", c = this.array(), r = 0; r < 15;) e = c[r++], b = c[r++], a = c[r++], f += _0x2c185e[e >>> 2] + _0x2c185e[63 & (e << 4 | b >>> 4)] + _0x2c185e[63 & (b << 2 | a >>> 6)] + _0x2c185e[63 & a]; + return f + (_0x2c185e[(e = c[r]) >>> 2] + _0x2c185e[e << 4 & 63] + "==") + }; + var _0x4dd781 = _0x38ba77(); + _0x17dcbf ? _0x770f81.exports = _0x4dd781 : (_0x1702f9.md5 = _0x4dd781, _0x554fed && (void 0)(function() { + return _0x4dd781 + })) + }() + }); + +function _0x178cef(e) { + return jsvmp("484e4f4a403f52430038001eab0015840e8ee21a00000000000000621b000200001d000146000306000e271f001b000200021d00010500121b001b000b021b000b04041d0001071b000b0500000003000126207575757575757575757575757575757575757575757575757575757575757575", [, , void 0 !== _0x124d1a ? _0x124d1a : void 0, _0x178cef, e]) +} +for (var _0xb55f3e = { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, _0x3eaf64 = { + debug: function(e, b) { + let a = !1; + a = !1 + } + }, _0x233455 = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], _0x2e9f6d = [], _0x511f86 = [], _0x3d35de = 0; _0x3d35de < 256; _0x3d35de++) _0x2e9f6d[_0x3d35de] = _0x233455[_0x3d35de >> 4 & 15] + _0x233455[15 & _0x3d35de], _0x3d35de < 16 && (_0x3d35de < 10 ? _0x511f86[48 + _0x3d35de] = _0x3d35de : _0x511f86[87 + _0x3d35de] = _0x3d35de); +var _0x2ce54d = function(e) { + for (var b = e.length, a = "", f = 0; f < b;) a += _0x2e9f6d[e[f++]]; + return a + }, + _0x5960a2 = function(e) { + for (var b = e.length >> 1, a = b << 1, f = new Uint8Array(b), c = 0, r = 0; r < a;) f[c++] = _0x511f86[e.charCodeAt(r++)] << 4 | _0x511f86[e.charCodeAt(r++)]; + return f + }, + _0x4e46b6 = { + encode: _0x2ce54d, + decode: _0x5960a2 + }; + +function sign(e, b) { + return jsvmp("484e4f4a403f5243001f240fbf2031ccf317480300000000000007181b0002012e1d00921b000b171b000b02402217000a1c1b000b1726402217000c1c1b000b170200004017002646000306000e271f001b000200021d00920500121b001b000b031b000b17041d0092071b000b041e012f17000d1b000b05260a0000101c1b000b06260a0000101c1b001b000b071e01301d00931b001b000b081e00081d00941b0048021d00951b001b000b1b1d00961b0048401d009e1b001b000b031b000b16041d009f1b001b000b09221e0131241b000b031b000b09221e0131241b000b1e0a000110040a0001101d00d51b001b000b09221e0131241b000b031b000b09221e0131241b000b180a000110040a0001101d00d71b001b000b0a1e00101d00d91b001b000b0b261b000b1a1b000b190a0002101d00db1b001b000b0c261b000b221b000b210a0002101d00dc1b001b000b0d261b000b230200200a0002101d00dd1b001b000b09221e0131241b000b031b000b24040a0001101d00df1b001b000b0e1a00221e00de240a0000104903e82b1d00e31b001b000b0f260a0000101d00e41b001b000b1d1d00e71b001b000b1a4901002b1d00e81b001b000b1a4901002c1d00ea1b001b000b191d00f21b001b000b1f480e191d00f81b001b000b1f480f191d00f91b001b000b20480e191d00fb1b001b000b20480f191d00fe1b001b000b25480e191d01001b001b000b25480f191d01011b001b000b264818344900ff2f1d01031b001b000b264810344900ff2f1d01321b001b000b264808344900ff2f1d01331b001b000b264800344900ff2f1d01341b001b000b274818344900ff2f1d01351b001b000b274810344900ff2f1d01361b001b000b274808344900ff2f1d01371b001b000b274800344900ff2f1d01381b001b000b281b000b29311b000b2a311b000b2b311b000b2c311b000b2d311b000b2e311b000b2f311b000b30311b000b31311b000b32311b000b33311b000b34311b000b35311b000b36311b000b37311b000b38311b000b39311d01391b004900ff1d013a1b001b000b10261b000b281b000b2a1b000b2c1b000b2e1b000b301b000b321b000b341b000b361b000b381b000b3a1b000b291b000b2b1b000b2d1b000b2f1b000b311b000b331b000b351b000b371b000b390a0013101d013b1b001b000b0c261b000b111b000b3b041b000b3c0a0002101d013c1b001b000b12261b000b1c1b000b3b1b000b3d0a0003101d013d1b001b000b13261b000b3e0200240a0002101d013e1b000b3f0000013f000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b726152670f487c717976706733447a7d777c644e08577c70667e767d6712487c7179767067335d72657a7472677c614e057960777c7e10487c7179767067335b7a60677c616a4e07637f66747a7d60084c637b727d677c7e0b70727f7f437b727d677c7e0b4c4c7d7a747b677e726176055266777a7c1850727d65726041767d7776617a7d74507c7d67766b6721570964767177617a657661137476675c647d43617c637661676a5d727e7660097f727d74667274766006707b617c7e760761667d677a7e7607707c7d7d767067144c4c64767177617a6576614c7665727f66726776134c4c60767f767d7a667e4c7665727f667267761b4c4c64767177617a6576614c6070617a63674c75667d70677a7c7d174c4c64767177617a6576614c6070617a63674c75667d70154c4c64767177617a6576614c6070617a63674c757d134c4c756b77617a6576614c7665727f66726776124c4c77617a6576614c667d64617263637677154c4c64767177617a6576614c667d64617263637677114c4c77617a6576614c7665727f66726776144c4c60767f767d7a667e4c667d64617263637677144c4c756b77617a6576614c667d64617263637677094c60767f767d7a667e0c70727f7f40767f767d7a667e164c40767f767d7a667e4c5a57564c4176707c6177766108777c70667e767d670478766a60057e7267707b06417674566b630a4f3748723e694e77704c067072707b764c04607c7e7608707675407b72616308507675407b72616305767c72637a16767c44767151617c64607661577a60637267707b76610f717a7d775c717976706752606a7d700e7a60565c44767151617c646076610120047c63767d0467766067097a7d707c747d7a677c077c7d7661617c6104707c77761242465c47524c564b5056565756574c5641410e607660607a7c7d40677c61727476076076675a67767e10607c7e7658766a5b766176516a6776770a61767e7c65765a67767e097a7d77766b767757510c437c7a7d6776615665767d670e5e40437c7a7d6776615665767d670d706176726776567f767e767d670670727d65726009677c5772677246415f076176637f727076034f603901740a7d72677a6576707c777614487c717976706733437f66747a7d526161726a4e4a4d7b676763602c294f3c4f3c3b48233e2a4e68223f206e3b4f3d48233e2a4e68223f206e3a68206e6f48723e75233e2a4e68223f276e3b2948723e75233e2a4e68223f276e3a68246e3a0127087f7c7072677a7c7d047b61767504757a7f76107b676763293c3c7f7c70727f7b7c606708637f7267757c617e02222102222007647a7d777c646002222703647a7d02222607727d77617c7a77022225057f7a7d666b022224067a637b7c7d7602222b047a63727702222a047a637c77022123037e7270022122097e72707a7d677c607b0c7e72704c637c64766163703a0470617c60036b22220570617a7c6005756b7a7c6004637a787602212102212002212702212602212502212402212b08757a6176757c6b3c067c637661723c05337c63613c05337c63673c07707b617c7e763c0867617a77767d673c047e607a7602212a0220230665767d777c6106547c7c747f760e4c637261727e40647a67707b5c7d0a777a61767067407a747d0a707c7d607a6067767d670660647a67707b03777c7e07637b727d677c7e047b7c7c7840525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e3d03727a77017d01750161096067726167477a7e7601670972717a7f7a677a76600a677a7e766067727e6322137b72617764726176507c7d70666161767d706a0c7776657a70765e767e7c616a087f727d74667274760a6176607c7f66677a7c7d0f7265727a7f4176607c7f66677a7c7d0960706176767d477c630a60706176767d5f767567107776657a7076437a6b767f4172677a7c0a63617c77667067406671077172676776616a016309677c66707b5a7d757c08677a7e76697c7d760a677a7e766067727e6321077463665a7d757c0b7960557c7d67605f7a60670b637f66747a7d605f7a60670a677a7e766067727e63200a76657661507c7c787a760767674c60707a77017e0b606a7d67726b5661617c610c7d72677a65765f767d74677b056167705a43097563457661607a7c7d0b4c4c657661607a7c7d4c4c08707f7a767d675a770a677a7e766067727e63270b766b67767d77557a767f77046366607b03727f7f04677b767d097172607625274c707b0c75617c7e507b7261507c7776067125274c2023022022087172607625274c23022021087172607625274c22022020087172607625274c2102202702202602202507747667477a7e760220240b777c7e5d7c6745727f7a77096066716067617a7d740863617c677c707c7f02202b02202a01230e222323232323232322222323232302272302272207757c616176727f02272104717c776a096067617a7d747a756a02686e0b717c776a45727f216067610a717c776a4c7b72607b2e01350366617f02272005626676616a0a72607c7f774c607a747d096372677b7d727e762e0967674c6476717a772e063566667a772e0227270227260e4c716a6776774c6076704c777a770227250a27212a272a2524212a25097576457661607a7c7d0227240e4c232151274925647c232323232202272b02272a05607f7a7076022623074056505a5d555c037d7c6409677a7e766067727e6305757f7c7c610661727d777c7e0f7476674747447671507c7c787a7660056767647a770867674c6476717a770767674476715a770b67674c6476717a774c65210967674476717a7745210761667d7d7a7d7405757f66607b087e7c65765f7a60670660637f7a70760671765e7c657609707f7a70785f7a6067077176507f7a70780c78766a717c7261775f7a60670a717658766a717c7261770b7270677a657640677267760b647a7d777c6440677267760360477e05676172707808667d7a67477a7e76037270700a667d7a67527e7c667d670871767b72657a7c61077e6074476a637603645a5707727a775f7a60670b63617a6572706a5e7c777606706660677c7e067260607a747d0f4456514c5756455a50564c5a5d555c0479607c7d0a6176747a7c7d507c7d75096176637c616746617f04766b7a67094b3e5e403e404746510c4b3e5e403e43524a5f5c525720232323232323232323232323232323232323232323232323232323232323232320772722772b70772a2b75232371212327762a2b23232a2a2b7670752b272124760165066671707c7776067776707c777602262202262102262002262702262602262502262402262b02262a022523022522022521022520", [, , void 0, void 0 !== _0x178cef ? _0x178cef : void 0, { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [/\/web\/report/], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, () => 0, () => "03v", { + ubcode: 0 + }, { + bogusIndex: 0, + msNewTokenList: [], + moveList: [], + clickList: [], + keyboardList: [], + activeState: [], + aidList: [], + envcode: 0, + msToken: "", + msStatus: 0, + __ac_testid: "", + ttwid: "", + tt_webid: "", + tt_webid_v2: "" + }, void 0 !== _0x4e46b6 ? _0x4e46b6 : void 0, { + userAgent: b + }, (e, b) => { + let a = new Uint8Array(3); + return a[0] = e / 256, a[1] = e % 256, a[2] = b % 256, String.fromCharCode.apply(null, a) + }, (e, b) => { + let a, f = [], + c = 0, + r = ""; + for (let e = 0; e < 256; e++) f[e] = e; + for (let b = 0; b < 256; b++) c = (c + f[b] + e.charCodeAt(b % e.length)) % 256, a = f[b], f[b] = f[c], f[c] = a; + let t = 0; + c = 0; + for (let e = 0; e < b.length; e++) c = (c + f[t = (t + 1) % 256]) % 256, a = f[t], f[t] = f[c], f[c] = a, r += String.fromCharCode(b.charCodeAt(e) ^ f[(f[t] + f[c]) % 256]); + return r + }, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), "undefined" != typeof Date ? Date : void 0, () => 0, (e, b, a, f, c, r, t, d, i, n, s, o, l, _, x, u, h, p, y) => { + let v = new Uint8Array(19); + return v[0] = e, v[1] = s, v[2] = b, v[3] = o, v[4] = a, v[5] = l, v[6] = f, v[7] = _, v[8] = c, v[9] = x, v[10] = r, v[11] = u, v[12] = t, v[13] = h, v[14] = d, v[15] = p, v[16] = i, v[17] = y, v[18] = n, String.fromCharCode.apply(null, v) + }, e => String.fromCharCode(e), (e, b, a) => String.fromCharCode(e) + String.fromCharCode(b) + a, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), , sign, e, void 0]) +} + +//export text function +module.exports = sign; diff --git a/src/LiveRecorder.Infrastructure/Platforms/Huya/HuyaLivePlatformAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Huya/HuyaLivePlatformAdapter.cs new file mode 100644 index 0000000..19e9b61 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Huya/HuyaLivePlatformAdapter.cs @@ -0,0 +1,25 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Platforms.Huya; + +public sealed class HuyaLivePlatformAdapter : ILivePlatformAdapter +{ + public LivePlatformType PlatformType => LivePlatformType.Huya; + + public bool CanHandle(string input) => + !string.IsNullOrWhiteSpace(input) && + input.Contains("huya.com", StringComparison.OrdinalIgnoreCase); + + public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Huya 适配器尚未实现。"); + + public Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Huya 适配器尚未实现。"); + + public Task<StreamUrlResult> GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("Huya 适配器尚未实现。"); +} diff --git a/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs b/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs new file mode 100644 index 0000000..88f2419 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs @@ -0,0 +1,265 @@ +using System.Net; +using System.Net.Mail; +using System.Text; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Domain.Entities; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed class EmailNotificationService : IEmailNotificationService +{ + private const string AppName = "LiveRecorder"; + private static readonly Regex TemplateTokenRegex = new("""\{\{\s*(?<name>[a-zA-Z0-9_]+)\s*\}\}""", RegexOptions.Compiled); + + private readonly ISystemSettingsService _systemSettingsService; + private readonly ILogger<EmailNotificationService> _logger; + + public EmailNotificationService( + ISystemSettingsService systemSettingsService, + ILogger<EmailNotificationService> logger) + { + _systemSettingsService = systemSettingsService; + _logger = logger; + } + + public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted) + { + return; + } + + var tokens = CreateTokenMap(new Dictionary<string, string?> + { + ["platform"] = liveRoom.Platform.ToString(), + ["roomId"] = liveRoom.RoomId, + ["title"] = liveRoom.Title, + ["anchor"] = liveRoom.AnchorName, + ["sourceUrl"] = liveRoom.SourceUrl, + ["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + + var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens); + var body = RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, tokens); + + await SendAsync(settings, subject, body, cancellationToken); + } + + public async Task SendExceptionAsync( + string source, + string summary, + string? detail = null, + LiveRoom? liveRoom = null, + RecordTask? recordTask = null, + CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + if (!settings.EnableEmailNotification || !settings.NotifyOnException) + { + return; + } + + var tokens = CreateTokenMap(new Dictionary<string, string?> + { + ["source"] = source, + ["summary"] = summary, + ["detail"] = detail, + ["liveRoomId"] = liveRoom?.Id.ToString(), + ["roomId"] = liveRoom?.RoomId, + ["recordTaskId"] = recordTask?.Id.ToString(), + ["taskStatus"] = recordTask?.Status.ToString(), + ["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + + var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens); + var body = RenderHtml(settings.EmailExceptionBodyTemplateHtml, tokens); + + await SendAsync(settings, subject, body, cancellationToken); + } + + public async Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var settings = new SystemSettingsDto + { + EnableEmailNotification = true, + NotifyOnLiveStarted = true, + NotifyOnException = true, + EmailSmtpHost = request.EmailSmtpHost.Trim(), + EmailSmtpPort = request.EmailSmtpPort, + EmailUseSsl = request.EmailUseSsl, + EmailUsername = request.EmailUsername.Trim(), + EmailPassword = request.EmailPassword, + EmailFromAddress = request.EmailFromAddress.Trim(), + EmailFromDisplayName = request.EmailFromDisplayName.Trim(), + EmailToAddresses = request.EmailToAddresses.Trim(), + EmailLiveStartedSubjectTemplate = request.EmailLiveStartedSubjectTemplate, + EmailLiveStartedBodyTemplateHtml = request.EmailLiveStartedBodyTemplateHtml, + EmailExceptionSubjectTemplate = request.EmailExceptionSubjectTemplate, + EmailExceptionBodyTemplateHtml = request.EmailExceptionBodyTemplateHtml + }; + + var sampleLiveTokens = CreateTokenMap(new Dictionary<string, string?> + { + ["platform"] = "Douyin", + ["roomId"] = "123456789", + ["title"] = "Sample Live Title", + ["anchor"] = "Sample Anchor", + ["sourceUrl"] = "https://live.douyin.com/123456789", + ["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?> + { + ["source"] = "Scheduler", + ["summary"] = "Background polling failed for a live room.", + ["detail"] = "Sample stack trace or diagnostic detail goes here.", + ["liveRoomId"] = Guid.NewGuid().ToString(), + ["roomId"] = "123456789", + ["recordTaskId"] = Guid.NewGuid().ToString(), + ["taskStatus"] = "Running", + ["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + + var body = $$""" +<!DOCTYPE html> +<html lang="en"> + <body style="margin: 0; padding: 24px; background: #f5f7fb; font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937;"> + <div style="max-width: 880px; margin: 0 auto;"> + <h1 style="margin: 0 0 20px; color: #2f4c66;">LiveRecorder SMTP template test</h1> + <div style="margin-bottom: 18px; padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;"> + <h2 style="margin: 0 0 14px; color: #3e5f7c;">开播提醒示例</h2> + <div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailLiveStartedSubjectTemplate, sampleLiveTokens))}}</div> + {{RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, sampleLiveTokens)}} + </div> + <div style="padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;"> + <h2 style="margin: 0 0 14px; color: #8b5e3c;">异常提醒示例</h2> + <div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailExceptionSubjectTemplate, sampleExceptionTokens))}}</div> + {{RenderHtml(settings.EmailExceptionBodyTemplateHtml, sampleExceptionTokens)}} + </div> + </div> + </body> +</html> +"""; + + await SendAsync(settings, "[LiveRecorder] SMTP template test", body, cancellationToken, swallowErrors: false); + } + + private async Task SendAsync( + SystemSettingsDto settings, + string subject, + string body, + CancellationToken cancellationToken, + bool swallowErrors = true) + { + var recipients = ParseRecipients(settings.EmailToAddresses); + if (string.IsNullOrWhiteSpace(settings.EmailSmtpHost) || + string.IsNullOrWhiteSpace(settings.EmailFromAddress) || + recipients.Count == 0) + { + if (swallowErrors) + { + return; + } + + throw new InvalidOperationException("SMTP host, sender address or recipient list is missing."); + } + + try + { + using var message = new MailMessage + { + From = new MailAddress(settings.EmailFromAddress, settings.EmailFromDisplayName), + Subject = subject, + Body = body, + BodyEncoding = Encoding.UTF8, + SubjectEncoding = Encoding.UTF8, + IsBodyHtml = true + }; + + foreach (var recipient in recipients) + { + message.To.Add(recipient); + } + + using var client = new SmtpClient(settings.EmailSmtpHost, settings.EmailSmtpPort) + { + EnableSsl = settings.EmailUseSsl, + DeliveryMethod = SmtpDeliveryMethod.Network + }; + + if (!string.IsNullOrWhiteSpace(settings.EmailUsername)) + { + client.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword); + } + + using var registration = cancellationToken.Register(client.SendAsyncCancel); + await client.SendMailAsync(message); + } + catch (Exception ex) + { + if (!swallowErrors) + { + throw new InvalidOperationException($"SMTP test email send failed: {ex.Message}", ex); + } + + _logger.LogWarning(ex, "Email notification send failed"); + } + } + + private static List<string> ParseRecipients(string rawAddresses) => + rawAddresses + .Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static Dictionary<string, string> CreateTokenMap(IReadOnlyDictionary<string, string?> rawTokens) + { + var tokens = rawTokens.ToDictionary( + static pair => pair.Key, + static pair => pair.Value?.Trim() ?? string.Empty, + StringComparer.OrdinalIgnoreCase); + tokens["appName"] = AppName; + return tokens; + } + + private static string RenderSubject(string template, IReadOnlyDictionary<string, string> tokens) + { + var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false); + rendered = rendered.Replace("\r", " ").Replace("\n", " ").Trim(); + return string.IsNullOrWhiteSpace(rendered) ? $"[{AppName}] Notification" : rendered; + } + + private static string RenderHtml(string template, IReadOnlyDictionary<string, string> tokens) + { + var rendered = RenderTemplate(template, tokens, htmlEncodeValues: true).Trim(); + return string.IsNullOrWhiteSpace(rendered) ? "<div></div>" : rendered; + } + + private static string RenderTemplate(string template, IReadOnlyDictionary<string, string> tokens, bool htmlEncodeValues) + { + if (string.IsNullOrWhiteSpace(template)) + { + return string.Empty; + } + + return TemplateTokenRegex.Replace( + template, + match => + { + var tokenName = match.Groups["name"].Value; + if (!tokens.TryGetValue(tokenName, out var value)) + { + return string.Empty; + } + + return htmlEncodeValues ? WebUtility.HtmlEncode(value) : value; + }); + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs new file mode 100644 index 0000000..c1f6e54 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs @@ -0,0 +1,767 @@ +using System.Diagnostics; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Services; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed partial class FfmpegService +{ + private async Task HandleProcessOutputAsync(SessionProcessRuntime runtime, string? line, bool isError) + { + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + _logger.LogDebug("ffmpeg[{SessionId}] {Line}", runtime.RecordSessionId, line); + if (TryParseSegmentOpenPath(line, out var openedPath)) + { + await HandleSegmentOpenedAsync(runtime, openedPath); + return; + } + + if (!runtime.HasOpenedFirstSegment && IsOptionCompatibilityFailureLine(line)) + { + runtime.MarkStartupFailure(StartupFailureKind.InputOptionCompatibility, line); + } + else if (!runtime.HasOpenedFirstSegment && IsRetryableStartupFailureLine(line)) + { + runtime.MarkStartupFailure(StartupFailureKind.StreamHandshake, line); + } + + if (line.Contains("error", StringComparison.OrdinalIgnoreCase) || + line.Contains("fail", StringComparison.OrdinalIgnoreCase) || + line.Contains("timed out", StringComparison.OrdinalIgnoreCase)) + { + await PersistFfmpegLineAsync(runtime, line, isError ? SystemLogLevel.Error : SystemLogLevel.Warning); + } + } + + private async Task PersistFfmpegLineAsync(SessionProcessRuntime runtime, string line, SystemLogLevel level) + { + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + level, + "FFmpeg", + "ffmpeg reported a warning or error line.", + line, + runtime.LiveRoomId, + runtime.RecordSessionId, + runtime.CurrentTaskId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Persist ffmpeg output failed for session {RecordSessionId}", runtime.RecordSessionId); + } + } + + private async Task HandleSegmentOpenedAsync(SessionProcessRuntime runtime, string openedPath) + { + await runtime.Gate.WaitAsync(); + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var session = await dbContext.RecordSessions + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId); + + if (session is null) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId); + if (currentTask is null) + { + return; + } + + if (runtime.SaveMode == RecordSaveMode.SingleFile) + { + runtime.HasOpenedFirstSegment = true; + currentTask.AttachProcess(runtime.ProcessId, now); + currentTask.MarkRunning(now); + session.AttachProcess(runtime.ProcessId, now); + session.MarkRunning(now); + session.ActivateSegment(1, now); + if (!runtime.HasInitializedDanmaku) + { + await EnsureDanmakuSegmentAsync(runtime, currentTask, now); + runtime.HasInitializedDanmaku = true; + } + + await dbContext.SaveChangesAsync(); + return; + } + + var segmentIndex = string.Equals(openedPath, runtime.CurrentOutputFilePath, StringComparison.OrdinalIgnoreCase) + ? Math.Max(1, runtime.CurrentSegmentIndex) + : Math.Max(1, runtime.CurrentSegmentIndex + 1); + if (!runtime.HasInitializedDanmaku) + { + await EnsureDanmakuSegmentAsync(runtime, currentTask, now); + runtime.HasInitializedDanmaku = true; + } + + if (segmentIndex == runtime.CurrentSegmentIndex) + { + runtime.HasOpenedFirstSegment = true; + currentTask.MarkStarting(runtime.StreamUrl, openedPath, currentTask.StartedAt ?? now); + currentTask.AttachProcess(runtime.ProcessId, now); + currentTask.MarkRunning(now); + session.AttachProcess(runtime.ProcessId, now); + session.MarkRunning(now); + session.ActivateSegment(segmentIndex, now); + runtime.CurrentOutputFilePath = openedPath; + await dbContext.SaveChangesAsync(); + return; + } + + var previousTask = currentTask; + var previousTaskId = previousTask.Id; + var previousOutputPath = runtime.CurrentOutputFilePath; + + var newTask = new RecordTask( + session.LiveRoomId, + session.Id, + segmentIndex, + session.PreferredQuality, + session.OutputFormat, + now); + newTask.MarkStarting(runtime.StreamUrl, openedPath, now); + newTask.AttachProcess(runtime.ProcessId, now); + newTask.MarkRunning(now); + await dbContext.RecordTasks.AddAsync(newTask); + + previousTask.MarkCompleted( + now, + previousTask.StartedAt.HasValue ? Math.Max(0, (now - previousTask.StartedAt.Value).TotalSeconds) : null); + previousTask.DetachProcess(now); + + session.AttachProcess(runtime.ProcessId, now); + session.MarkRunning(now); + session.ActivateSegment(segmentIndex, now); + + await dbContext.SaveChangesAsync(); + + if (runtime.DanmakuRecorder is not null) + { + await runtime.DanmakuRecorder.StartSegmentAsync(newTask.Id, segmentIndex, openedPath, now); + } + + var previousDanmakuSummary = runtime.DanmakuRecorder?.TakeSummary(previousTaskId); + UpsertRecordResult( + previousTask, + dbContext, + previousOutputPath, + CalculateFileSize(previousOutputPath), + previousTask.DurationSeconds, + previousDanmakuSummary?.FilePath, + previousDanmakuSummary?.MessageCount ?? 0, + now); + + await dbContext.SaveChangesAsync(); + + runtime.CurrentTaskId = newTask.Id; + runtime.CurrentSegmentIndex = segmentIndex; + runtime.CurrentOutputFilePath = openedPath; + runtime.HasOpenedFirstSegment = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Handle segment open failed for session {RecordSessionId}", runtime.RecordSessionId); + } + finally + { + runtime.Gate.Release(); + } + } + + private async Task HandleProcessExitedAsync(SessionProcessRuntime runtime, Process process) + { + _processes.TryRemove(runtime.RecordSessionId, out _); + + try + { + runtime.DanmakuCancellation.Cancel(); + if (runtime.DanmakuPumpTask is not null) + { + try + { + await runtime.DanmakuPumpTask; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Danmaku pump ended with error for session {RecordSessionId}", runtime.RecordSessionId); + } + } + + SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary = null; + if (runtime.DanmakuRecorder is not null) + { + activeDanmakuSummary = await runtime.DanmakuRecorder.CompleteActiveSegmentAsync(); + await runtime.DanmakuRecorder.DisposeAsync(); + } + + if (runtime.DanmakuConnection is not null) + { + await runtime.DanmakuConnection.DisposeAsync(); + } + + if (await TryRecoverStartupFailureAsync(runtime)) + { + return; + } + + await FinalizeExitedSessionAsync(runtime, process, activeDanmakuSummary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Handle ffmpeg exit failed for session {RecordSessionId}", runtime.RecordSessionId); + } + finally + { + runtime.ExitCompletion.TrySetResult(true); + runtime.Dispose(); + process.Dispose(); + } + } + + private async Task<bool> TryRecoverStartupFailureAsync(SessionProcessRuntime runtime) + { + if (runtime.HasOpenedFirstSegment || + runtime.StartupFailureKind == StartupFailureKind.None || + string.IsNullOrWhiteSpace(runtime.LastStartupFailureLine)) + { + return false; + } + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + + var session = await dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId); + + if (session?.LiveRoom is null || !IsActiveSessionStatus(session.Status)) + { + return false; + } + + var currentTask = session.RecordTasks + .OrderBy(item => item.SegmentIndex) + .ThenBy(item => item.CreatedAt) + .FirstOrDefault(item => item.Id == runtime.CurrentTaskId); + + if (currentTask is null || !IsActiveTaskStatus(currentTask.Status)) + { + return false; + } + + var observedAt = DateTimeOffset.UtcNow; + if (runtime.StartupFailureKind == StartupFailureKind.InputOptionCompatibility) + { + if (runtime.HasRetriedWithCompatibilityProfile) + { + return false; + } + + await logService.WriteAsync( + SystemLogLevel.Warning, + "FFmpeg", + "Initial ffmpeg input profile was rejected. Retrying once with minimal compatibility options.", + runtime.LastStartupFailureLine, + session.LiveRoomId, + session.Id, + currentTask.Id); + + var retryStream = new StreamUrlResult( + runtime.SelectedQuality, + runtime.SelectedProtocol, + runtime.StreamUrl, + runtime.InputHeaders, + Array.Empty<StreamQualityOption>()); + + session.MarkStarting(retryStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt); + session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt); + currentTask.MarkStarting(retryStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt); + await dbContext.SaveChangesAsync(); + + await StartInternalAsync( + session, + currentTask, + retryStream, + FfmpegInputOptionProfile.Minimal, + hasRetriedWithCompatibilityProfile: true, + hasRetriedWithRefreshedStream: runtime.HasRetriedWithRefreshedStream, + runtime.RetryAttemptCount + 1); + + var restartedAt = DateTimeOffset.UtcNow; + session.MarkRunning(restartedAt); + currentTask.MarkRunning(restartedAt); + await dbContext.SaveChangesAsync(); + + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + "ffmpeg startup retry succeeded with minimal compatibility options.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: currentTask.Id); + + return true; + } + + if (runtime.StartupFailureKind != StartupFailureKind.StreamHandshake || runtime.HasRetriedWithRefreshedStream) + { + return false; + } + + var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>(); + var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>(); + var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform); + var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId); + await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt); + await dbContext.SaveChangesAsync(); + + if (!liveStatus.IsLive) + { + return false; + } + + await logService.WriteAsync( + SystemLogLevel.Warning, + "FFmpeg", + "Initial ffmpeg input handshake failed. Refreshing stream URL and retrying once.", + runtime.LastStartupFailureLine, + session.LiveRoomId, + session.Id, + currentTask.Id); + + var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality); + session.MarkStarting(refreshedStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt); + session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt); + currentTask.MarkStarting(refreshedStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt); + await dbContext.SaveChangesAsync(); + + await StartInternalAsync( + session, + currentTask, + refreshedStream, + runtime.InputOptionProfile, + hasRetriedWithCompatibilityProfile: runtime.HasRetriedWithCompatibilityProfile, + hasRetriedWithRefreshedStream: true, + runtime.RetryAttemptCount + 1); + + var refreshedRetryStartedAt = DateTimeOffset.UtcNow; + session.MarkRunning(refreshedRetryStartedAt); + currentTask.MarkRunning(refreshedRetryStartedAt); + await dbContext.SaveChangesAsync(); + + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + "ffmpeg startup retry succeeded with a refreshed stream URL.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: currentTask.Id); + + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Recover ffmpeg startup failure failed for session {RecordSessionId}", runtime.RecordSessionId); + return false; + } + } + + private async Task FinalizeExitedSessionAsync( + SessionProcessRuntime runtime, + Process process, + SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary) + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>(); + var settings = await settingsService.GetAsync(); + + var session = await dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId); + + if (session is null) + { + return; + } + + var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId) + ?? session.RecordTasks.OrderByDescending(static item => item.SegmentIndex).FirstOrDefault(); + if (currentTask is null) + { + return; + } + + var endedAt = DateTimeOffset.UtcNow; + string? finalizationError = null; + var effectiveOutputPath = runtime.CurrentOutputFilePath; + + if (session.SaveMode == RecordSaveMode.SingleFile && + session.OutputFormat == RecordOutputFormat.Mp4 && + !string.IsNullOrWhiteSpace(session.OutputPathPattern)) + { + var finalOutputPath = Path.IsPathRooted(session.OutputPathPattern) + ? session.OutputPathPattern + : Path.GetFullPath(session.OutputPathPattern, AppContext.BaseDirectory); + var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, runtime.RecorderOutputPath, finalOutputPath); + effectiveOutputPath = finalizationResult.OutputPath; + finalizationError = finalizationResult.ErrorMessage; + } + + var fileSize = CalculateFileSize(effectiveOutputPath); + var durationSeconds = currentTask.StartedAt.HasValue + ? (double?)Math.Max(0, (endedAt - currentTask.StartedAt.Value).TotalSeconds) + : null; + var danmakuPath = activeDanmakuSummary?.FilePath ?? currentTask.Result?.DanmakuFilePath ?? GuessDanmakuPath(currentTask.OutputFilePath); + var danmakuMessageCount = activeDanmakuSummary?.MessageCount ?? CountDanmakuMessages(danmakuPath); + + if (!string.IsNullOrWhiteSpace(finalizationError)) + { + currentTask.MarkFailed(finalizationError, endedAt); + session.MarkFailed(finalizationError, endedAt); + } + else if (runtime.StopRequested) + { + currentTask.MarkStopped(endedAt, durationSeconds); + session.MarkStopped(endedAt); + } + else if (process.ExitCode == 0 && (runtime.CompletionRequested || !runtime.StopRequested)) + { + currentTask.MarkCompleted(endedAt, durationSeconds); + session.MarkCompleted(endedAt); + } + else + { + var errorMessage = $"ffmpeg exit code: {process.ExitCode}"; + currentTask.MarkFailed(errorMessage, endedAt); + session.MarkFailed(errorMessage, endedAt); + } + + currentTask.DetachProcess(endedAt); + session.SyncSegmentCount(session.RecordTasks.Count, endedAt); + UpsertRecordResult(currentTask, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuMessageCount, endedAt); + + await dbContext.SaveChangesAsync(); + + await logService.WriteAsync( + session.Status == RecordSessionStatus.Completed ? SystemLogLevel.Info : + session.Status == RecordSessionStatus.Stopped ? SystemLogLevel.Warning : + SystemLogLevel.Error, + "FFmpeg", + $"Recording session exited with status={session.Status}.", + $"exitCode={process.ExitCode}; output={effectiveOutputPath}; recorderOutput={runtime.RecorderOutputPath}", + session.LiveRoomId, + session.Id, + currentTask.Id); + + if (session.Status == RecordSessionStatus.Failed && session.LiveRoom is not null) + { + await emailNotificationService.SendExceptionAsync( + "FFmpeg", + "Recording session exited abnormally.", + $"exitCode={process.ExitCode}; output={effectiveOutputPath}", + session.LiveRoom, + currentTask); + } + } + + private async Task PersistProcessBindingAsync(Guid recordSessionId, Guid recordTaskId, int processId, CancellationToken cancellationToken) + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var session = await dbContext.RecordSessions.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken); + var task = await dbContext.RecordTasks.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken); + + var now = DateTimeOffset.UtcNow; + session?.AttachProcess(processId, now); + task?.AttachProcess(processId, now); + if (session is not null || task is not null) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task StartDanmakuAsync(SessionProcessRuntime runtime, RecordTask initialTask, CancellationToken cancellationToken) + { + try + { + LiveRoom? liveRoom = null; + var includeNonChatEvents = true; + + using (var readScope = _serviceScopeFactory.CreateScope()) + { + var dbContext = readScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var adapterFactory = readScope.ServiceProvider.GetRequiredService<ILiveDanmakuAdapterFactory>(); + var settingsService = readScope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var settings = await settingsService.GetAsync(cancellationToken); + + if (!settings.EnableDanmakuRecording) + { + return; + } + + liveRoom = await dbContext.LiveRooms + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == runtime.LiveRoomId, cancellationToken); + if (liveRoom is null) + { + return; + } + + includeNonChatEvents = settings.DanmakuIncludeNonChatEvents; + var adapter = adapterFactory.GetByPlatform(liveRoom.Platform); + runtime.DanmakuConnection = await adapter.ConnectAsync( + new DanmakuConnectionContext( + liveRoom.Id, + runtime.RecordSessionId, + liveRoom.Platform, + liveRoom.RoomId, + liveRoom.AnchorName, + liveRoom.Title, + liveRoom.SourceUrl, + settings.DanmakuMinPollIntervalMilliseconds, + settings.DanmakuRetryDelayMaxSeconds), + cancellationToken); + } + + runtime.DanmakuRecorder = new SessionDanmakuXmlRecorder( + liveRoom.Platform, + liveRoom.Id, + runtime.RecordSessionId, + liveRoom.RoomId); + await runtime.DanmakuRecorder.StartSegmentAsync( + initialTask.Id, + initialTask.SegmentIndex, + initialTask.OutputFilePath ?? runtime.OutputPathPattern, + initialTask.StartedAt ?? DateTimeOffset.UtcNow); + runtime.HasInitializedDanmaku = true; + + runtime.DanmakuPumpTask = Task.Run( + () => runtime.DanmakuConnection.StartAsync( + danmakuEvent => + { + if (runtime.DanmakuRecorder is null) + { + return Task.CompletedTask; + } + + if (!includeNonChatEvents && + !string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase)) + { + return Task.CompletedTask; + } + + return runtime.DanmakuRecorder.AppendAsync(danmakuEvent); + }, + runtime.DanmakuCancellation.Token), + runtime.DanmakuCancellation.Token); + + using var logScope = _serviceScopeFactory.CreateScope(); + var logService = logScope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + SystemLogLevel.Info, + "Danmaku", + "Danmaku capture started for the recording session.", + liveRoomId: liveRoom.Id, + recordSessionId: runtime.RecordSessionId, + recordTaskId: initialTask.Id, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Start danmaku capture failed for session {RecordSessionId}", runtime.RecordSessionId); + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + SystemLogLevel.Warning, + "Danmaku", + "Danmaku capture startup failed. Recording will continue without live comments until retry succeeds.", + ex.ToString(), + runtime.LiveRoomId, + runtime.RecordSessionId, + runtime.CurrentTaskId, + cancellationToken); + } + catch (Exception logEx) + { + _logger.LogWarning(logEx, "Persist danmaku startup failure log failed for session {RecordSessionId}", runtime.RecordSessionId); + } + } + } + + private static bool IsRetryableStartupFailureLine(string line) => + line.Contains("Error reading HTTP response", StringComparison.OrdinalIgnoreCase) || + line.Contains("unexpected EOF", StringComparison.OrdinalIgnoreCase) || + line.Contains("Connection reset", StringComparison.OrdinalIgnoreCase) || + line.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) || + line.Contains("I/O error", StringComparison.OrdinalIgnoreCase) || + line.Contains("Invalid data found when processing input", StringComparison.OrdinalIgnoreCase) || + line.Contains("Server returned 4", StringComparison.OrdinalIgnoreCase) || + line.Contains("Server returned 5", StringComparison.OrdinalIgnoreCase) || + line.Contains("HTTP error 4", StringComparison.OrdinalIgnoreCase) || + line.Contains("HTTP error 5", StringComparison.OrdinalIgnoreCase); + + private static bool IsOptionCompatibilityFailureLine(string line) => + line.Contains("Option not found", StringComparison.OrdinalIgnoreCase) || + line.Contains("Unrecognized option", StringComparison.OrdinalIgnoreCase); + + private async Task EnsureDanmakuSegmentAsync(SessionProcessRuntime runtime, RecordTask recordTask, DateTimeOffset startedAt) + { + if (runtime.DanmakuRecorder is null) + { + return; + } + + await runtime.DanmakuRecorder.StartSegmentAsync( + recordTask.Id, + recordTask.SegmentIndex, + recordTask.OutputFilePath ?? runtime.CurrentOutputFilePath, + startedAt); + } + + private sealed class SessionProcessRuntime : IDisposable + { + public SessionProcessRuntime( + Guid recordSessionId, + Guid liveRoomId, + string streamUrl, + string outputPathPattern, + string recorderOutputPath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + Guid currentTaskId, + int currentSegmentIndex, + string currentOutputFilePath, + string selectedQuality, + string selectedProtocol, + StreamInputHeaders? inputHeaders, + FfmpegInputOptionProfile inputOptionProfile, + bool hasRetriedWithCompatibilityProfile, + bool hasRetriedWithRefreshedStream, + int retryAttemptCount) + { + RecordSessionId = recordSessionId; + LiveRoomId = liveRoomId; + StreamUrl = streamUrl; + OutputPathPattern = outputPathPattern; + RecorderOutputPath = recorderOutputPath; + OutputFormat = outputFormat; + SaveMode = saveMode; + CurrentTaskId = currentTaskId; + CurrentSegmentIndex = currentSegmentIndex; + CurrentOutputFilePath = currentOutputFilePath; + SelectedQuality = selectedQuality; + SelectedProtocol = selectedProtocol; + InputHeaders = inputHeaders; + InputOptionProfile = inputOptionProfile; + HasRetriedWithCompatibilityProfile = hasRetriedWithCompatibilityProfile; + HasRetriedWithRefreshedStream = hasRetriedWithRefreshedStream; + RetryAttemptCount = Math.Max(0, retryAttemptCount); + } + + public Guid RecordSessionId { get; } + public Guid LiveRoomId { get; } + public string StreamUrl { get; } + public string OutputPathPattern { get; } + public string RecorderOutputPath { get; } + public RecordOutputFormat OutputFormat { get; } + public RecordSaveMode SaveMode { get; } + public Guid CurrentTaskId { get; set; } + public int CurrentSegmentIndex { get; set; } + public string CurrentOutputFilePath { get; set; } + public string SelectedQuality { get; } + public string SelectedProtocol { get; } + public StreamInputHeaders? InputHeaders { get; } + public FfmpegInputOptionProfile InputOptionProfile { get; } + public bool HasRetriedWithCompatibilityProfile { get; } + public bool HasRetriedWithRefreshedStream { get; } + public Process? Process { get; private set; } + public int ProcessId => Process?.Id ?? 0; + public bool CompletionRequested { get; private set; } + public bool StopRequested { get; private set; } + public bool HasInitializedDanmaku { get; set; } + public bool HasOpenedFirstSegment { get; set; } + public int RetryAttemptCount { get; } + public StartupFailureKind StartupFailureKind { get; private set; } + public string? LastStartupFailureLine { get; private set; } + public SemaphoreSlim Gate { get; } = new(1, 1); + public CancellationTokenSource DanmakuCancellation { get; } = new(); + public TaskCompletionSource<bool> ExitCompletion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public SessionDanmakuXmlRecorder? DanmakuRecorder { get; set; } + public ILiveDanmakuConnection? DanmakuConnection { get; set; } + public Task? DanmakuPumpTask { get; set; } + + public void AttachProcess(Process process) => Process = process; + + public void MarkStopRequested(bool markAsCompletedOnExit) + { + if (markAsCompletedOnExit) + { + CompletionRequested = true; + StopRequested = false; + } + else + { + CompletionRequested = false; + StopRequested = true; + } + } + + public void MarkStartupFailure(StartupFailureKind kind, string line) + { + if (StartupFailureKind == StartupFailureKind.InputOptionCompatibility && + kind != StartupFailureKind.InputOptionCompatibility) + { + return; + } + + StartupFailureKind = kind; + LastStartupFailureLine = line; + } + + public void Dispose() + { + DanmakuCancellation.Dispose(); + Gate.Dispose(); + } + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs new file mode 100644 index 0000000..3266e6a --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs @@ -0,0 +1,399 @@ +using System.Diagnostics; +using System.Text; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed partial class FfmpegService +{ + private static async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async( + string ffmpegPath, + string sourcePath, + string targetPath) + { + if (!File.Exists(sourcePath)) + { + return (File.Exists(targetPath) ? targetPath : sourcePath, "The intermediate recording file was not found for MP4 finalization."); + } + + var tempPath = Path.Combine( + Path.GetDirectoryName(targetPath)!, + $"{Path.GetFileNameWithoutExtension(targetPath)}.remux{Path.GetExtension(targetPath)}"); + + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + + var remuxProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = ffmpegPath, + Arguments = $"-hide_banner -y -i {Quote(sourcePath)} -c copy -movflags +faststart {Quote(tempPath)}", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + remuxProcess.Start(); + await remuxProcess.WaitForExitAsync(); + + if (remuxProcess.ExitCode == 0 && File.Exists(tempPath)) + { + if (File.Exists(targetPath)) + { + File.Replace(tempPath, targetPath, null, ignoreMetadataErrors: true); + } + else + { + File.Move(tempPath, targetPath); + } + + if (!string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase) && File.Exists(sourcePath)) + { + File.Delete(sourcePath); + } + + return (targetPath, null); + } + + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + + var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath; + return (fallbackPath, "The MP4 file could not be finalized into a seekable output."); + } + + private static IReadOnlyList<string> BuildArgumentList( + string streamUrl, + string outputFilePath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + RecordingTemplateType recordingTemplate, + bool enableReconnect, + int reconnectDelayMaxSeconds, + int readWriteTimeoutMilliseconds, + int segmentDurationMinutes, + StreamInputHeaders? inputHeaders, + FfmpegInputOptionProfile inputOptionProfile) + { + var arguments = new List<string> { "-hide_banner", "-y" }; + var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode); + + if (IsHttpInput(streamUrl)) + { + if (!string.IsNullOrWhiteSpace(inputHeaders?.UserAgent)) + { + arguments.AddRange(["-user_agent", inputHeaders.UserAgent]); + } + + if (!string.IsNullOrWhiteSpace(inputHeaders?.Referer)) + { + arguments.AddRange(["-referer", inputHeaders.Referer]); + } + + var customHeaders = BuildCustomHeaderArgument(inputHeaders); + if (!string.IsNullOrWhiteSpace(customHeaders)) + { + arguments.AddRange(["-headers", customHeaders]); + } + } + + if (enableReconnect && inputOptionProfile == FfmpegInputOptionProfile.Baseline) + { + arguments.AddRange( + [ + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_at_eof", "1", + "-reconnect_delay_max", reconnectDelayMaxSeconds.ToString() + ]); + } + + arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", "+discardcorrupt+genpts", "-i", streamUrl]); + arguments.AddRange(BuildCodecArguments(recordingTemplate)); + + if (saveMode == RecordSaveMode.Segmented) + { + arguments.AddRange( + [ + "-f", "segment", + "-segment_start_number", "1", + "-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(), + "-reset_timestamps", "1", + "-strftime", "0", + "-segment_format", outputFormat == RecordOutputFormat.Ts ? "mpegts" : "mp4" + ]); + + if (outputFormat == RecordOutputFormat.Mp4) + { + arguments.AddRange(["-segment_format_options", $"movflags={BuildSegmentedMp4MovFlags()}"]); + } + } + else if (useIntermediateTransportStream) + { + arguments.AddRange(["-f", "mpegts"]); + } + else if (outputFormat == RecordOutputFormat.Mp4) + { + arguments.AddRange(["-movflags", BuildSingleFileMp4MovFlags()]); + } + + arguments.Add(outputFilePath); + return arguments; + } + + private static IReadOnlyList<string> BuildCodecArguments(RecordingTemplateType recordingTemplate) => + recordingTemplate switch + { + RecordingTemplateType.BalancedMp4 => + [ + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "23", + "-c:a", "aac", + "-b:a", "128k" + ], + RecordingTemplateType.ArchiveTs => + [ + "-map", "0", + "-c", "copy" + ], + _ => + [ + "-c", "copy" + ] + }; + + private static long? CalculateFileSize(string? outputPath) + { + if (string.IsNullOrWhiteSpace(outputPath)) + { + return null; + } + + if (File.Exists(outputPath)) + { + return new FileInfo(outputPath).Length; + } + + if (Directory.Exists(outputPath)) + { + return new DirectoryInfo(outputPath) + .EnumerateFiles("*", SearchOption.TopDirectoryOnly) + .Sum(static file => file.Length); + } + + return null; + } + + private static bool HasUsableOutput(string? outputPath, long? fileSize) + { + if (string.IsNullOrWhiteSpace(outputPath)) + { + return false; + } + + if (File.Exists(outputPath)) + { + return fileSize.GetValueOrDefault() > 0; + } + + if (Directory.Exists(outputPath)) + { + return Directory.EnumerateFiles(outputPath, "*", SearchOption.TopDirectoryOnly).Any(); + } + + return false; + } + + private static void UpsertRecordResult( + RecordTask recordTask, + LiveRecorderDbContext dbContext, + string? effectiveOutputPath, + long? fileSize, + double? durationSeconds, + string? danmakuFilePath, + int danmakuMessageCount, + DateTimeOffset endedAt) + { + if (string.IsNullOrWhiteSpace(effectiveOutputPath)) + { + effectiveOutputPath = recordTask.OutputFilePath ?? string.Empty; + } + + if (recordTask.Result is null) + { + dbContext.RecordResults.Add(new RecordResult( + recordTask.Id, + effectiveOutputPath, + fileSize, + durationSeconds, + danmakuFilePath, + danmakuMessageCount, + recordTask.Status, + recordTask.ErrorMessage, + endedAt)); + return; + } + + recordTask.Result.Update( + effectiveOutputPath, + fileSize, + durationSeconds, + danmakuFilePath, + danmakuMessageCount, + recordTask.Status, + recordTask.ErrorMessage); + } + + private static string BuildSingleFileMp4MovFlags() => + "+faststart+frag_keyframe+empty_moov+default_base_moof"; + + private static string BuildSegmentedMp4MovFlags() => + "+faststart+frag_keyframe+empty_moov+default_base_moof"; + + private static string GetRecorderOutputPath( + string finalOutputPath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode) + { + if (saveMode == RecordSaveMode.SingleFile && outputFormat == RecordOutputFormat.Mp4) + { + return Path.Combine( + Path.GetDirectoryName(finalOutputPath)!, + $"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts"); + } + + return finalOutputPath; + } + + private static bool ShouldUseIntermediateTransportStream( + string outputPath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode) => + saveMode == RecordSaveMode.SingleFile && + outputFormat == RecordOutputFormat.Mp4 && + outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase); + + private static bool IsHttpInput(string streamUrl) => + streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + private static string? BuildCustomHeaderArgument(StreamInputHeaders? inputHeaders) + { + if (inputHeaders is null) + { + return null; + } + + var builder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(inputHeaders.Cookie)) + { + builder.Append("Cookie: "); + builder.Append(inputHeaders.Cookie.Trim()); + builder.Append("\r\n"); + } + + if (inputHeaders.AdditionalHeaders is not null) + { + foreach (var pair in inputHeaders.AdditionalHeaders.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key))) + { + builder.Append(pair.Key.Trim()); + builder.Append(": "); + builder.Append(pair.Value?.Trim() ?? string.Empty); + builder.Append("\r\n"); + } + } + + return builder.Length == 0 ? null : builder.ToString(); + } + + private static bool TryParseSegmentOpenPath(string line, out string openedPath) + { + var match = SegmentOpeningRegex.Match(line); + if (match.Success) + { + openedPath = match.Groups[1].Value; + return true; + } + + openedPath = string.Empty; + return false; + } + + private static int? ExtractSegmentIndex(string openedPath) + { + var fileName = Path.GetFileNameWithoutExtension(openedPath); + var lastUnderscore = fileName.LastIndexOf('_'); + if (lastUnderscore < 0 || lastUnderscore == fileName.Length - 1) + { + return null; + } + + var suffix = fileName[(lastUnderscore + 1)..]; + return int.TryParse(suffix, out var value) ? value : null; + } + + private static string? GuessDanmakuPath(string? outputFilePath) + { + if (string.IsNullOrWhiteSpace(outputFilePath)) + { + return null; + } + + var fullPath = Path.IsPathRooted(outputFilePath) + ? outputFilePath + : Path.GetFullPath(outputFilePath, AppContext.BaseDirectory); + return SessionDanmakuXmlRecorder.GetDanmakuFilePath(fullPath); + } + + private static int CountDanmakuMessages(string? danmakuPath) + { + if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath)) + { + return 0; + } + + var count = 0; + foreach (var line in File.ReadLines(danmakuPath)) + { + if (line.Contains("<d ", StringComparison.OrdinalIgnoreCase) || + line.Contains("<event ", StringComparison.OrdinalIgnoreCase)) + { + count++; + } + } + + return count; + } + + private static bool IsActiveTaskStatus(RecordTaskStatus status) => + status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping; + + private static bool IsActiveSessionStatus(RecordSessionStatus status) => + status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; + + private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + + private enum FfmpegInputOptionProfile + { + Baseline = 0, + Minimal = 1 + } + + private enum StartupFailureKind + { + None = 0, + InputOptionCompatibility = 1, + StreamHandshake = 2 + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs new file mode 100644 index 0000000..38923d1 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs @@ -0,0 +1,380 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed partial class FfmpegService : IFfmpegService +{ + private static readonly Regex SegmentOpeningRegex = new( + """Opening '([^']+)' for writing""", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new(); + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger<FfmpegService> _logger; + + public FfmpegService( + IServiceScopeFactory serviceScopeFactory, + ILogger<FfmpegService> logger) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId); + + public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) => + RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken); + + public Task StartAsync( + RecordSession recordSession, + RecordTask initialTask, + StreamUrlResult streamUrlResult, + CancellationToken cancellationToken = default) => + StartInternalAsync( + recordSession, + initialTask, + streamUrlResult, + inputOptionProfile: FfmpegInputOptionProfile.Baseline, + hasRetriedWithCompatibilityProfile: false, + hasRetriedWithRefreshedStream: false, + retryAttemptCount: 0, + cancellationToken); + + private async Task StartInternalAsync( + RecordSession recordSession, + RecordTask initialTask, + StreamUrlResult streamUrlResult, + FfmpegInputOptionProfile inputOptionProfile, + bool hasRetriedWithCompatibilityProfile, + bool hasRetriedWithRefreshedStream, + int retryAttemptCount, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recordSession); + ArgumentNullException.ThrowIfNull(initialTask); + ArgumentNullException.ThrowIfNull(streamUrlResult); + + if (string.IsNullOrWhiteSpace(streamUrlResult.SelectedUrl) || string.IsNullOrWhiteSpace(recordSession.OutputPathPattern)) + { + throw new InvalidOperationException("Recording session is missing stream URL or output path."); + } + + using var settingsScope = _serviceScopeFactory.CreateScope(); + var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var settings = await settingsService.GetAsync(cancellationToken); + + var outputPathPattern = Path.IsPathRooted(recordSession.OutputPathPattern) + ? recordSession.OutputPathPattern + : Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory); + var recorderOutputPath = GetRecorderOutputPath(outputPathPattern, recordSession.OutputFormat, recordSession.SaveMode); + Directory.CreateDirectory(Path.GetDirectoryName(recorderOutputPath)!); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = settings.FfmpegPath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }, + EnableRaisingEvents = true + }; + + var runtime = new SessionProcessRuntime( + recordSession.Id, + recordSession.LiveRoomId, + streamUrlResult.SelectedUrl, + outputPathPattern, + recorderOutputPath, + recordSession.OutputFormat, + recordSession.SaveMode, + initialTask.Id, + Math.Max(1, initialTask.SegmentIndex), + initialTask.OutputFilePath ?? outputPathPattern, + streamUrlResult.SelectedQuality, + streamUrlResult.SelectedProtocol, + streamUrlResult.InputHeaders, + inputOptionProfile, + hasRetriedWithCompatibilityProfile, + hasRetriedWithRefreshedStream, + retryAttemptCount); + + foreach (var argument in BuildArgumentList( + streamUrlResult.SelectedUrl, + recorderOutputPath, + recordSession.OutputFormat, + recordSession.SaveMode, + settings.RecordingTemplate, + settings.EnableAutoReconnect, + settings.ReconnectDelayMaxSeconds, + settings.ReadWriteTimeoutMilliseconds, + settings.SegmentDurationMinutes, + streamUrlResult.InputHeaders, + inputOptionProfile)) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.OutputDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: false); + process.ErrorDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: true); + process.Exited += (_, _) => _ = HandleProcessExitedAsync(runtime, process); + + if (!process.Start()) + { + throw new InvalidOperationException("ffmpeg failed to start."); + } + + runtime.AttachProcess(process); + if (!_processes.TryAdd(recordSession.Id, runtime)) + { + process.Kill(true); + process.Dispose(); + throw new InvalidOperationException("A running ffmpeg process already exists for the recording session."); + } + + await PersistProcessBindingAsync(recordSession.Id, initialTask.Id, process.Id, cancellationToken); + await PersistStartupProfileAsync(runtime, cancellationToken); + await StartDanmakuAsync(runtime, initialTask, cancellationToken); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + public Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default) => + RequestStopAsync(recordSessionId, markAsCompletedOnExit: false, cancellationToken); + + public async Task<bool> StopAndWaitAsync( + Guid recordSessionId, + bool markAsCompletedOnExit, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (!_processes.TryGetValue(recordSessionId, out var runtime)) + { + return true; + } + + await RequestStopAsync(recordSessionId, markAsCompletedOnExit, cancellationToken); + return await WaitForExitAsync(runtime, timeout, cancellationToken); + } + + public async Task<bool> KillAndWaitAsync( + Guid recordSessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (!_processes.TryGetValue(recordSessionId, out var runtime)) + { + return true; + } + + var process = runtime.Process; + if (process is not null) + { + try + { + if (!process.HasExited) + { + process.Kill(true); + } + } + catch (InvalidOperationException) + { + } + } + + return await WaitForExitAsync(runtime, timeout, cancellationToken); + } + + public async Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default) + { + if (IsRunning(recordSessionId)) + { + return false; + } + + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + + var recordSession = await dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken); + + if (recordSession is null || !IsActiveSessionStatus(recordSession.Status)) + { + return false; + } + + var settings = await settingsService.GetAsync(cancellationToken); + var endedAt = DateTimeOffset.UtcNow; + var tasks = recordSession.RecordTasks + .OrderBy(static item => item.SegmentIndex) + .ThenBy(static item => item.CreatedAt) + .ToList(); + + var anyUsableOutput = false; + string? finalizationError = null; + + foreach (var task in tasks.Where(item => IsActiveTaskStatus(item.Status))) + { + var effectiveOutputPath = task.OutputFilePath ?? string.Empty; + if (recordSession.SaveMode == RecordSaveMode.SingleFile && + recordSession.OutputFormat == RecordOutputFormat.Mp4 && + !string.IsNullOrWhiteSpace(recordSession.OutputPathPattern)) + { + var finalOutputPath = Path.IsPathRooted(recordSession.OutputPathPattern) + ? recordSession.OutputPathPattern + : Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory); + var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode); + if (File.Exists(recorderOutputPath)) + { + var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, recorderOutputPath, finalOutputPath); + effectiveOutputPath = finalizationResult.OutputPath; + finalizationError ??= finalizationResult.ErrorMessage; + } + } + + var fileSize = CalculateFileSize(effectiveOutputPath); + var danmakuPath = task.Result?.DanmakuFilePath ?? GuessDanmakuPath(task.OutputFilePath); + var danmakuCount = CountDanmakuMessages(danmakuPath); + var durationSeconds = task.StartedAt.HasValue + ? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds) + : null; + + if (!string.IsNullOrWhiteSpace(finalizationError)) + { + task.MarkFailed(finalizationError, endedAt); + } + else if (HasUsableOutput(effectiveOutputPath, fileSize)) + { + task.MarkCompleted(endedAt, durationSeconds); + anyUsableOutput = true; + } + else + { + task.MarkStopped(endedAt, durationSeconds, "Recording process was no longer running when the session was reconciled."); + } + + UpsertRecordResult(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt); + } + + recordSession.SyncSegmentCount(tasks.Count, endedAt); + if (!string.IsNullOrWhiteSpace(finalizationError)) + { + recordSession.MarkFailed(finalizationError, endedAt); + } + else if (anyUsableOutput) + { + recordSession.MarkCompleted(endedAt); + } + else + { + recordSession.MarkStopped(endedAt, "Recording process was no longer running when the session was reconciled."); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + private async Task PersistStartupProfileAsync(SessionProcessRuntime runtime, CancellationToken cancellationToken) + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + $"ffmpeg input profile={runtime.InputOptionProfile}.", + detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}", + liveRoomId: runtime.LiveRoomId, + recordSessionId: runtime.RecordSessionId, + recordTaskId: runtime.CurrentTaskId, + cancellationToken: cancellationToken); + } + + private static async Task<bool> WaitForExitAsync( + SessionProcessRuntime runtime, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var delayTask = Task.Delay(timeout, timeoutCts.Token); + var completed = await Task.WhenAny(runtime.ExitCompletion.Task, delayTask); + if (completed == runtime.ExitCompletion.Task) + { + timeoutCts.Cancel(); + await runtime.ExitCompletion.Task; + return true; + } + + return false; + } + + private async Task RequestStopAsync(Guid recordSessionId, bool markAsCompletedOnExit, CancellationToken cancellationToken) + { + if (!_processes.TryGetValue(recordSessionId, out var runtime)) + { + return; + } + + runtime.MarkStopRequested(markAsCompletedOnExit); + var process = runtime.Process; + if (process is null) + { + return; + } + + try + { + if (process.HasExited) + { + return; + } + + await process.StandardInput.WriteLineAsync("q"); + await process.StandardInput.FlushAsync(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(12)); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(true); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Stop ffmpeg process failed for session {RecordSessionId}", recordSessionId); + + if (!process.HasExited) + { + process.Kill(true); + } + } + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs new file mode 100644 index 0000000..485582d --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs @@ -0,0 +1,243 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed class LiveRoomPollingBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger<LiveRoomPollingBackgroundService> _logger; + + public LiveRoomPollingBackgroundService( + IServiceScopeFactory serviceScopeFactory, + ILogger<LiveRoomPollingBackgroundService> logger) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var delaySeconds = 60; + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var settings = await settingsService.GetAsync(stoppingToken); + var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>(); + + delaySeconds = settings.PollingIntervalSeconds; + if (!settings.EnableBackgroundPolling) + { + await DelayAsync(delaySeconds, stoppingToken); + continue; + } + + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>(); + var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>(); + var recordService = scope.ServiceProvider.GetRequiredService<RecordService>(); + var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + + var liveRooms = (await dbContext.LiveRooms.ToListAsync(stoppingToken)) + .Where(static item => item.IsEnabled) + .OrderBy(static item => item.UpdatedAt) + .ToList(); + + foreach (var liveRoom in liveRooms) + { + if (stoppingToken.IsCancellationRequested) + { + break; + } + + try + { + var adapter = adapterFactory.GetByPlatform(liveRoom.Platform); + var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, stoppingToken); + var now = DateTimeOffset.UtcNow; + + await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, stoppingToken); + + await dbContext.SaveChangesAsync(stoppingToken); + + if (!liveStatus.IsLive) + { + await CompleteActiveSessionsForOfflineRoomAsync( + dbContext, + ffmpegService, + logService, + liveRoom.Id, + stoppingToken); + continue; + } + + if (!settings.AutoStartRecordingOnLive) + { + continue; + } + + var hasRunningSession = await dbContext.RecordSessions.AnyAsync( + item => item.LiveRoomId == liveRoom.Id && + (item.Status == RecordSessionStatus.Starting || + item.Status == RecordSessionStatus.Running || + item.Status == RecordSessionStatus.Stopping), + stoppingToken); + + if (hasRunningSession) + { + continue; + } + + _logger.LogInformation("Auto-start recording for live room {RoomId}", liveRoom.RoomId); + await logService.WriteAsync( + SystemLogLevel.Info, + "Scheduler", + "Live detected by background poller. Auto-starting recording task.", + liveRoomId: liveRoom.Id, + cancellationToken: stoppingToken); + + await recordService.StartAsync( + new StartRecordTaskRequest + { + LiveRoomId = liveRoom.Id, + PreferredQuality = settings.DefaultQuality, + OutputFormat = settings.DefaultOutputFormat + }, + stoppingToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId); + + var isTransient = IsTransientPollingException(ex, stoppingToken); + await logService.WriteAsync( + isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error, + "Scheduler", + isTransient + ? "Transient background polling failure. The room will be retried on the next cycle." + : "Background polling failed for a live room.", + ex.ToString(), + liveRoomId: liveRoom.Id, + cancellationToken: stoppingToken); + + if (!isTransient) + { + await emailNotificationService.SendExceptionAsync( + "Scheduler", + "Background polling failed for a live room.", + ex.ToString(), + liveRoom, + cancellationToken: stoppingToken); + } + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Background live room polling failed"); + + try + { + using var notificationScope = _serviceScopeFactory.CreateScope(); + var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService<IEmailNotificationService>(); + await emailNotificationService.SendExceptionAsync( + "Scheduler", + "Background live room polling failed.", + ex.ToString(), + cancellationToken: stoppingToken); + } + catch (Exception notificationEx) + { + _logger.LogWarning(notificationEx, "Scheduler failure notification send failed"); + } + } + + await DelayAsync(delaySeconds, stoppingToken); + } + } + + private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) => + Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken); + + private static async Task CompleteActiveSessionsForOfflineRoomAsync( + LiveRecorderDbContext dbContext, + IFfmpegService ffmpegService, + ISystemLogService logService, + Guid liveRoomId, + CancellationToken cancellationToken) + { + var activeSessions = await dbContext.RecordSessions + .Where(item => item.LiveRoomId == liveRoomId && + (item.Status == RecordSessionStatus.Starting || + item.Status == RecordSessionStatus.Running || + item.Status == RecordSessionStatus.Stopping)) + .ToListAsync(cancellationToken); + + activeSessions = activeSessions + .OrderBy(item => item.CreatedAt) + .ToList(); + + foreach (var activeSession in activeSessions) + { + if (ffmpegService.IsRunning(activeSession.Id)) + { + await ffmpegService.CompleteAsync(activeSession.Id, cancellationToken); + await logService.WriteAsync( + SystemLogLevel.Info, + "Scheduler", + "Live room is offline. Completing the active recording session.", + liveRoomId: liveRoomId, + recordSessionId: activeSession.Id, + cancellationToken: cancellationToken); + continue; + } + + if (await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken)) + { + await logService.WriteAsync( + SystemLogLevel.Warning, + "Scheduler", + "Recovered a stale active recording session after the room was detected offline.", + liveRoomId: liveRoomId, + recordSessionId: activeSession.Id, + cancellationToken: cancellationToken); + } + } + } + + private static bool IsTransientPollingException(Exception exception, CancellationToken cancellationToken) + { + if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested) + { + return false; + } + + if (exception is HttpRequestException or IOException or TimeoutException) + { + return true; + } + + return exception.InnerException is not null && + IsTransientPollingException(exception.InnerException, cancellationToken); + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/RecordMediaService.cs b/src/LiveRecorder.Infrastructure/Services/RecordMediaService.cs new file mode 100644 index 0000000..63996c4 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/RecordMediaService.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.Caching.Memory; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed class RecordMediaService : IRecordMediaService +{ + private static readonly TimeSpan PreviewTicketLifetime = TimeSpan.FromMinutes(5); + + private readonly IMemoryCache _memoryCache; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + + public RecordMediaService( + IMemoryCache memoryCache, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository) + { + _memoryCache = memoryCache; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + } + + public async Task<RecordPreviewTicketGrant> CreatePreviewTicketAsync(Guid recordTaskId, CancellationToken cancellationToken = default) + { + var recordTask = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken) + ?? throw new KeyNotFoundException("Recording task was not found."); + + if (recordTask.Status != RecordTaskStatus.Completed) + { + throw new InvalidOperationException("Preview is only available for completed tasks."); + } + + if (recordTask.OutputFormat != RecordOutputFormat.Mp4) + { + throw new NotSupportedException("Only MP4 recordings are supported for preview."); + } + + var recordResult = await _recordResultRepository.GetByTaskIdAsync(recordTaskId, cancellationToken); + var filePath = recordResult?.FilePath ?? recordTask.OutputFilePath; + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new InvalidOperationException("Preview file path is missing."); + } + + var absoluteFilePath = Path.IsPathRooted(filePath) + ? filePath + : Path.GetFullPath(filePath, AppContext.BaseDirectory); + + if (Directory.Exists(absoluteFilePath)) + { + throw new NotSupportedException("Segmented recordings are not supported for preview."); + } + + if (!absoluteFilePath.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException("Only MP4 recordings are supported for preview."); + } + + if (!File.Exists(absoluteFilePath)) + { + throw new InvalidOperationException("Preview file does not exist on disk."); + } + + var ticket = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); + var expiresAt = DateTimeOffset.UtcNow.Add(PreviewTicketLifetime); + + _memoryCache.Set( + GetCacheKey(ticket), + new PreviewTicketPayload(recordTaskId, absoluteFilePath, "video/mp4"), + expiresAt); + + return new RecordPreviewTicketGrant(ticket, expiresAt); + } + + public Task<RecordMediaFile?> ResolvePreviewAsync(string ticket, CancellationToken cancellationToken = default) + { + if (!_memoryCache.TryGetValue(GetCacheKey(ticket), out PreviewTicketPayload? payload) || payload is null) + { + return Task.FromResult<RecordMediaFile?>(null); + } + + if (!File.Exists(payload.FilePath)) + { + _memoryCache.Remove(GetCacheKey(ticket)); + return Task.FromResult<RecordMediaFile?>(null); + } + + return Task.FromResult<RecordMediaFile?>( + new RecordMediaFile(payload.RecordTaskId, payload.FilePath, payload.ContentType)); + } + + private static string GetCacheKey(string ticket) => $"record-preview:{ticket}"; + + private sealed record PreviewTicketPayload( + Guid RecordTaskId, + string FilePath, + string ContentType); +} diff --git a/src/LiveRecorder.Infrastructure/Services/SessionDanmakuXmlRecorder.cs b/src/LiveRecorder.Infrastructure/Services/SessionDanmakuXmlRecorder.cs new file mode 100644 index 0000000..945b1f4 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/SessionDanmakuXmlRecorder.cs @@ -0,0 +1,236 @@ +using System.Collections.Concurrent; +using System.Security; +using System.Text; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Services; + +internal sealed class SessionDanmakuXmlRecorder : IAsyncDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly LivePlatformType _platformType; + private readonly Guid _liveRoomId; + private readonly Guid _recordSessionId; + private readonly string _roomId; + + private DanmakuSegmentWriter? _currentWriter; + private readonly ConcurrentDictionary<Guid, DanmakuSegmentSummary> _completedSummaries = new(); + + public SessionDanmakuXmlRecorder( + LivePlatformType platformType, + Guid liveRoomId, + Guid recordSessionId, + string roomId) + { + _platformType = platformType; + _liveRoomId = liveRoomId; + _recordSessionId = recordSessionId; + _roomId = roomId; + } + + public async Task StartSegmentAsync(Guid recordTaskId, int segmentIndex, string videoFilePath, DateTimeOffset startedAt) + { + await _gate.WaitAsync(); + try + { + if (_currentWriter is not null) + { + var summary = await _currentWriter.CloseAsync(); + _completedSummaries[_currentWriter.RecordTaskId] = summary; + } + + _currentWriter = await DanmakuSegmentWriter.CreateAsync( + _platformType, + _liveRoomId, + _recordSessionId, + recordTaskId, + _roomId, + Math.Max(1, segmentIndex), + GetDanmakuFilePath(videoFilePath), + startedAt); + } + finally + { + _gate.Release(); + } + } + + public async Task AppendAsync(DanmakuEvent danmakuEvent) + { + await _gate.WaitAsync(); + try + { + if (_currentWriter is null) + { + return; + } + + await _currentWriter.AppendAsync(danmakuEvent); + } + finally + { + _gate.Release(); + } + } + + public async Task<DanmakuSegmentSummary?> CompleteActiveSegmentAsync() + { + await _gate.WaitAsync(); + try + { + if (_currentWriter is null) + { + return null; + } + + var summary = await _currentWriter.CloseAsync(); + _completedSummaries[_currentWriter.RecordTaskId] = summary; + _currentWriter = null; + return summary; + } + finally + { + _gate.Release(); + } + } + + public DanmakuSegmentSummary? TakeSummary(Guid recordTaskId) + { + if (_completedSummaries.TryRemove(recordTaskId, out var summary)) + { + return summary; + } + + if (_currentWriter is not null && _currentWriter.RecordTaskId == recordTaskId) + { + return _currentWriter.ToSummary(); + } + + return null; + } + + public async ValueTask DisposeAsync() + { + await CompleteActiveSegmentAsync(); + _gate.Dispose(); + } + + public static string GetDanmakuFilePath(string videoFilePath) => + Path.ChangeExtension(videoFilePath, ".xml"); + + internal sealed record DanmakuSegmentSummary(Guid RecordTaskId, string FilePath, int MessageCount); + + private sealed class DanmakuSegmentWriter + { + private readonly StreamWriter _writer; + private readonly DateTimeOffset _startedAt; + private bool _closed; + + private DanmakuSegmentWriter( + Guid recordTaskId, + int segmentIndex, + string filePath, + DateTimeOffset startedAt, + StreamWriter writer) + { + RecordTaskId = recordTaskId; + SegmentIndex = segmentIndex; + FilePath = filePath; + _startedAt = startedAt; + _writer = writer; + } + + public Guid RecordTaskId { get; } + + public int SegmentIndex { get; } + + public string FilePath { get; } + + public int MessageCount { get; private set; } + + public static async Task<DanmakuSegmentWriter> CreateAsync( + LivePlatformType platformType, + Guid liveRoomId, + Guid recordSessionId, + Guid recordTaskId, + string roomId, + int segmentIndex, + string filePath, + DateTimeOffset startedAt) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + var writer = new StreamWriter(filePath, false, new UTF8Encoding(false)); + await writer.WriteLineAsync("""<?xml version="1.0" encoding="UTF-8"?>"""); + await writer.WriteLineAsync( + $"""<i platform="{Escape(platformType.ToString())}" roomId="{Escape(roomId)}" liveRoomId="{liveRoomId}" recordSessionId="{recordSessionId}" recordTaskId="{recordTaskId}" segmentIndex="{segmentIndex}" startedAt="{startedAt:O}">"""); + await writer.FlushAsync(); + + return new DanmakuSegmentWriter(recordTaskId, segmentIndex, filePath, startedAt, writer); + } + + public async Task AppendAsync(DanmakuEvent danmakuEvent) + { + if (_closed) + { + return; + } + + var offsetSeconds = Math.Max(0, (danmakuEvent.OccurredAt - _startedAt).TotalSeconds); + var timestamp = danmakuEvent.OccurredAt.ToUnixTimeMilliseconds(); + if (string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase)) + { + var content = Escape(danmakuEvent.Content ?? string.Empty); + var user = Escape(danmakuEvent.User ?? string.Empty); + var userId = Escape(danmakuEvent.UserId ?? "0"); + var raw = Escape(CompactRawPayload(danmakuEvent.RawPayload)); + await _writer.WriteLineAsync( + $""" <d p="{offsetSeconds:F1},1,25,16777215,{timestamp},0,{userId},0" user="{user}" type="chat" raw="{raw}">{content}</d>"""); + } + else + { + var extraAttributes = string.Empty; + if (danmakuEvent.Extra is not null) + { + extraAttributes = string.Join( + string.Empty, + danmakuEvent.Extra.Select(pair => $" {Escape(pair.Key)}=\"{Escape(pair.Value)}\"")); + } + + await _writer.WriteLineAsync( + $""" <event type="{Escape(danmakuEvent.Type)}" ts="{timestamp}" offset="{offsetSeconds:F1}" user="{Escape(danmakuEvent.User ?? string.Empty)}" userId="{Escape(danmakuEvent.UserId ?? string.Empty)}" content="{Escape(danmakuEvent.Content ?? string.Empty)}" raw="{Escape(CompactRawPayload(danmakuEvent.RawPayload))}"{extraAttributes} />"""); + } + + MessageCount++; + await _writer.FlushAsync(); + } + + public async Task<DanmakuSegmentSummary> CloseAsync() + { + if (!_closed) + { + await _writer.WriteLineAsync("</i>"); + await _writer.FlushAsync(); + await _writer.DisposeAsync(); + _closed = true; + } + + return ToSummary(); + } + + public DanmakuSegmentSummary ToSummary() => new(RecordTaskId, FilePath, MessageCount); + + private static string Escape(string input) => SecurityElement.Escape(input) ?? string.Empty; + + private static string CompactRawPayload(string rawPayload) + { + if (string.IsNullOrWhiteSpace(rawPayload)) + { + return string.Empty; + } + + var compact = rawPayload.Trim(); + return compact.Length <= 512 ? compact : compact[..512]; + } + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/AuthController.cs b/src/LiveRecorder.WebApi/Controllers/AuthController.cs new file mode 100644 index 0000000..bbd4c4b --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/AuthController.cs @@ -0,0 +1,36 @@ +using LiveRecorder.Application.Abstractions.Auth; +using LiveRecorder.Application.Models.Auth; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) + { + var result = await _authService.LoginAsync(request, cancellationToken); + return Ok(result); + } + + [HttpPost("logout")] + public async Task<IActionResult> Logout(CancellationToken cancellationToken) + { + var authorization = Request.Headers.Authorization.ToString(); + var token = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? authorization["Bearer ".Length..].Trim() + : string.Empty; + + await _authService.LogoutAsync(token, cancellationToken); + return NoContent(); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs b/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs new file mode 100644 index 0000000..4a0ea5d --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs @@ -0,0 +1,50 @@ +using LiveRecorder.Application.Models.LiveRooms; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/live-rooms")] +public sealed class LiveRoomsController : ControllerBase +{ + private readonly LiveRoomService _liveRoomService; + + public LiveRoomsController(LiveRoomService liveRoomService) + { + _liveRoomService = liveRoomService; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<LiveRoomDto>>> List(CancellationToken cancellationToken) => + Ok(await _liveRoomService.ListAsync(cancellationToken)); + + [HttpGet("{id:guid}")] + public async Task<ActionResult<LiveRoomDto>> Get(Guid id, CancellationToken cancellationToken) + { + var result = await _liveRoomService.GetAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task<ActionResult<LiveRoomDto>> Create([FromBody] CreateLiveRoomRequest request, CancellationToken cancellationToken) => + Ok(await _liveRoomService.CreateAsync(request, cancellationToken)); + + [HttpPost("{id:guid}/refresh")] + public async Task<ActionResult<LiveRoomDto>> Refresh(Guid id, CancellationToken cancellationToken) => + Ok(await _liveRoomService.RefreshStatusAsync(id, cancellationToken)); + + [HttpPut("{id:guid}/enabled")] + public async Task<ActionResult<LiveRoomDto>> SetEnabled( + Guid id, + [FromBody] SetLiveRoomEnabledRequest request, + CancellationToken cancellationToken) => + Ok(await _liveRoomService.SetEnabledAsync(id, request.IsEnabled, cancellationToken)); + + [HttpDelete("{id:guid}")] + public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken) + { + await _liveRoomService.DeleteAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/LogsController.cs b/src/LiveRecorder.WebApi/Controllers/LogsController.cs new file mode 100644 index 0000000..2ebbc72 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/LogsController.cs @@ -0,0 +1,26 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Models.Logs; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/logs")] +public sealed class LogsController : ControllerBase +{ + private readonly ISystemLogService _systemLogService; + + public LogsController(ISystemLogService systemLogService) + { + _systemLogService = systemLogService; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<SystemLogDto>>> List( + [FromQuery] Guid? liveRoomId, + [FromQuery] Guid? recordSessionId, + [FromQuery] Guid? recordTaskId, + [FromQuery] int take = 200, + CancellationToken cancellationToken = default) => + Ok(await _systemLogService.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken)); +} diff --git a/src/LiveRecorder.WebApi/Controllers/MediaController.cs b/src/LiveRecorder.WebApi/Controllers/MediaController.cs new file mode 100644 index 0000000..3f47af9 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/MediaController.cs @@ -0,0 +1,32 @@ +using LiveRecorder.Application.Abstractions.Recording; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("media/record-tasks")] +public sealed class MediaController : ControllerBase +{ + private readonly IRecordMediaService _recordMediaService; + + public MediaController(IRecordMediaService recordMediaService) + { + _recordMediaService = recordMediaService; + } + + [HttpGet("{ticket}")] + public async Task<IActionResult> GetRecordTaskMedia(string ticket, CancellationToken cancellationToken) + { + var mediaFile = await _recordMediaService.ResolvePreviewAsync(ticket, cancellationToken); + if (mediaFile is null) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(mediaFile.FilePath); + return new FileStreamResult(stream, mediaFile.ContentType) + { + EnableRangeProcessing = true + }; + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs new file mode 100644 index 0000000..3ef7cff --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs @@ -0,0 +1,38 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/record-sessions")] +public sealed class RecordSessionsController : ControllerBase +{ + private readonly RecordSessionService _recordSessionService; + + public RecordSessionsController(RecordSessionService recordSessionService) + { + _recordSessionService = recordSessionService; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<RecordSessionDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) => + Ok(await _recordSessionService.ListAsync(liveRoomId, cancellationToken)); + + [HttpGet("{id:guid}")] + public async Task<ActionResult<RecordSessionDetailDto>> Get(Guid id, CancellationToken cancellationToken) + { + var result = await _recordSessionService.GetDetailAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("{id:guid}/stop")] + public async Task<ActionResult<RecordSessionDto>> Stop(Guid id, CancellationToken cancellationToken) => + Ok(await _recordSessionService.StopAsync(id, cancellationToken)); + + [HttpPost("delete")] + public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete( + [FromBody] DeleteRecordSessionsRequest request, + CancellationToken cancellationToken) => + Ok(await _recordSessionService.DeleteAsync(request, cancellationToken)); +} diff --git a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs new file mode 100644 index 0000000..3f4dfd1 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs @@ -0,0 +1,64 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/record-tasks")] +public sealed class RecordTasksController : ControllerBase +{ + private readonly RecordService _recordService; + private readonly LinkGenerator _linkGenerator; + + public RecordTasksController(RecordService recordService, LinkGenerator linkGenerator) + { + _recordService = recordService; + _linkGenerator = linkGenerator; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<RecordTaskDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) => + Ok(await _recordService.ListAsync(liveRoomId, cancellationToken)); + + [HttpGet("{id:guid}")] + public async Task<ActionResult<RecordTaskDetailDto>> Get(Guid id, CancellationToken cancellationToken) + { + var result = await _recordService.GetDetailAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("start")] + public async Task<ActionResult<RecordTaskDto>> Start([FromBody] StartRecordTaskRequest request, CancellationToken cancellationToken) => + Ok(await _recordService.StartAsync(request, cancellationToken)); + + [HttpPost("{id:guid}/stop")] + public async Task<ActionResult<RecordTaskDto>> Stop(Guid id, CancellationToken cancellationToken) => + Ok(await _recordService.StopAsync(id, cancellationToken)); + + [HttpPost("delete")] + public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete( + [FromBody] DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken) => + Ok(await _recordService.DeleteTasksAsync(request, cancellationToken)); + + [HttpPost("delete-completed")] + public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteCompleted( + [FromBody] DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken) => + Ok(await _recordService.DeleteTasksAsync(request, cancellationToken)); + + [HttpPost("{id:guid}/preview-ticket")] + public async Task<ActionResult<RecordPreviewTicketDto>> CreatePreviewTicket(Guid id, CancellationToken cancellationToken) + { + var baseUrl = _linkGenerator.GetUriByAction( + HttpContext, + action: nameof(MediaController.GetRecordTaskMedia), + controller: "Media", + values: new { ticket = "placeholder" }) + ?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/placeholder"; + + var mediaBaseUrl = baseUrl[..baseUrl.LastIndexOf('/')]; + return Ok(await _recordService.CreatePreviewTicketAsync(id, mediaBaseUrl, cancellationToken)); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/SettingsController.cs b/src/LiveRecorder.WebApi/Controllers/SettingsController.cs new file mode 100644 index 0000000..01c5578 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/SettingsController.cs @@ -0,0 +1,41 @@ +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Models.Settings; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/settings")] +public sealed class SettingsController : ControllerBase +{ + private readonly ISystemSettingsService _systemSettingsService; + private readonly IEmailNotificationService _emailNotificationService; + + public SettingsController( + ISystemSettingsService systemSettingsService, + IEmailNotificationService emailNotificationService) + { + _systemSettingsService = systemSettingsService; + _emailNotificationService = emailNotificationService; + } + + [HttpGet] + public async Task<ActionResult<SystemSettingsDto>> Get(CancellationToken cancellationToken) => + Ok(await _systemSettingsService.GetAsync(cancellationToken)); + + [HttpPut] + public async Task<ActionResult<SystemSettingsDto>> Update( + [FromBody] UpdateSystemSettingsRequest request, + CancellationToken cancellationToken) => + Ok(await _systemSettingsService.UpdateAsync(request, cancellationToken)); + + [HttpPost("test-email")] + public async Task<IActionResult> SendTestEmail( + [FromBody] SendTestEmailRequest request, + CancellationToken cancellationToken) + { + await _emailNotificationService.SendTestAsync(request, cancellationToken); + return NoContent(); + } +} diff --git a/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj new file mode 100644 index 0000000..b001221 --- /dev/null +++ b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\LiveRecorder.Application\LiveRecorder.Application.csproj" /> + <ProjectReference Include="..\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + +</Project> diff --git a/src/LiveRecorder.WebApi/LiveRecorder.WebApi.http b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.http new file mode 100644 index 0000000..75abe69 --- /dev/null +++ b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.http @@ -0,0 +1,6 @@ +@LiveRecorder.WebApi_HostAddress = http://localhost:5135 + +GET {{LiveRecorder.WebApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/LiveRecorder.WebApi/Middleware/ApiTokenAuthenticationMiddleware.cs b/src/LiveRecorder.WebApi/Middleware/ApiTokenAuthenticationMiddleware.cs new file mode 100644 index 0000000..6b6aa58 --- /dev/null +++ b/src/LiveRecorder.WebApi/Middleware/ApiTokenAuthenticationMiddleware.cs @@ -0,0 +1,49 @@ +using LiveRecorder.Application.Abstractions.Auth; + +namespace LiveRecorder.WebApi.Middleware; + +public sealed class ApiTokenAuthenticationMiddleware +{ + private readonly RequestDelegate _next; + + public ApiTokenAuthenticationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IAuthService authService) + { + if (HttpMethods.IsOptions(context.Request.Method)) + { + await _next(context); + return; + } + + var path = context.Request.Path; + if (!path.StartsWithSegments("/api") || + path.StartsWithSegments("/api/auth/login")) + { + await _next(context); + return; + } + + var authorization = context.Request.Headers.Authorization.ToString(); + var token = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? authorization["Bearer ".Length..].Trim() + : string.Empty; + + var user = await authService.ValidateTokenAsync(token, context.RequestAborted); + if (user is null) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new + { + message = "Unauthorized" + }); + return; + } + + context.Items["CurrentUser"] = user; + await _next(context); + } +} diff --git a/src/LiveRecorder.WebApi/Middleware/ExceptionHandlingMiddleware.cs b/src/LiveRecorder.WebApi/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..d50fd07 --- /dev/null +++ b/src/LiveRecorder.WebApi/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,38 @@ +namespace LiveRecorder.WebApi.Middleware; + +public sealed class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger<ExceptionHandlingMiddleware> _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception"); + context.Response.StatusCode = ex switch + { + KeyNotFoundException => StatusCodes.Status404NotFound, + InvalidOperationException => StatusCodes.Status400BadRequest, + NotSupportedException => StatusCodes.Status400BadRequest, + _ => StatusCodes.Status500InternalServerError + }; + + await context.Response.WriteAsJsonAsync(new + { + message = ex.Message, + detail = context.Response.StatusCode == StatusCodes.Status500InternalServerError ? "Internal Server Error" : null + }); + } + } +} diff --git a/src/LiveRecorder.WebApi/Program.cs b/src/LiveRecorder.WebApi/Program.cs new file mode 100644 index 0000000..18d5dee --- /dev/null +++ b/src/LiveRecorder.WebApi/Program.cs @@ -0,0 +1,181 @@ +using System.Net; +using LiveRecorder.Application.Abstractions.Auth; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Services; +using LiveRecorder.Infrastructure.Persistence; +using LiveRecorder.Infrastructure.Persistence.Repositories; +using LiveRecorder.Infrastructure.Platforms.Bilibili; +using LiveRecorder.Infrastructure.Platforms.Douyin; +using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku; +using LiveRecorder.Infrastructure.Platforms.Douyin.Signing; +using LiveRecorder.Infrastructure.Platforms.Huya; +using LiveRecorder.Infrastructure.Services; +using LiveRecorder.WebApi.Middleware; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; + +var builder = WebApplication.CreateBuilder(args); +var resetRecordingData = args.Contains("--reset-recording-data", StringComparer.OrdinalIgnoreCase); +var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? ["http://localhost:5173"]; + +builder.Services.AddControllers(); +builder.Services.AddMemoryCache(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Live Recorder API", + Version = "v1", + Description = "Multi-platform live recording service" + }); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Input token as: Bearer {token}", + Name = "Authorization", + Type = SecuritySchemeType.ApiKey + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty<string>() + } + }); +}); + +builder.Services.AddCors(options => +{ + options.AddPolicy("frontend", policy => + { + policy.WithOrigins(corsOrigins) + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.AddHttpClient("douyin", client => +{ + client.Timeout = TimeSpan.FromSeconds(20); + client.DefaultRequestVersion = HttpVersion.Version11; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower; +}) +.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler +{ + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30), + MaxConnectionsPerServer = 8 +}); + +builder.Services.AddDbContext<LiveRecorderDbContext>(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>()); +builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>(); +builder.Services.AddScoped<ILiveRoomRepository, LiveRoomRepository>(); +builder.Services.AddScoped<IRecordSessionRepository, RecordSessionRepository>(); +builder.Services.AddScoped<IRecordTaskRepository, RecordTaskRepository>(); +builder.Services.AddScoped<IRecordResultRepository, RecordResultRepository>(); +builder.Services.AddScoped<ISystemLogRepository, SystemLogRepository>(); +builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>(); +builder.Services.AddScoped<IUserSessionRepository, UserSessionRepository>(); + +builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>(); +builder.Services.AddScoped<ISystemLogService, SystemLogService>(); +builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>(); +builder.Services.AddScoped<IAuthService, AuthService>(); +builder.Services.AddScoped<LiveRoomService>(); +builder.Services.AddScoped<LiveRoomStatusService>(); +builder.Services.AddScoped<RecordService>(); +builder.Services.AddScoped<RecordSessionService>(); +builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>(); +builder.Services.AddScoped<DatabaseInitializer>(); + +builder.Services.AddScoped<DouyinHttpClient>(); +builder.Services.AddSingleton<DouyinXBogusSigner>(); +builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>(); +builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>(); +builder.Services.AddScoped<ILivePlatformAdapter, HuyaLivePlatformAdapter>(); +builder.Services.AddScoped<ILivePlatformAdapterFactory, LivePlatformAdapterFactory>(); +builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>(); +builder.Services.AddScoped<ILiveDanmakuAdapterFactory, LiveDanmakuAdapterFactory>(); + +builder.Services.AddSingleton<IFfmpegService, FfmpegService>(); +builder.Services.AddScoped<IRecordMediaService, RecordMediaService>(); +builder.Services.AddHostedService<LiveRoomPollingBackgroundService>(); + +var app = builder.Build(); + +app.UseMiddleware<ExceptionHandlingMiddleware>(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseCors("frontend"); +app.UseMiddleware<ApiTokenAuthenticationMiddleware>(); + +app.MapGet("/", () => Results.Redirect("/swagger")); +app.MapControllers(); + +if (!resetRecordingData) +{ + using var scope = app.Services.CreateScope(); + var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>(); + await initializer.InitializeAsync(); +} + +if (resetRecordingData) +{ + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var before = await ReadRecordingDataCountsAsync(dbContext); + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + await dbContext.Database.ExecuteSqlRawAsync( + "DELETE FROM SystemLogEntries WHERE LiveRoomId IS NOT NULL OR RecordSessionId IS NOT NULL OR RecordTaskId IS NOT NULL;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordResults;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordTasks;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordSessions;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM LiveRooms;"); + await transaction.CommitAsync(); + } + + var after = await ReadRecordingDataCountsAsync(dbContext); + Console.WriteLine("Recording data reset complete."); + foreach (var key in before.Keys) + { + Console.WriteLine($"{key}: {before[key]} -> {after[key]}"); + } + + return; +} + +app.Run(); + +static async Task<Dictionary<string, long>> ReadRecordingDataCountsAsync(LiveRecorderDbContext dbContext) +{ + return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase) + { + ["LiveRooms"] = await dbContext.LiveRooms.LongCountAsync(), + ["RecordSessions"] = await dbContext.RecordSessions.LongCountAsync(), + ["RecordTasks"] = await dbContext.RecordTasks.LongCountAsync(), + ["RecordResults"] = await dbContext.RecordResults.LongCountAsync(), + ["SystemLogEntries(Related)"] = await dbContext.SystemLogEntries.LongCountAsync( + item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null) + }; +} diff --git a/src/LiveRecorder.WebApi/Properties/launchSettings.json b/src/LiveRecorder.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..0d07ae4 --- /dev/null +++ b/src/LiveRecorder.WebApi/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:45325", + "sslPort": 44312 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7033;http://localhost:5135", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/LiveRecorder.WebApi/appsettings.Development.json b/src/LiveRecorder.WebApi/appsettings.Development.json new file mode 100644 index 0000000..34f00ef --- /dev/null +++ b/src/LiveRecorder.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/LiveRecorder.WebApi/appsettings.json b/src/LiveRecorder.WebApi/appsettings.json new file mode 100644 index 0000000..deab313 --- /dev/null +++ b/src/LiveRecorder.WebApi/appsettings.json @@ -0,0 +1,18 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=live-recorder.db" + }, + "Cors": { + "Origins": [ + "http://localhost:5173" + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/LiveRecorder.WebApi/live-recorder.db b/src/LiveRecorder.WebApi/live-recorder.db new file mode 100644 index 0000000..f0e4ac5 Binary files /dev/null and b/src/LiveRecorder.WebApi/live-recorder.db differ diff --git a/src/LiveRecorder.WebApi/live-recorder.db-shm b/src/LiveRecorder.WebApi/live-recorder.db-shm new file mode 100644 index 0000000..eba5a66 Binary files /dev/null and b/src/LiveRecorder.WebApi/live-recorder.db-shm differ diff --git a/src/LiveRecorder.WebApi/live-recorder.db-wal b/src/LiveRecorder.WebApi/live-recorder.db-wal new file mode 100644 index 0000000..067e6a6 Binary files /dev/null and b/src/LiveRecorder.WebApi/live-recorder.db-wal differ diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-21-15/第五人格赛事/092132_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-21-15/第五人格赛事/092132_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..4a34e89 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-21-15/第五人格赛事/092132_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="96541586-d6a4-492f-b77e-8351f907f6b2" recordTaskId="16e3ee7b-775a-4306-af9f-030d3fc16c59" segmentIndex="1" startedAt="2026-04-15T09:21:32.9557018+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-40-15/第五人格赛事/084002_第五人格赛事_COA9小组赛Day6_24482384478/084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-40-15/第五人格赛事/084002_第五人格赛事_COA9小组赛Day6_24482384478/084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..dc2bef4 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-40-15/第五人格赛事/084002_第五人格赛事_COA9小组赛Day6_24482384478/084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b972a1-0fa4-4cb0-a4f4-49c1886f68b9" recordTaskId="16c513ce-20a4-4b68-b781-cbe0a3866bd3" segmentIndex="1" startedAt="2026-04-15T08:40:02.3186341+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-47-15/第五人格赛事/084717_第五人格赛事_COA9小组赛Day6_24482384478/084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-47-15/第五人格赛事/084717_第五人格赛事_COA9小组赛Day6_24482384478/084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..056e71b --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-47-15/第五人格赛事/084717_第五人格赛事_COA9小组赛Day6_24482384478/084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="8066da9f-7234-4bb9-b5a6-dbda093ec805" recordTaskId="275d15d5-06a3-4592-a2e4-213f2d33a05e" segmentIndex="1" startedAt="2026-04-15T08:47:17.0035883+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-49-15/第五人格赛事/084924_第五人格赛事_COA9小组赛Day6_24482384478/084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-49-15/第五人格赛事/084924_第五人格赛事_COA9小组赛Day6_24482384478/084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..1215b47 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-49-15/第五人格赛事/084924_第五人格赛事_COA9小组赛Day6_24482384478/084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="b1f625ae-9630-40cb-b332-0911cbf9495a" recordTaskId="31681d5f-32f1-4d3b-b67c-9c76bb262ae6" segmentIndex="1" startedAt="2026-04-15T08:49:24.7664120+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/Wd/2026-28-15/132847_Wd_Wd正在直播__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/Wd/2026-28-15/132847_Wd_Wd正在直播__00001.xml new file mode 100644 index 0000000..8fe6407 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/Wd/2026-28-15/132847_Wd_Wd正在直播__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="410202829936" liveRoomId="723ddadf-f211-488a-b3c8-af8ca26ce501" recordSessionId="6edb0432-0aad-42ca-913f-f754fe1c74a2" recordTaskId="b363a8c2-4b2f-4a66-b178-7015f2ba42bf" segmentIndex="1" startedAt="2026-04-15T13:28:47.9706852+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-05-15/100501_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-05-15/100501_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..44a6dd7 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-05-15/100501_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="710beae4-aaab-49b3-b290-cb92e8cfbf73" recordTaskId="662d9dd1-a52c-49d3-939f-acb9047118ba" segmentIndex="1" startedAt="2026-04-15T10:05:01.8366164+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/061914_第五人格赛事_COA9小组赛Day7__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/061914_第五人格赛事_COA9小组赛Day7__00001.xml new file mode 100644 index 0000000..ca53097 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/061914_第五人格赛事_COA9小组赛Day7__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="a332ebc7-1ec8-479d-be4e-ae9bd0e3b492" recordTaskId="f809528f-50f0-4294-9898-2c78077e89c6" segmentIndex="1" startedAt="2026-04-16T06:19:14.1248014+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/071935_第五人格赛事_COA9小组赛Day7__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/071935_第五人格赛事_COA9小组赛Day7__00001.xml new file mode 100644 index 0000000..4068c43 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/071935_第五人格赛事_COA9小组赛Day7__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="adb12db2-cb13-4220-b674-066034168179" recordTaskId="234cabf8-2b21-44e3-90e4-3eb3f972f84b" segmentIndex="1" startedAt="2026-04-16T07:19:35.5837509+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-22-15/092236_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-22-15/092236_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..53cc141 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-22-15/092236_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="4339a2f9-7971-49c3-a862-51c40b5f863a" recordTaskId="e13a9515-a3b2-4926-91e8-ab5c950451f6" segmentIndex="1" startedAt="2026-04-15T09:22:36.1844012+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-23-15/092339_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-23-15/092339_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..c78cd97 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-23-15/092339_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b25554-b0c4-401c-b609-154900b3e7ae" recordTaskId="10b39d85-ff00-411b-8aa3-1eebf8f211b2" segmentIndex="1" startedAt="2026-04-15T09:23:39.5759339+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-49-15/114940_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-49-15/114940_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..6e2309b --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-49-15/114940_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="bcde0a6f-36f5-49e6-9793-123771a6a073" recordTaskId="782b119c-ea9e-4854-a19e-178699feb5ca" segmentIndex="1" startedAt="2026-04-15T11:49:40.6314631+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-58-15/115804_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-58-15/115804_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..1a42839 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-58-15/115804_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="bbcc8489-a89b-43e9-8cef-36d8a2aa25de" recordSessionId="f0ffa9e6-8200-4605-8005-badc2a025939" recordTaskId="d8131983-8ea8-484e-b306-f4086861154b" segmentIndex="1" startedAt="2026-04-15T11:58:04.5702834+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/豆浆(S邦邦)/2026-00-16/030005_豆浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/豆浆(S邦邦)/2026-00-16/030005_豆浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml new file mode 100644 index 0000000..b21a14e --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/豆浆(S邦邦)/2026-00-16/030005_豆浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="386835971310" liveRoomId="082677d1-f76c-4983-aeeb-3deb2cb91bca" recordSessionId="bd9b2b8f-739e-4be6-b6f7-358388925065" recordTaskId="8b3eb0b0-598a-415d-a2c0-f7d390ed805c" segmentIndex="1" startedAt="2026-04-16T03:00:05.1545652+00:00"> +</i>