feat: 将本地独有功能移植到 upstream 最新版本

基于 upstream/master (c5922f9) 新建分支,移植以下功能:
1. 扫码登录(DyQrCodeService + ConfigController 3个接口)
2. 视频预览组件(VideoPlayer.vue)
3. 下载配置服务(DownloadConfigService + AppConfig 6个扩展字段)
4. Gitea CI/CD(.gitea/workflows/deploy.yml)
5. 生产 Dockerfile(Dockerfile.prod,含中国镜像源配置)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yuming
2026-07-02 20:58:08 +08:00
parent c5922f9b08
commit 30060fe29c
9 changed files with 1086 additions and 1 deletions
+47
View File
@@ -0,0 +1,47 @@
name: 部署到群晖
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 安装 Docker CLI
run: |
# 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
apt-get update -qq
apt-get install -y -qq docker.io
- name: 拉取代码
uses: actions/checkout@v4
- name: 构建镜像
run: docker build -f Dockerfile.prod -t dysync:latest .
- name: 停止旧容器
run: |
docker stop dysync 2>/dev/null || true
docker rm dysync 2>/dev/null || true
- name: 启动新容器
run: |
docker run -d \
--name dysync \
--restart unless-stopped \
-p 10101:10101 \
-e TZ=Asia/Shanghai \
-e ASPNETCORE_ENVIRONMENT=Production \
-v /volume1/docker/apps/dysync/db:/app/db \
-v /volume1/docker/apps/dysync/logs:/app/logs \
-v /volume2/save/dysync/collect:/app/collect \
-v /volume2/save/dysync/favorite:/app/favorite \
-v /volume2/save/dysync/uper:/app/uper \
dysync:latest
- name: 部署完成提示
run: |
echo "✅ 部署完成,浏览器访问 http://192.168.1.66:10101 查看"
+63 -1
View File
@@ -25,10 +25,11 @@ namespace dy.net.Controllers
private readonly DouyinFollowService douyinFollowService; private readonly DouyinFollowService douyinFollowService;
private readonly DouyinCookieService douyinCookieService; private readonly DouyinCookieService douyinCookieService;
private readonly DouyinHttpClientService httpClientService; private readonly DouyinHttpClientService httpClientService;
private readonly DyQrCodeService _qrCodeService;
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService) public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService, DyQrCodeService qrCodeService)
{ {
this.dyCookieService = dyCookieService; this.dyCookieService = dyCookieService;
this.commonService = commonService; this.commonService = commonService;
@@ -36,6 +37,7 @@ namespace dy.net.Controllers
this.douyinFollowService = douyinFollowService; this.douyinFollowService = douyinFollowService;
this.douyinCookieService = douyinCookieService; this.douyinCookieService = douyinCookieService;
this.httpClientService = httpClientService; this.httpClientService = httpClientService;
_qrCodeService = qrCodeService;
} }
@@ -484,5 +486,65 @@ namespace dy.net.Controllers
return ApiResult.Fail("文件不存在"); return ApiResult.Fail("文件不存在");
} }
} }
/// <summary>
/// 获取抖音扫码登录二维码
/// </summary>
[AllowAnonymous]
[HttpGet("qrcode")]
public async Task<IActionResult> GetQrCode()
{
try
{
var (token, qrBase64) = await _qrCodeService.GetQrCodeAsync();
return Ok(new { code = 0, data = new { token, qrBase64 } });
}
catch (Exception ex)
{
return Ok(new { code = -1, error = ex.Message });
}
}
/// <summary>
/// 轮询扫码状态(前端每2秒调一次)
/// status: 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期
/// </summary>
[AllowAnonymous]
[HttpGet("qrstatus")]
public async Task<IActionResult> GetQrStatus([FromQuery] string token)
{
try
{
var (status, cookies, secUserId) = await _qrCodeService.CheckStatusAsync(token);
return Ok(new { code = 0, data = new { status, cookies, secUserId } });
}
catch (Exception ex)
{
Serilog.Log.Error($"查询扫码状态失败: {ex.Message}");
return Ok(new { code = -1, error = ex.Message });
}
}
/// <summary>
/// 扫码成功后,将 Cookie 绑定到指定账号
/// </summary>
[HttpPost("qrbind")]
public async Task<IActionResult> BindQrCode([FromQuery] string token, [FromQuery] string id)
{
try
{
var (cookies, secUserId) = _qrCodeService.GetSessionResult(token);
if (string.IsNullOrEmpty(cookies))
return Ok(new { code = -1, error = "扫码尚未完成或已过期" });
var result = await douyinCookieService.FastResetCookie(id, cookies);
_qrCodeService.RemoveSession(token);
return Ok(new { code = result ? 0 : -1, secUserId });
}
catch (Exception ex)
{
return Ok(new { code = -1, error = ex.Message });
}
}
} }
} }
+86
View File
@@ -0,0 +1,86 @@
# 多阶段构建 Dockerfile
# 第一阶段:构建前端
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
COPY app/package.json app/pnpm-lock.yaml* ./
RUN npm config set registry https://registry.npmmirror.com && \
npm install -g pnpm && \
pnpm config set registry https://registry.npmmirror.com && \
if [ -f pnpm-lock.yaml ]; then \
pnpm install --frozen-lockfile; \
else \
npm install; \
fi
COPY app/ ./
RUN pnpm build || npm run build
# 第二阶段:构建后端 + 下载 Playwright Chromium
FROM mcr.azurecr.cn/dotnet/sdk:6.0 AS backend-builder
WORKDIR /src
# 安装 Node.js 18(用于 npx playwright install,最可靠的方式)
RUN apt-get update && apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
COPY *.csproj ./
RUN dotnet restore dy.net.csproj
COPY . .
RUN find . -name "._*" -type f -delete
COPY --from=frontend-builder /app/frontend/dist ./app/dist
RUN dotnet publish dy.net.csproj -c Release -o /app/publish
# 用 npx 下载 Playwright Chromium 到固定路径
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npx -y playwright@1.44.0 install chromium
# 第三阶段:运行镜像
FROM mcr.azurecr.cn/dotnet/aspnet:6.0 AS final
WORKDIR /app
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 切换阿里云镜像源,解决国内网络无法访问 deb.debian.org 的问题
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list
# 安装 Chromium 运行时依赖(Debian Bullseye)
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgtk-3-0 \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
COPY --from=backend-builder /app/publish .
COPY --from=backend-builder /ms-playwright /ms-playwright
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
EXPOSE 10101
ENV ASPNETCORE_URLS=http://*:10101
ENV ASPNETCORE_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "dy.net.dll"]
+580
View File
@@ -0,0 +1,580 @@
<template>
<a-modal
v-model:visible="visible"
:width="900"
:footer="null"
:maskClosable="false"
@cancel="handleClose"
class="video-player-modal"
:destroyOnClose="true"
>
<template #title>
<div class="modal-title-wrapper">
<span class="modal-title-text">{{ modalTitle }}</span>
</div>
</template>
<div class="video-player-container" v-if="currentVideoId">
<!-- 视频信息 -->
<div class="video-info" v-if="currentVideoInfo">
<div class="info-row">
<span class="label">作者:</span>
<span class="value">{{ currentVideoInfo.author || '未知' }}</span>
</div>
<div class="info-row">
<span class="label">分类:</span>
<span class="value">{{ currentVideoInfo.viedoCate || '未分类' }}</span>
</div>
<div class="info-row">
<span class="label">分辨率:</span>
<span class="value">{{ currentVideoInfo.resolution || '未知' }}</span>
</div>
<div class="info-row">
<span class="label">文件大小:</span>
<span class="value">{{ formatFileSize(currentVideoInfo.fileSize || 0) }}</span>
</div>
</div>
<!-- 视频播放器 -->
<div class="video-wrapper" v-if="currentVideoId">
<video
ref="videoPlayer"
:src="videoUrl"
controls
preload="auto"
class="video-element"
@error="handleVideoError"
@loadstart="handleLoadStart"
@loadedmetadata="handleLoadedMetadata"
@canplay="handleCanPlay"
@canplaythrough="handleCanPlayThrough"
@progress="handleProgress"
@waiting="handleWaiting"
@playing="handlePlaying"
>
您的浏览器不支持视频播放
</video>
</div>
<div v-else class="video-wrapper">
<a-empty description="视频信息加载中..." />
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<a-spin size="large" tip="加载视频中..." />
</div>
<!-- 错误提示 -->
<div v-if="error" class="error-message">
<a-alert
:message="error"
type="error"
show-icon
closable
@close="error = ''"
/>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch, onUnmounted } from 'vue';
import { message } from 'ant-design-vue';
import Cookie from 'js-cookie';
interface VideoInfo {
id: string;
videoTitle: string;
author: string;
viedoCate: string;
resolution: string;
fileSize: number;
videoSavePath?: string;
}
const props = defineProps<{
videoId?: string;
videoInfo?: VideoInfo;
}>();
const emit = defineEmits<{
close: [];
}>();
const visible = ref(false);
const loading = ref(false);
const error = ref('');
const videoPlayer = ref<HTMLVideoElement | null>(null);
const currentVideoId = ref<string>('');
const currentVideoInfo = ref<VideoInfo | null>(null);
const videoDirectUrl = ref<string>('');
// 视频URL - 直接使用流式 URL,让浏览器处理 Range 请求
const videoUrl = computed(() => videoDirectUrl.value);
// 标题(完整显示,支持多行)
const modalTitle = computed(() => {
return currentVideoInfo.value?.videoTitle || '视频预览';
});
// 设置视频流 URL,让浏览器直接处理 Range 请求实现流式播放
const loadVideoStream = async (videoId: string) => {
try {
loading.value = true;
error.value = '';
// 构建视频流 URL,浏览器会自动发送 Range 请求
// 添加时间戳避免缓存问题
const url = `/api/video/stream/${videoId}?t=${Date.now()}`;
console.log('[VideoPlayer] 设置视频流 URL:', url);
// 直接使用 URL,让浏览器处理 Range 请求
// 浏览器会自动发送 Range 请求头,实现边加载边播放
videoDirectUrl.value = url;
// 确保 video 元素存在,并且等待 DOM 更新
await new Promise(resolve => setTimeout(resolve, 100));
if (videoPlayer.value) {
// 先清空 src,避免使用旧的 URL
videoPlayer.value.src = '';
// 等待一下,确保清空操作完成
await new Promise(resolve => setTimeout(resolve, 50));
// 设置新的视频 URL
videoPlayer.value.src = videoDirectUrl.value;
console.log('[VideoPlayer] 视频 src 已设置:', videoDirectUrl.value);
// 加载视频元数据(不等待完整下载)
// 浏览器会自动发送 Range 请求,只加载必要的部分
videoPlayer.value.load();
console.log('[VideoPlayer] 调用 video.load(),开始加载视频');
// 视频元素会自动处理 Range 请求,实现流式播放
// 当有足够的数据可以播放时(通常只需要几秒的数据),浏览器就可以开始播放
// 不需要等待整个文件下载完成
// 监听网络状态变化,用于调试
const checkNetworkState = () => {
const networkState = videoPlayer.value?.networkState;
const readyState = videoPlayer.value?.readyState;
const buffered = videoPlayer.value?.buffered;
console.log('[VideoPlayer] 网络状态:', {
networkState,
readyState,
bufferedLength: buffered?.length || 0,
bufferedRanges: buffered?.length ? Array.from({ length: buffered.length }, (_, i) => ({
start: buffered.start(i),
end: buffered.end(i)
})) : []
});
};
// 定期检查网络状态(用于调试)
const networkCheckInterval = setInterval(() => {
if (videoPlayer.value && loading.value) {
checkNetworkState();
} else {
clearInterval(networkCheckInterval);
}
}, 1000);
// 5秒后停止检查
setTimeout(() => {
clearInterval(networkCheckInterval);
}, 5000);
}
} catch (err: any) {
console.error('[VideoPlayer] 设置视频流 URL 失败:', err);
loading.value = false;
// 根据错误类型设置错误消息
if (err.message) {
error.value = err.message;
} else {
error.value = '视频加载失败,请稍后重试';
}
// 清空 URL
videoDirectUrl.value = '';
}
};
// 打开播放器
const open = async (id?: string, info?: VideoInfo) => {
console.log('VideoPlayer.open called', { id, info });
if (!id && !info?.id) {
message.error('视频ID不能为空');
return;
}
const videoId = id || info!.id;
const videoInfo = info || {
id: videoId,
videoTitle: '视频预览',
author: '未知',
viedoCate: '未分类',
resolution: '未知',
fileSize: 0,
};
console.log('设置视频信息', { videoId, videoInfo });
// 先清空之前的状态
if (videoPlayer.value) {
videoPlayer.value.pause();
videoPlayer.value.src = '';
}
videoDirectUrl.value = '';
error.value = '';
loading.value = true;
// 设置视频信息
currentVideoId.value = videoId;
currentVideoInfo.value = videoInfo;
// 先显示模态框
visible.value = true;
// 等待 DOM 渲染完成后再加载视频流
await new Promise(resolve => setTimeout(resolve, 100));
// 加载视频流
await loadVideoStream(videoId);
};
// 关闭播放器
const handleClose = () => {
visible.value = false;
if (videoPlayer.value) {
videoPlayer.value.pause();
videoPlayer.value.src = '';
}
videoDirectUrl.value = '';
loading.value = false;
error.value = '';
currentVideoId.value = '';
currentVideoInfo.value = null;
emit('close');
};
// 视频加载开始
const handleLoadStart = () => {
loading.value = true;
error.value = '';
console.log('[VideoPlayer] loadstart 事件触发');
};
// 视频元数据加载完成
const handleLoadedMetadata = () => {
const video = videoPlayer.value;
if (video) {
console.log('[VideoPlayer] loadedmetadata 事件触发', {
duration: video.duration,
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
readyState: video.readyState,
networkState: video.networkState
});
}
// 不在这里关闭 loading,等待 canplay 事件
};
// 视频可以开始播放(有足够的数据)
const handleCanPlay = () => {
const video = videoPlayer.value;
loading.value = false;
if (video) {
console.log('[VideoPlayer] canplay 事件触发 - 视频可以开始播放', {
duration: video.duration,
readyState: video.readyState,
networkState: video.networkState,
bufferedLength: video.buffered?.length || 0
});
}
// 如果视频还没有播放,可以尝试自动播放(需要用户交互)
if (video && video.paused) {
// 注意:自动播放可能被浏览器阻止,需要用户交互
// 这里不自动播放,让用户点击播放按钮
}
};
// 视频可以流畅播放(有足够的数据缓冲)
const handleCanPlayThrough = () => {
const video = videoPlayer.value;
loading.value = false;
if (video) {
console.log('[VideoPlayer] canplaythrough 事件触发 - 视频可以流畅播放', {
duration: video.duration,
readyState: video.readyState,
networkState: video.networkState,
bufferedLength: video.buffered?.length || 0
});
}
};
// 视频加载进度
const handleProgress = () => {
const video = videoPlayer.value;
if (video && video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
const bufferedPercent = video.duration > 0 ? (bufferedEnd / video.duration) * 100 : 0;
console.log('[VideoPlayer] progress 事件 - 缓冲进度:', {
bufferedPercent: bufferedPercent.toFixed(2) + '%',
bufferedEnd,
duration: video.duration,
readyState: video.readyState
});
// 如果已经有足够的数据可以播放,关闭加载状态
if (bufferedEnd > 0 && video.readyState >= 2) {
loading.value = false;
}
}
};
// 视频等待数据
const handleWaiting = () => {
console.log('[VideoPlayer] waiting 事件 - 视频等待数据');
loading.value = true;
};
// 视频开始播放
const handlePlaying = () => {
console.log('[VideoPlayer] playing 事件 - 视频开始播放');
loading.value = false;
};
// 视频加载错误
const handleVideoError = (e: Event) => {
const video = e.target as HTMLVideoElement;
// 如果 src 为空或正在加载中,忽略错误(可能是时序问题)
if (!video.src || !videoDirectUrl.value || video.networkState === 0) {
console.log('忽略视频加载错误(可能是时序问题):', {
src: video.src,
directUrl: videoDirectUrl.value,
networkState: video.networkState,
});
return;
}
console.error('视频加载错误详情:', {
error: video.error,
errorCode: video.error?.code,
errorMessage: video.error?.message,
networkState: video.networkState,
readyState: video.readyState,
src: video.src,
directUrl: videoDirectUrl.value,
});
loading.value = false;
if (video.error) {
switch (video.error.code) {
case 1: // MEDIA_ERR_ABORTED
error.value = '视频加载被中止';
break;
case 2: // MEDIA_ERR_NETWORK
// 网络错误可能是认证失败(401)或其他网络问题
// 检查是否是认证问题
if (video.networkState === 3) { // NETWORK_NO_SOURCE
// 可能是认证失败,尝试提示用户
error.value = '视频加载失败,可能是认证问题。请检查是否已登录,或刷新页面后重试';
} else {
error.value = '网络错误,请检查网络连接';
}
break;
case 3: // MEDIA_ERR_DECODE
error.value = '视频解码失败,可能是视频格式问题或文件损坏';
break;
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
// 尝试重新加载视频
if (videoDirectUrl.value && video.src === videoDirectUrl.value) {
console.log('尝试重新加载视频...');
setTimeout(() => {
if (videoPlayer.value && videoDirectUrl.value) {
videoPlayer.value.load();
}
}, 500);
return; // 不显示错误,等待重试
}
error.value = '视频格式不支持。请检查:1) 视频文件是否完整 2) 浏览器是否支持该格式 3) 文件扩展名是否正确';
break;
default:
error.value = `视频加载失败 (错误代码: ${video.error.code})`;
}
} else {
error.value = '视频加载失败,请检查网络连接和视频文件';
}
};
// 监听视频加载完成
watch(
() => videoPlayer.value?.readyState,
(readyState) => {
if (readyState && readyState >= 2) {
// HAVE_CURRENT_DATA 或更高
loading.value = false;
}
}
);
// 组件卸载时清理
onUnmounted(() => {
if (videoPlayer.value) {
videoPlayer.value.pause();
videoPlayer.value.src = '';
}
videoDirectUrl.value = '';
});
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
// 暴露方法
defineExpose({
open,
close: handleClose,
});
</script>
<style scoped lang="less">
.video-player-modal {
:deep(.ant-modal-body) {
padding: 20px;
}
// 确保标题区域不遮挡关闭按钮,支持多行显示
:deep(.ant-modal-header) {
position: relative;
padding-right: 56px !important; // 为关闭按钮留出足够空间
min-height: 55px;
padding-bottom: 16px; // 增加底部内边距,为多行标题留空间
.ant-modal-title {
padding: 0 !important;
margin: 0 !important;
}
}
// 自定义标题样式
.modal-title-wrapper {
width: 100%;
max-width: calc(100% - 56px); // 减去关闭按钮的宽度
padding-right: 8px;
box-sizing: border-box;
}
.modal-title-text {
display: block;
overflow: visible;
word-wrap: break-word;
word-break: break-all;
white-space: normal;
line-height: 1.5;
max-height: 80px;
overflow-y: auto;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
font-size: 16px;
}
// 确保关闭按钮始终可见且在最上层
:deep(.ant-modal-close) {
position: absolute !important;
right: 0 !important;
top: 0 !important;
z-index: 1000 !important;
width: 56px !important;
height: 55px !important;
line-height: 55px !important;
text-align: center !important;
cursor: pointer;
border: none;
background: transparent;
margin: 0 !important;
padding: 0 !important;
&:hover {
background-color: rgba(0, 0, 0, 0.06);
}
.ant-modal-close-x {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.video-player-container {
position: relative;
}
.video-info {
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
.info-row {
display: inline-block;
margin-right: 24px;
margin-bottom: 8px;
.label {
font-weight: 500;
color: #666;
}
.value {
color: #333;
}
}
}
.video-wrapper {
position: relative;
width: 100%;
background: #000;
border-radius: 4px;
overflow: hidden;
.video-element {
width: 100%;
height: auto;
max-height: 70vh;
display: block;
}
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 10;
}
.error-message {
margin-top: 16px;
}
</style>
+1
View File
@@ -76,6 +76,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" /> <PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" /> <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="Microsoft.Playwright" Version="1.44.0" />
<!--<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />--> <!--<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />-->
</ItemGroup> </ItemGroup>
+3
View File
@@ -309,6 +309,9 @@ namespace dy.net.extension
}); });
services.AddScoped<DouyinQuartzJobService>(); services.AddScoped<DouyinQuartzJobService>();
// 扫码登录服务(Playwright,全局单例避免重复初始化)
services.AddSingleton<DyQrCodeService>();
services.AddScoped<DownloadConfigService>();
} }
/// <summary> /// <summary>
+32
View File
@@ -120,6 +120,38 @@ namespace dy.net.model.entity
/// </summary> /// </summary>
[SugarColumn(IsNullable = true)] [SugarColumn(IsNullable = true)]
public bool CloseNfo { get; set; } public bool CloseNfo { get; set; }
// -------- 下载配置扩展字段(本地新增)--------
/// <summary>
/// 是否启用自动下载
/// </summary>
[SugarColumn(IsNullable = true)]
public bool? EnableAutoDownload { get; set; }
/// <summary>
/// 是否下载喜欢视频(videoType=1)
/// </summary>
[SugarColumn(IsNullable = true)]
public bool? DownloadFavorite { get; set; }
/// <summary>
/// 是否下载收藏视频(videoType=2)
/// </summary>
[SugarColumn(IsNullable = true)]
public bool? DownloadCollect { get; set; }
/// <summary>
/// 下载时间范围(天),0 表示不限
/// </summary>
[SugarColumn(IsNullable = true)]
public int? DownloadDaysRange { get; set; }
/// <summary>
/// 作者白名单(JSON 数组字符串)
/// </summary>
[SugarColumn(Length = 2000, IsNullable = true)]
public string AuthorWhitelist { get; set; }
/// <summary>
/// 作者黑名单(JSON 数组字符串)
/// </summary>
[SugarColumn(Length = 2000, IsNullable = true)]
public string AuthorBlacklist { get; set; }
} }
} }
+121
View File
@@ -0,0 +1,121 @@
using dy.net.model.entity;
using Newtonsoft.Json;
namespace dy.net.service
{
/// <summary>
/// 下载配置服务,用于判断是否应该下载视频
/// </summary>
public class DownloadConfigService
{
private readonly DouyinCommonService _commonService;
public DownloadConfigService(DouyinCommonService commonService)
{
_commonService = commonService;
}
private AppConfig GetConfig()
{
return _commonService.GetConfig();
}
/// <summary>
/// 判断是否应该下载视频
/// </summary>
/// <param name="videoType">视频类型:"1"=喜欢,"2"=收藏</param>
/// <param name="authorId">作者ID</param>
/// <param name="authorName">作者名称</param>
/// <param name="createTime">视频创建时间</param>
/// <returns>true=应该下载,false=不应该下载</returns>
public bool ShouldDownload(string videoType, string authorId, string authorName, DateTime createTime)
{
var config = GetConfig();
if (config == null)
{
return false;
}
// 如果未启用自动下载,直接返回false
if (config.EnableAutoDownload != true)
{
return false;
}
// 检查视频类型是否允许下载
if (videoType == "1" && config.DownloadFavorite != true)
{
return false; // 喜欢类型但未启用下载
}
if (videoType == "2" && config.DownloadCollect != true)
{
return false; // 收藏类型但未启用下载
}
// 检查时间范围
if (config.DownloadDaysRange.HasValue && config.DownloadDaysRange.Value > 0)
{
var daysDiff = (DateTime.Now - createTime).TotalDays;
if (daysDiff > config.DownloadDaysRange.Value)
{
return false; // 超出时间范围
}
}
// 检查作者白名单
if (!string.IsNullOrWhiteSpace(config.AuthorWhitelist))
{
try
{
var whitelist = JsonConvert.DeserializeObject<List<string>>(config.AuthorWhitelist);
if (whitelist != null && whitelist.Any())
{
// 如果配置了白名单,只有白名单中的作者才下载
var isInWhitelist = whitelist.Any(a =>
(!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase))
);
if (!isInWhitelist)
{
return false; // 不在白名单中
}
}
}
catch (Exception ex)
{
Serilog.Log.Warning($"解析作者白名单失败:{ex.Message}");
}
}
// 检查作者黑名单
if (!string.IsNullOrWhiteSpace(config.AuthorBlacklist))
{
try
{
var blacklist = JsonConvert.DeserializeObject<List<string>>(config.AuthorBlacklist);
if (blacklist != null && blacklist.Any())
{
// 如果配置了黑名单,黑名单中的作者不下载
var isInBlacklist = blacklist.Any(a =>
(!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase))
);
if (isInBlacklist)
{
return false; // 在黑名单中
}
}
}
catch (Exception ex)
{
Serilog.Log.Warning($"解析作者黑名单失败:{ex.Message}");
}
}
// 所有检查都通过,可以下载
return true;
}
}
}
+153
View File
@@ -0,0 +1,153 @@
using System.Collections.Concurrent;
using Microsoft.Playwright;
namespace dy.net.service
{
public class DyQrCodeService
{
private static readonly ConcurrentDictionary<string, QrSession> _sessions = new();
private class QrSession
{
public IPlaywright Playwright { get; set; }
public IBrowser Browser { get; set; }
public IBrowserContext Context { get; set; }
public IPage Page { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.Now;
public string FinalCookies { get; set; }
public string SecUserId { get; set; }
}
private const string LoginUrl = "https://creator.douyin.com/creator-micro/login";
// QR 码图片的 class 名含 qrcode_img(后缀随版本变化,用属性选择器匹配)
private const string QrSelector = "img[class*='qrcode_img']";
public async Task<(string token, string qrBase64)> GetQrCodeAsync()
{
CleanExpiredSessions();
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage" }
});
var context = await browser.NewContextAsync(new BrowserNewContextOptions
{
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
});
var page = await context.NewPageAsync();
await page.GotoAsync(LoginUrl, new PageGotoOptions { Timeout = 15000 });
await page.WaitForSelectorAsync(QrSelector, new PageWaitForSelectorOptions { Timeout = 10000 });
// img.src 已经是 data:image/png;base64,... 直接取用
var src = await page.EvaluateAsync<string>($"() => document.querySelector(\"{QrSelector}\").src");
var qrBase64 = src.Replace("data:image/png;base64,", "");
var token = Guid.NewGuid().ToString("N");
_sessions[token] = new QrSession
{
Playwright = playwright,
Browser = browser,
Context = context,
Page = page
};
return (token, qrBase64);
}
public async Task<(int status, string cookies, string secUserId)> CheckStatusAsync(string token)
{
if (!_sessions.TryGetValue(token, out var session))
return (-1, null, null);
if (DateTime.Now - session.CreatedAt > TimeSpan.FromMinutes(5))
{
await CloseSessionAsync(token, session);
return (-1, null, null);
}
// 已经登录过,直接返回缓存
if (session.FinalCookies != null)
return (3, session.FinalCookies, session.SecUserId);
var url = session.Page.Url;
// 还在登录页
if (url.Contains("/creator-micro/login") || url.Contains("sso.douyin.com"))
{
// 判断是否已扫码(页面出现"确认"或"已扫码"文字)
var scanned = await session.Page.EvaluateAsync<bool>(
"() => { const t = document.body.innerText; return t.includes('已扫码') || t.includes('确认登录'); }"
);
return (scanned ? 2 : 1, null, null);
}
// URL 已离开登录页,说明登录成功
var browserCookies = await session.Context.CookiesAsync(new[]
{
"https://creator.douyin.com",
"https://www.douyin.com",
"https://sso.douyin.com"
});
var cookieStr = string.Join("; ", browserCookies.Select(c => $"{c.Name}={c.Value}"));
// 尝试从页面获取 sec_user_id
var secUserId = await GetSecUserIdAsync(session.Page);
session.FinalCookies = cookieStr;
session.SecUserId = secUserId;
// 登录完成后关闭浏览器释放资源
_ = Task.Run(async () => await CloseSessionAsync(token, session));
return (3, cookieStr, secUserId);
}
public (string cookies, string secUserId) GetSessionResult(string token)
{
if (_sessions.TryGetValue(token, out var session))
return (session.FinalCookies ?? "", session.SecUserId ?? "");
return ("", "");
}
public void RemoveSession(string token)
{
if (_sessions.TryRemove(token, out var session))
_ = Task.Run(async () => await CloseSessionAsync(token, session));
}
private static async Task<string> GetSecUserIdAsync(IPage page)
{
try
{
// 从页面 URL 或 API 提取 sec_user_id
var resp = await page.EvaluateAsync<string>(
"async () => { const r = await fetch('/web/api/userinfo/'); const j = await r.json(); return j?.data?.sec_uid || j?.sec_uid || ''; }"
);
return resp ?? "";
}
catch
{
return "";
}
}
private static async Task CloseSessionAsync(string token, QrSession session)
{
_sessions.TryRemove(token, out _);
try { await session.Browser?.CloseAsync(); } catch { }
try { session.Playwright?.Dispose(); } catch { }
}
private static void CleanExpiredSessions()
{
var expired = _sessions
.Where(kv => DateTime.Now - kv.Value.CreatedAt > TimeSpan.FromMinutes(6))
.ToList();
foreach (var kv in expired)
_ = Task.Run(async () => await CloseSessionAsync(kv.Key, kv.Value));
}
}
}