This commit is contained in:
lz_db
2026-10-17 19:04:05 +08:00
parent 28b11b4365
commit 1253c159f5
43 changed files with 2652 additions and 114 deletions
+5
View File
@@ -15,3 +15,8 @@ export function getUserDetail(userId) {
export function setUserStatus(userId, data) { export function setUserStatus(userId, data) {
return request.put(`/admin/users/${userId}/status`, data) return request.put(`/admin/users/${userId}/status`, data)
} }
/** 后台重置用户密码(敏感) { new_password, reason } */
export function resetUserPassword(userId, data) {
return request.put(`/admin/users/${userId}/password`, data)
}
+1
View File
@@ -71,6 +71,7 @@ const menuConfig = [
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] }, { title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] }, { title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] }, { title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
{ title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] },
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] }, { title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] }, { title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] }, { title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
+6
View File
@@ -58,6 +58,12 @@ const routes = [
component: () => import('@/views/SeriesManage.vue'), component: () => import('@/views/SeriesManage.vue'),
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] }, meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
}, },
{
path: 'collect-sources',
name: 'collect-sources',
component: () => import('@/views/CollectSources.vue'),
meta: { title: '采集源管理', icon: 'Download', roles: [] },
},
{ {
path: 'categories', path: 'categories',
name: 'category-manage', name: 'category-manage',
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="collect-sources">
<el-card shadow="never">
<template #header>
<div class="header">
<span>采集源管理</span>
<div>
<el-button :loading="syncAllLoading" @click="syncAll">全部同步</el-button>
<el-button @click="batchVisible = true">批量添加</el-button>
<el-button type="primary" @click="showCreate">+ 添加采集源</el-button>
</div>
</div>
</template>
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 <code v-pre>{{page}}</code> 表示页码占位。
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。
</el-alert>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="分类名" width="130" />
<el-table-column prop="list_url" label="列表 URL" min-width="320" show-overflow-tooltip />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="160">
<template #default="{ row }">{{ row.last_sync_at || '从未' }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="warning" :loading="syncingId === row.id" @click="syncOne(row)">同步</el-button>
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button link type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="batchVisible" title="批量添加采集源" width="640px">
<el-alert type="info" :closable="false" style="margin-bottom: 10px">
每行一条,格式:<code v-pre>分类名 URL</code>(URL 可带反引号)。提交后自动创建分类并立即开始采集。
</el-alert>
<el-input v-model="batchText" type="textarea" :rows="10"
:placeholder="'每日更新 https://mdcmai4.xyz/api/v1/videos?categoryId=27&page={{page}}&size=60\n清纯少女 https://mdcmai4.xyz/api/v1/videos?categoryId=13&page={{page}}&size=60'" />
<div v-if="batchResult" style="margin-top: 10px; font-size: 13px">
<div v-if="batchResult.created.length" style="color: #67c23a">
已创建 {{ batchResult.created.length }} 个并启动采集:{{ batchResult.created.map(c => c.name).join('、') }}
</div>
<div v-if="batchResult.skipped.length" style="color: #e6a23c">
跳过 {{ batchResult.skipped.length }} 条(URL 已存在):{{ batchResult.skipped.map(s => s.name).join('、') }}
</div>
<div v-if="batchResult.failed.length" style="color: #f56c6c">
失败 {{ batchResult.failed.length }} 条:{{ batchResult.failed.map(f => f.line).join(';') }}
</div>
</div>
<template #footer>
<el-button @click="batchVisible = false">关闭</el-button>
<el-button type="primary" :loading="batchSubmitting" @click="submitBatch">提交</el-button>
</template>
</el-dialog>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑采集源' : '添加采集源'" width="560px">
<el-form :model="form" label-width="90px">
<el-form-item label="分类名">
<el-input v-model="form.name" maxlength="50" placeholder="如:每日更新(不存在会自动创建分类)" />
</el-form-item>
<el-form-item label="列表 URL">
<el-input v-model="form.list_url" type="textarea" :rows="3"
placeholder="https://example.com/api/v1/videos?categoryId=27&page={{page}}&size=60" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/api/request'
const list = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const syncingId = ref(0)
const syncAllLoading = ref(false)
const form = reactive({ name: '', list_url: '' })
const batchVisible = ref(false)
const batchText = ref('')
const batchSubmitting = ref(false)
const batchResult = ref(null)
async function submitBatch() {
if (!batchText.value.trim()) { ElMessage.warning('请粘贴内容'); return }
batchSubmitting.value = true
batchResult.value = null
try {
const r = await request({ url: '/admin/collection/sources/batch', method: 'post', data: { text: batchText.value } })
batchResult.value = r
if (r.created.length) { batchText.value = ''; setTimeout(load, 3000) }
else load()
} catch (e) {} finally {
batchSubmitting.value = false
}
}
onMounted(load)
async function load() {
loading.value = true
try {
const r = await request({ url: '/admin/collection/sources', method: 'get' })
list.value = r.list
} catch (e) {} finally {
loading.value = false
}
}
function showCreate() {
editing.value = null
Object.assign(form, { name: '', list_url: '' })
dialogVisible.value = true
}
function showEdit(row) {
editing.value = row
Object.assign(form, { name: row.name, list_url: row.list_url })
dialogVisible.value = true
}
async function save() {
if (!form.name || !form.list_url) { ElMessage.warning('请填写完整'); return }
if (!form.list_url.includes('{{page}}')) { ElMessage.warning('URL 需包含 {{page}} 页码占位符'); return }
submitting.value = true
try {
if (editing.value) {
await request({ url: `/admin/collection/sources/${editing.value.id}`, method: 'put', data: form })
ElMessage.success('已更新')
} else {
await request({ url: '/admin/collection/sources', method: 'post', data: form })
ElMessage.success('已添加,点击"同步"开始采集')
}
dialogVisible.value = false
load()
} catch (e) {} finally {
submitting.value = false
}
}
async function toggleStatus(row) {
try {
await request({
url: `/admin/collection/sources/${row.id}`, method: 'put',
data: { status: row.status === 1 ? 0 : 1 },
})
ElMessage.success('已更新')
load()
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(
`确认删除采集源「${row.name}」?已采集的视频不会被删除。`, '警告', { type: 'warning' }
)
try {
await request({ url: `/admin/collection/sources/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) {}
}
async function syncOne(row) {
syncingId.value = row.id
try {
await request({ url: `/admin/collection/sources/${row.id}/sync`, method: 'post', data: { max_pages: 0 } })
ElMessage.success(`「${row.name}」已触发后台同步,稍后刷新查看`)
setTimeout(load, 20000)
} catch (e) {} finally {
syncingId.value = 0
}
}
async function syncAll() {
syncAllLoading.value = true
try {
const r = await request({ url: '/admin/collection/sources/sync-all', method: 'post' })
ElMessage.success(`已触发 ${r.started.length} 个源的后台同步`)
setTimeout(load, 20000)
} catch (e) {} finally {
syncAllLoading.value = false
}
}
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
</style>
+34 -1
View File
@@ -7,6 +7,7 @@
<div> <div>
<el-input v-model="keyword" placeholder="搜索系列标题" clearable <el-input v-model="keyword" placeholder="搜索系列标题" clearable
style="width: 200px; margin-right: 8px" @keyup.enter="load" /> style="width: 200px; margin-right: 8px" @keyup.enter="load" />
<el-button :loading="syncAllLoading" @click="syncAll">同步新剧</el-button>
<el-button type="primary" @click="showCreate">+ 新建系列</el-button> <el-button type="primary" @click="showCreate">+ 新建系列</el-button>
</div> </div>
</div> </div>
@@ -23,6 +24,12 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="total_episodes" label="集数" width="90" /> <el-table-column prop="total_episodes" label="集数" width="90" />
<el-table-column label="来源" width="80">
<template #default="{ row }">
<el-tag v-if="row.source_id" size="small" type="warning">采集</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80"> <el-table-column label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small"> <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
@@ -31,9 +38,11 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" /> <el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="showEdit(row)">编辑</el-button> <el-button link type="primary" @click="showEdit(row)">编辑</el-button>
<el-button v-if="row.source_id" link type="warning"
:loading="syncingId === row.id" @click="syncOne(row)">更新</el-button>
<el-button link type="primary" @click="toggleStatus(row)"> <el-button link type="primary" @click="toggleStatus(row)">
{{ row.status === 1 ? '隐藏' : '恢复' }} {{ row.status === 1 ? '隐藏' : '恢复' }}
</el-button> </el-button>
@@ -116,6 +125,8 @@ const dialogVisible = ref(false)
const editing = ref(null) const editing = ref(null)
const submitting = ref(false) const submitting = ref(false)
const coverUploading = ref(false) const coverUploading = ref(false)
const syncAllLoading = ref(false)
const syncingId = ref(0)
const form = reactive({ const form = reactive({
title: '', cover_url: '', description: '', category_id: 0, title: '', cover_url: '', description: '', category_id: 0,
@@ -224,6 +235,28 @@ async function del(row) {
load() load()
} catch (e) {} } catch (e) {}
} }
async function syncOne(row) {
syncingId.value = row.id
try {
const r = await request({ url: `/admin/collection/sync/${row.id}`, method: 'post' })
ElMessage.success(`同步完成:新增 ${r.episodes_created} 集,已存在 ${r.episodes_skipped} 集`)
load()
} catch (e) {} finally {
syncingId.value = 0
}
}
async function syncAll() {
syncAllLoading.value = true
try {
await request({ url: '/admin/collection/sync-all', method: 'post', data: { pages: 2 } })
ElMessage.success('已触发后台同步,稍后刷新查看')
setTimeout(load, 15000)
} catch (e) {} finally {
syncAllLoading.value = false
}
}
</script> </script>
<style scoped> <style scoped>
+86 -3
View File
@@ -48,9 +48,18 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="注册时间" width="170" /> <el-table-column prop="created_at" label="注册时间" width="170" />
<el-table-column label="操作" width="170" fixed="right"> <el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" :icon="View" @click="toDetail(row)">详情</el-button> <el-button link type="primary" :icon="View" @click="toDetail(row)">详情</el-button>
<el-button
v-if="row.status !== 3"
link
type="warning"
:icon="Key"
@click="openResetPwd(row)"
>
改密
</el-button>
<el-button <el-button
v-if="row.status === 1" v-if="row.status === 1"
link link
@@ -84,16 +93,52 @@
@current-change="load" @current-change="load"
/> />
</div> </div>
<!-- 重置密码弹窗 -->
<el-dialog v-model="pwdDialog.visible" title="重置用户密码" width="420px">
<el-form label-width="80px">
<el-form-item label="用户">
<span>{{ pwdDialog.nickname }} #{{ pwdDialog.userId }}</span>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="pwdDialog.newPassword"
type="password"
show-password
placeholder="6-100 位"
maxlength="100"
/>
</el-form-item>
<el-form-item label="操作原因">
<el-input
v-model="pwdDialog.reason"
type="textarea"
:rows="2"
placeholder="必填,将记录操作日志"
maxlength="200"
/>
</el-form-item>
</el-form>
<div style="color: var(--el-color-warning); font-size: 12px; margin-top: -8px">
重置后该用户所有端登录态立即失效,需用新密码重新登录
</div>
<template #footer>
<el-button @click="pwdDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="pwdDialog.submitting" @click="submitResetPwd">
确定
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Lock, Refresh, Search, Unlock, View } from '@element-plus/icons-vue' import { Key, Lock, Refresh, Search, Unlock, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { listUsers, setUserStatus } from '@/api/users' import { listUsers, resetUserPassword, setUserStatus } from '@/api/users'
import { askReason } from '@/utils/reason' import { askReason } from '@/utils/reason'
const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' } const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' }
@@ -154,5 +199,43 @@ async function toggleStatus(row, target) {
load() load()
} }
const pwdDialog = reactive({
visible: false,
userId: 0,
nickname: '',
newPassword: '',
reason: '',
submitting: false,
})
function openResetPwd(row) {
pwdDialog.userId = row.user_id
pwdDialog.nickname = row.nickname || ''
pwdDialog.newPassword = ''
pwdDialog.reason = ''
pwdDialog.visible = true
}
async function submitResetPwd() {
const pwd = pwdDialog.newPassword.trim()
const reason = pwdDialog.reason.trim()
if (pwd.length < 6 || pwd.length > 100) {
ElMessage.warning('新密码长度需为 6-100 位')
return
}
if (!reason) {
ElMessage.warning('请填写操作原因')
return
}
pwdDialog.submitting = true
try {
await resetUserPassword(pwdDialog.userId, { new_password: pwd, reason })
ElMessage.success('密码已重置,该用户全端登录态已失效')
pwdDialog.visible = false
} finally {
pwdDialog.submitting = false
}
}
onMounted(load) onMounted(load)
</script> </script>
+25 -3
View File
@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// 正式签名配置:从 android/key.properties 读取(该文件与 keystore 均已 gitignore,不入库)
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.jsnbhh.video_app" namespace = "com.jsnbhh.video_app"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -30,11 +40,23 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // key.properties 存在时用正式签名;否则回退 debug(保证 CI/新环境能编译)
// Signing with the debug keys for now, so `flutter run --release` works. signingConfig = if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("debug") signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
} }
} }
} }
+304
View File
@@ -0,0 +1,304 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../../models/video_item.dart';
import 'local_media_server.dart';
/// 已下载视频元数据(持久化在 downloads/<videoId>/meta.json)
class DownloadItem {
DownloadItem({
required this.videoId,
required this.title,
required this.coverUrl,
required this.duration,
required this.sizeBytes,
required this.mp4,
required this.createdAt,
});
final int videoId;
final String title;
final String coverUrl;
final int duration; // 秒
final int sizeBytes;
final bool mp4;
final int createdAt; // 毫秒时间戳
/// 本地播放地址(经 LocalMediaServer /local 路由,支持 Range)
String get playUrl =>
LocalMediaServer.I.localPlayUrl(videoId, mp4: mp4);
Map<String, dynamic> toJson() => {
'video_id': videoId,
'title': title,
'cover_url': coverUrl,
'duration': duration,
'size_bytes': sizeBytes,
'mp4': mp4,
'created_at': createdAt,
};
factory DownloadItem.fromJson(Map<String, dynamic> j) => DownloadItem(
videoId: j['video_id'] as int,
title: (j['title'] as String?) ?? '',
coverUrl: (j['cover_url'] as String?) ?? '',
duration: (j['duration'] as num?)?.toInt() ?? 0,
sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0,
mp4: j['mp4'] as bool? ?? false,
createdAt: (j['created_at'] as num?)?.toInt() ?? 0,
);
}
/// 下载任务状态机:value 为 0..1 进度;done/error 用独立集合
class DownloadsState {
const DownloadsState({
this.progress = const {},
this.errors = const {},
this.items = const [],
});
final Map<int, double> progress; // 下载中
final Map<int, String> errors; // 失败信息
final List<DownloadItem> items; // 已完成(按下载时间倒序)
DownloadsState copyWith({
Map<int, double>? progress,
Map<int, String>? errors,
List<DownloadItem>? items,
}) =>
DownloadsState(
progress: progress ?? this.progress,
errors: errors ?? this.errors,
items: items ?? this.items,
);
}
class DownloadManager extends StateNotifier<DownloadsState> {
DownloadManager() : super(const DownloadsState());
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 10),
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
},
));
final Map<int, CancelToken> _cancel = {};
bool isDownloaded(int videoId) =>
state.items.any((e) => e.videoId == videoId);
bool isDownloading(int videoId) => state.progress.containsKey(videoId);
/// 启动时扫描下载目录恢复列表
Future<void> init() async {
final dir = LocalMediaServer.I.downloadsDir;
final items = <DownloadItem>[];
await for (final e in dir.list()) {
if (e is! Directory) continue;
final meta = File(p.join(e.path, 'meta.json'));
if (!await meta.exists()) continue;
try {
items.add(DownloadItem.fromJson(
jsonDecode(await meta.readAsString()) as Map<String, dynamic>));
} catch (_) {}
}
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
state = state.copyWith(items: items);
}
/// 开始下载(已在下载/已下载则忽略)
Future<void> start(VideoItem item) async {
if (isDownloading(item.videoId) || isDownloaded(item.videoId)) return;
final dir = Directory(p.join(
LocalMediaServer.I.downloadsDir.path, '${item.videoId}'));
await dir.create(recursive: true);
final cancel = CancelToken();
_cancel[item.videoId] = cancel;
_setProgress(item.videoId, 0);
try {
final isMp4 = item.videoUrl.contains('.mp4') &&
!item.videoUrl.contains('m3u8');
int size;
if (isMp4) {
size = await _downloadMp4(item.videoUrl, dir, item.videoId, cancel);
} else {
size = await _downloadHls(item.videoUrl, dir, item.videoId, cancel);
}
final meta = DownloadItem(
videoId: item.videoId,
title: item.title,
coverUrl: item.coverUrl,
duration: item.duration,
sizeBytes: size,
mp4: isMp4,
createdAt: DateTime.now().millisecondsSinceEpoch,
);
await File(p.join(dir.path, 'meta.json'))
.writeAsString(jsonEncode(meta.toJson()));
final progress = Map<int, double>.from(state.progress)
..remove(item.videoId);
state = state.copyWith(
progress: progress,
items: [meta, ...state.items],
);
} catch (e) {
// 失败/取消:清掉半成品目录
try {
await dir.delete(recursive: true);
} catch (_) {}
final progress = Map<int, double>.from(state.progress)
..remove(item.videoId);
if (e is DioException && CancelToken.isCancel(e)) {
state = state.copyWith(progress: progress);
} else {
final errors = Map<int, String>.from(state.errors)
..[item.videoId] = '下载失败,请重试';
state = state.copyWith(progress: progress, errors: errors);
}
rethrow;
} finally {
_cancel.remove(item.videoId);
}
}
void cancel(int videoId) => _cancel[videoId]?.cancel();
Future<void> delete(int videoId) async {
_cancel[videoId]?.cancel();
final dir = Directory(
p.join(LocalMediaServer.I.downloadsDir.path, '$videoId'));
try {
await dir.delete(recursive: true);
} catch (_) {}
state = state.copyWith(
items: state.items.where((e) => e.videoId != videoId).toList(),
);
}
// ---------- MP4 ----------
Future<int> _downloadMp4(
String url, Directory dir, int videoId, CancelToken cancel) async {
final file = File(p.join(dir.path, 'video.mp4'));
await _dio.download(
url,
file.path,
cancelToken: cancel,
onReceiveProgress: (r, t) {
if (t > 0) _setProgress(videoId, r / t);
},
);
return file.length();
}
// ---------- HLS ----------
/// 拉取 m3u8 → 下载 key + 全部 ts → 改写为本地相对引用
Future<int> _downloadHls(
String url, Directory dir, int videoId, CancelToken cancel) async {
var playlist = (await _dio.get<String>(url, cancelToken: cancel)).data ?? '';
var base = Uri.parse(url);
// master playlist(多变体):取第一个变体再拉一次
if (playlist.contains('#EXT-X-STREAM-INF')) {
final variant = const LineSplitter()
.convert(playlist)
.firstWhere((l) => l.trim().isNotEmpty && !l.startsWith('#'),
orElse: () => '');
if (variant.isEmpty) throw Exception('空变体列表');
final variantUrl = base.resolve(variant.trim()).toString();
playlist =
(await _dio.get<String>(variantUrl, cancelToken: cancel)).data ?? '';
base = Uri.parse(variantUrl);
}
final lines = const LineSplitter().convert(playlist);
final segUrls = <Uri>[
for (final l in lines)
if (l.trim().isNotEmpty && !l.trim().startsWith('#'))
base.resolve(l.trim()),
];
if (segUrls.isEmpty) throw Exception('无分片');
var total = 0;
var done = 0;
// key(可能多个,逐标签改写时按出现顺序对应)
final keyUris = <String>[];
for (final l in lines) {
for (final m in RegExp(r'URI="([^"]+)"').allMatches(l)) {
final abs = base.resolve(m.group(1)!).toString();
if (!keyUris.contains(abs)) keyUris.add(abs);
}
}
final keyNames = <String, String>{};
for (var i = 0; i < keyUris.length; i++) {
final name = i == 0 ? 'key.bin' : 'key_$i.bin';
final bytes = (await _dio.get<List<int>>(keyUris[i],
cancelToken: cancel,
options: Options(responseType: ResponseType.bytes)))
.data;
if (bytes == null || bytes.isEmpty) throw Exception('密钥下载失败');
await File(p.join(dir.path, name)).writeAsBytes(bytes);
keyNames[keyUris[i]] = name;
total += bytes.length;
}
// ts 分片:4 并发
const concurrency = 4;
for (var i = 0; i < segUrls.length; i += concurrency) {
final batch = segUrls.sublist(
i, (i + concurrency).clamp(0, segUrls.length));
final results = await Future.wait(batch.map((u) => _dio.get<List<int>>(
u.toString(),
cancelToken: cancel,
options: Options(responseType: ResponseType.bytes))));
for (var j = 0; j < batch.length; j++) {
final bytes = results[j].data;
if (bytes == null || bytes.isEmpty) throw Exception('分片下载失败');
await File(p.join(dir.path, 'seg_${i + j}.ts')).writeAsBytes(bytes);
total += bytes.length;
done++;
}
_setProgress(videoId, done / segUrls.length);
}
// 改写 playlist 为本地相对引用
var segIndex = 0;
final out = StringBuffer();
for (final raw in lines) {
final line = raw.trim();
if (line.isEmpty) continue;
if (line.startsWith('#')) {
out.writeln(line.replaceAllMapped(RegExp(r'URI="([^"]+)"'), (m) {
final abs = base.resolve(m.group(1)!).toString();
return 'URI="${keyNames[abs] ?? m.group(1)!}"';
}));
} else {
out.writeln('seg_${segIndex++}.ts');
}
}
await File(p.join(dir.path, 'index.m3u8')).writeAsString(out.toString());
return total;
}
void _setProgress(int videoId, double value) {
final progress = Map<int, double>.from(state.progress);
progress[videoId] = value;
state = state.copyWith(progress: progress);
}
}
final downloadManagerProvider =
StateNotifierProvider<DownloadManager, DownloadsState>(
(ref) => DownloadManager());
+303
View File
@@ -0,0 +1,303 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
/// 本地媒体服务(127.0.0.1 环回 HTTP):
///
/// 1. HLS 分片缓存(1G LRU):播放器请求经 /hls 改写 m3u8 内的 ts/key 绝对
/// 地址到 /seg,/seg 命中磁盘缓存直接返回,未命中回源并落盘 —— 回看不下载。
/// 注意:ts 链接带时效签名,缓存键取「去 query 的路径 sha1」,m3u8 每次回源。
/// 2. 已下载视频离线播放:/local/<videoId>/<file> 直接读下载目录(支持 Range)。
///
/// 只有 m3u8 走代理;mp4 直连(库里仅个位数 mp4,不做缓存)。
class LocalMediaServer {
LocalMediaServer._();
static final LocalMediaServer I = LocalMediaServer._();
static const int _cacheLimit = 1024 * 1024 * 1024; // 1G
static const int _evictTo = 900 * 1024 * 1024; // 清理到 900M(滞后防抖)
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 5),
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
},
));
bool _started = false;
late int _port;
late Directory cacheDir; // <temp>/hls_cache(系统可清理,适合缓存语义)
late Directory downloadsDir; // <appSupport>/downloads(持久)
int _cacheBytes = 0;
int _writesSinceEvictCheck = 0;
/// 同一分片并发回源去重(playlist 内可能重复引用 / 多槽位同时 open)
final Map<String, Future<List<int>>> _inflight = {};
Future<void> init() async {
if (_started) return;
final tmp = await getTemporaryDirectory();
cacheDir = Directory(p.join(tmp.path, 'hls_cache'));
await cacheDir.create(recursive: true);
final support = await getApplicationSupportDirectory();
downloadsDir = Directory(p.join(support.path, 'downloads'));
await downloadsDir.create(recursive: true);
_cacheBytes = await _dirSize(cacheDir);
final router = Router()
..get('/hls', _handlePlaylist)
..get('/seg', _handleSegment)
..get('/local/<vid>/<name>', _handleLocal);
final server = await shelf_io.serve(
const shelf.Pipeline().addHandler(router.call),
InternetAddress.loopbackIPv4,
0,
);
_port = server.port;
_started = true;
}
/// 播放地址改写:网络 m3u8 → 本地缓存代理;其余(本地地址/mp4/空)原样返回
String wrap(String url) {
if (!_started || url.isEmpty) return url;
final u = Uri.tryParse(url);
if (u == null || (u.scheme != 'http' && u.scheme != 'https')) return url;
if (u.host == '127.0.0.1' || u.host == 'localhost') return url;
if (url.contains('m3u8')) {
return 'http://127.0.0.1:$_port/hls?src=${Uri.encodeComponent(url)}';
}
return url;
}
/// 已下载视频的本地播放地址
String localPlayUrl(int videoId, {bool mp4 = false}) =>
'http://127.0.0.1:$_port/local/$videoId/${mp4 ? 'video.mp4' : 'index.m3u8'}';
// ---------- /hls:回源拉 m3u8,改写分片地址到 /seg ----------
Future<shelf.Response> _handlePlaylist(shelf.Request req) async {
final src = req.url.queryParameters['src'];
if (src == null || src.isEmpty) {
return shelf.Response(400, body: 'missing src');
}
try {
final resp = await _dio.get<String>(src);
final text = resp.data ?? '';
if (!text.trimLeft().startsWith('#EXTM3U')) {
return shelf.Response(502, body: 'bad playlist');
}
return _bytesResponse(
req,
utf8.encode(_rewritePlaylist(text, Uri.parse(src))),
'application/vnd.apple.mpegurl',
noCache: true,
);
} catch (e) {
return shelf.Response(502, body: 'upstream error: $e');
}
}
String _segProxyUrl(Uri absolute) =>
'http://127.0.0.1:$_port/seg?u=${Uri.encodeComponent(absolute.toString())}';
String _rewritePlaylist(String text, Uri base) {
final out = StringBuffer();
for (final rawLine in const LineSplitter().convert(text)) {
final line = rawLine.trim();
if (line.isEmpty) continue;
if (line.startsWith('#')) {
// 改写标签内的 URI="..."(EXT-X-KEY / EXT-X-MAP 等)
out.writeln(line.replaceAllMapped(
RegExp(r'URI="([^"]+)"'),
(m) => 'URI="${_segProxyUrl(base.resolve(m.group(1)!))}"',
));
} else {
out.writeln(_segProxyUrl(base.resolve(line)));
}
}
return out.toString();
}
// ---------- /seg:分片缓存(键 = 去签名路径 sha1)----------
/// 统一字节响应:始终声明 Accept-Ranges,带 Range 头则返回 206 切片。
/// (ffmpeg/mpv 依据 Accept-Ranges 判断流可拖动,否则按直播处理:
/// duration=0 → 进度条不走、无法 seek)
shelf.Response _bytesResponse(
shelf.Request req, List<int> bytes, String contentType,
{bool noCache = false}) {
final headers = <String, String>{
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
if (noCache) 'Cache-Control': 'no-cache' else 'Cache-Control': 'max-age=31536000',
};
final range = req.headers['range'];
if (range != null && range.startsWith('bytes=')) {
final m = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(range);
if (m != null) {
final start = int.parse(m.group(1)!);
if (start < bytes.length) {
final endStr = m.group(2) ?? '';
final end = endStr.isEmpty
? bytes.length - 1
: int.parse(endStr).clamp(0, bytes.length - 1);
return shelf.Response(206,
body: bytes.sublist(start, end + 1),
headers: {
...headers,
'Content-Range': 'bytes $start-$end/${bytes.length}',
});
}
}
}
return shelf.Response.ok(bytes, headers: headers);
}
String _cacheKeyFor(Uri u) {
final stripped = Uri(scheme: u.scheme, host: u.host, port: u.port, path: u.path);
return sha1.convert(utf8.encode(stripped.toString())).toString();
}
Future<shelf.Response> _handleSegment(shelf.Request req) async {
final raw = req.url.queryParameters['u'];
if (raw == null || raw.isEmpty) return shelf.Response(400, body: 'missing u');
final u = Uri.tryParse(raw);
if (u == null) return shelf.Response(400, body: 'bad u');
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
if (await file.exists()) {
// 触活:LRU 以 mtime 排序
await file.setLastModified(DateTime.now());
return _bytesResponse(req, await file.readAsBytes(), _contentType(u.path));
}
try {
final bytes = await (_inflight[raw] ??= _fetch(u));
return _bytesResponse(req, bytes, _contentType(u.path));
} catch (e) {
return shelf.Response(502, body: 'fetch error: $e');
} finally {
unawaited(Future(() async {
await Future<void>.delayed(const Duration(seconds: 30));
_inflight.remove(raw);
}));
}
}
Future<List<int>> _fetch(Uri u) async {
final resp = await _dio.get<List<int>>(
u.toString(),
options: Options(responseType: ResponseType.bytes),
);
final bytes = resp.data ?? const <int>[];
if (bytes.isEmpty) throw Exception('empty body');
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
// tmp 名唯一化:不同签名的同一分片并发回源时避免写同一个临时文件
final tmp = File(
'${file.path}.${DateTime.now().microsecondsSinceEpoch}.tmp');
await tmp.writeAsBytes(bytes, flush: true);
await tmp.rename(file.path);
_cacheBytes += bytes.length;
_maybeEvict();
return bytes;
}
/// LRU 清理:超 1G 时按 mtime 从旧到新删到 900M(每 20 次写入最多全扫一次)
void _maybeEvict() {
if (_cacheBytes <= _cacheLimit) return;
if (++_writesSinceEvictCheck < 20) return;
_writesSinceEvictCheck = 0;
unawaited(Future(() async {
final entries = <File>[];
await for (final e in cacheDir.list()) {
if (e is File) entries.add(e);
}
entries.sort((a, b) =>
a.lastModifiedSync().compareTo(b.lastModifiedSync()));
for (final f in entries) {
if (_cacheBytes <= _evictTo) break;
try {
final len = await f.length();
await f.delete();
_cacheBytes -= len;
} catch (_) {}
}
}));
}
// ---------- /local:已下载内容(Range 支持 mp4 拖动)----------
Future<shelf.Response> _handleLocal(
shelf.Request req, String vid, String name) async {
// 防路径穿越
if (name.contains('/') || name.contains('..') || vid.contains('..')) {
return shelf.Response(400, body: 'bad path');
}
final file = File(p.join(downloadsDir.path, vid, name));
if (!await file.exists()) return shelf.Response.notFound('gone');
final total = await file.length();
final range = req.headers['range'];
if (range != null && range.startsWith('bytes=')) {
final m = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(range);
if (m != null) {
final start = int.tryParse(m.group(1) ?? '') ?? 0;
final endStr = m.group(2) ?? '';
final end = endStr.isEmpty
? total - 1
: (int.tryParse(endStr) ?? total - 1).clamp(0, total - 1);
final raf = await file.open();
try {
await raf.setPosition(start);
final bytes = await raf.read(end - start + 1);
return shelf.Response(206,
body: bytes,
headers: {
'Content-Type': _contentType(name),
'Content-Range': 'bytes $start-$end/$total',
'Accept-Ranges': 'bytes',
'Content-Length': '${bytes.length}',
});
} finally {
await raf.close();
}
}
}
return shelf.Response.ok(await file.readAsBytes(), headers: {
'Content-Type': _contentType(name),
'Accept-Ranges': 'bytes',
});
}
String _contentType(String path) {
if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
if (path.endsWith('.ts')) return 'video/mp2t';
if (path.endsWith('.mp4')) return 'video/mp4';
return 'application/octet-stream';
}
Future<int> _dirSize(Directory dir) async {
var total = 0;
await for (final e in dir.list()) {
if (e is File) {
try {
total += await e.length();
} catch (_) {}
}
}
return total;
}
}
+40 -37
View File
@@ -199,49 +199,52 @@ class _ErrorInterceptor extends Interceptor {
final apiError = err.error; final apiError = err.error;
final status = err.response?.statusCode; final status = err.response?.statusCode;
// 401:静默刷新重试一次 // 401:静默刷新重试一次;无法刷新(无 refresh / 刷新被拒 / 重试仍 401)
// 统一按会话失效处理,否则 APP 会卡在"本地有 user 但 access 已过期"的
// 僵尸登录态:我的页正常显示资料卡,点会员/钱包等全部 401 加载失败
if (status == 401 && if (status == 401 &&
err.requestOptions.extra['retried'] != true && err.requestOptions.extra['retried'] != true) {
TokenStore.refreshToken != null) { if (TokenStore.refreshToken != null) {
String? newToken; String? newToken;
try {
newToken = await _refreshToken();
} on ApiException catch (refreshErr) {
// 刷新请求本身网络失败:不清登录态,透传错误
return handler.next(DioException(
requestOptions: err.requestOptions,
error: refreshErr,
));
}
if (newToken != null) {
try { try {
final opts = err.requestOptions; newToken = await _refreshToken();
opts.extra['retried'] = true; } on ApiException catch (refreshErr) {
opts.headers['Authorization'] = 'Bearer $newToken'; // 刷新请求本身网络失败:不清登录态,透传错误
final resp = await ApiClient._dio.fetch<dynamic>(opts);
return handler.resolve(resp);
} on DioException catch (retryErr) {
final retryApi = retryErr.error;
final retryStatus = retryErr.response?.statusCode;
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
final stillAuthFailed = retryStatus == 401 ||
(retryApi is ApiException && retryApi.isUnauthorized);
if (!stillAuthFailed) {
return handler.next(DioException(
requestOptions: err.requestOptions,
error: retryApi is ApiException
? retryApi
: ApiException(-1, '网络异常,请稍后重试'),
));
}
} catch (_) {
// 非 Dio 失败:视为网络异常,不清登录态
return handler.next(DioException( return handler.next(DioException(
requestOptions: err.requestOptions, requestOptions: err.requestOptions,
error: ApiException(-1, '网络异常,请稍后重试'), error: refreshErr,
)); ));
} }
if (newToken != null) {
try {
final opts = err.requestOptions;
opts.extra['retried'] = true;
opts.headers['Authorization'] = 'Bearer $newToken';
final resp = await ApiClient._dio.fetch<dynamic>(opts);
return handler.resolve(resp);
} on DioException catch (retryErr) {
final retryApi = retryErr.error;
final retryStatus = retryErr.response?.statusCode;
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
final stillAuthFailed = retryStatus == 401 ||
(retryApi is ApiException && retryApi.isUnauthorized);
if (!stillAuthFailed) {
return handler.next(DioException(
requestOptions: err.requestOptions,
error: retryApi is ApiException
? retryApi
: ApiException(-1, '网络异常,请稍后重试'),
));
}
} catch (_) {
// 非 Dio 失败:视为网络异常,不清登录态
return handler.next(DioException(
requestOptions: err.requestOptions,
error: ApiException(-1, '网络异常,请稍后重试'),
));
}
}
} }
await TokenStore.clear(); await TokenStore.clear();
ApiClient.onSessionExpired?.call(); ApiClient.onSessionExpired?.call();
+86 -10
View File
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import '../media/local_media_server.dart';
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频 /// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
class PlayerSlot { class PlayerSlot {
PlayerSlot(this.player); PlayerSlot(this.player);
@@ -44,6 +46,20 @@ class FeedPlayerPool {
int _currentIndex = 0; int _currentIndex = 0;
int get currentIndex => _currentIndex; int get currentIndex => _currentIndex;
/// 代际计数器:每次 bindWindow 自增,await 恢复后校验,过期调用直接退出。
/// 快速连划时多个 bindWindow 并发 await open,无互斥会导致旧调用恢复后
/// 误 play 装有旧媒体的槽位(幽灵声音来源)。
int _generation = 0;
/// 是否挂起(切出首页 Tab / App 退后台):挂起期间 bindWindow 的 open
/// 完成后不允许自动 play —— 否则「先暂停、open 后完成又 play」的竞态
/// 会让视频在后台继续出声(切到"我的"听到同一视频声音的根因)
bool _suspended = false;
/// 最近一次 bindWindow(resume 时需等在途 open 完成再 play,避免 play 被
/// open(play:false) 内部的 pause 覆盖导致恢复后不播)
Future<void>? _pendingBind;
/// 初始化(创建 3 槽位并订阅各自进度流) /// 初始化(创建 3 槽位并订阅各自进度流)
void init() { void init() {
for (var i = 0; i < slotCount; i++) { for (var i = 0; i < slotCount; i++) {
@@ -65,26 +81,40 @@ class FeedPlayerPool {
bool get isCurrentPlaying => currentSlot.player.state.playing; bool get isCurrentPlaying => currentSlot.player.state.playing;
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源 /// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async { ///
/// 窗口策略 [index, index+1]:只保留当前页(播放)和下一页(预缓冲)。
/// 上一页槽位 stop 清空:一是满足"划走即停止缓存下载"(pause 不停止缓冲,
/// stop 才断开),二是 stop 后槽内无媒体,即使误 play 也不会出声(防幽灵)。
Future<void> bindWindow(int index, List<({int videoId, String url})> items) {
final fut = _bindWindow(index, items);
_pendingBind = fut;
return fut;
}
Future<void> _bindWindow(
int index, List<({int videoId, String url})> items) async {
final gen = ++_generation;
// 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它) // 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它)
_currentIndex = index; _currentIndex = index;
// 窗口内应保留的槽位集合 [index-1, index, index+1] // 窗口内应保留的槽位集合 [index, index+1]
final activeSet = <int>{}; final activeSet = <int>{};
for (var offset = -1; offset <= 1; offset++) { for (var offset = 0; offset <= 1; offset++) {
final i = index + offset; final i = index + offset;
if (i >= 0 && i < items.length) activeSet.add(slotOf(i)); if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
} }
// 2. 立即静音(同步执行,不等后续网络 open): // 2. 立即静音(同步执行,不等后续网络 open):
// - 出窗槽位:stop(断音 + 清缓存),并复位 // - 出窗槽位:stop(断音 + 断缓存 + 清媒体),并复位
// - 窗口内槽位:全部 pause(含刚划走的上一页,防止切页期间串音) // - 窗口内非当前槽位:pause(下一页预缓冲不自动播)
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声 // 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
for (var s = 0; s < slotCount; s++) { for (var s = 0; s < slotCount; s++) {
final slot = _slots[s]; final slot = _slots[s];
if (slot.videoId < 0) continue; if (slot.videoId < 0) continue;
if (activeSet.contains(s)) { if (activeSet.contains(s)) {
try { slot.player.pause(); } catch (_) {} if (s != slotOf(index)) {
try { slot.player.pause(); } catch (_) {}
}
} else { } else {
unawaited(slot.player.stop()); unawaited(slot.player.stop());
slot.videoId = -1; slot.videoId = -1;
@@ -93,7 +123,7 @@ class FeedPlayerPool {
} }
// 3. 窗口内槽位按需加载(open 完才 play) // 3. 窗口内槽位按需加载(open 完才 play)
for (var offset = -1; offset <= 1; offset++) { for (var offset = 0; offset <= 1; offset++) {
final i = index + offset; final i = index + offset;
if (i < 0 || i >= items.length) continue; if (i < 0 || i >= items.length) continue;
final slot = _slots[slotOf(i)]; final slot = _slots[slotOf(i)];
@@ -102,14 +132,44 @@ class FeedPlayerPool {
slot.videoId = item.videoId; slot.videoId = item.videoId;
slot.completeNotified = false; slot.completeNotified = false;
if (item.url.isNotEmpty) { if (item.url.isNotEmpty) {
await slot.player.open(Media(item.url), play: false); // 经本地代理:HLS 分片 1G LRU 缓存,回看命中缓存不再下载
await slot.player.open(Media(LocalMediaServer.I.wrap(item.url)), play: false);
// await 期间用户可能又翻页:本次绑定已过期,退出(最新绑定会接管)
if (gen != _generation) return;
} }
} }
// 4. 当前槽:播放 // 4. 当前槽:播放(播前再校验代际与挂起态,防过期调用/后台挂起时唤醒媒体)
if (gen != _generation || _suspended) return;
currentSlot.player.play(); currentSlot.player.play();
} }
/// 挂起(切出首页底部 Tab / App 退后台 / 切走当前策略):
/// 立即暂停当前槽,并阻止在途 bindWindow 的 open 完成后自动播放
void suspend() {
_suspended = true;
try {
currentSlot.player.pause();
} catch (_) {}
}
/// 恢复(切回首页 / App 回前台 / 策略切回):解除挂起;若 bind 仍在 open,
/// 等其完成后再 play,避免 play 被 open(play:false) 的内部 pause 覆盖
Future<void> resume() async {
_suspended = false;
final pending = _pendingBind;
if (pending != null) {
try {
await pending;
} catch (_) {}
}
if (!_suspended) {
try {
currentSlot.player.play();
} catch (_) {}
}
}
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调 /// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
void _hookSlot(PlayerSlot slot) { void _hookSlot(PlayerSlot slot) {
var lastPosition = Duration.zero; var lastPosition = Duration.zero;
@@ -135,7 +195,7 @@ class FeedPlayerPool {
} }
} }
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来) // 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
if (slot == currentSlot) { if (slot == currentSlot && !_suspended) {
slot.player.seek(Duration.zero); slot.player.seek(Duration.zero);
slot.player.play(); slot.player.play();
} }
@@ -155,6 +215,22 @@ class FeedPlayerPool {
} }
} }
/// 全部停止并清空(进入全屏播放器前调用):停音 + 断缓存 + 清媒体。
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
void stopAll() {
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
_generation++;
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
_suspended = false;
for (final slot in _slots) {
if (slot.videoId < 0) continue;
unawaited(slot.player.stop());
slot.videoId = -1;
slot.completeNotified = false;
}
}
/// 释放全部资源(Feed 页 dispose 时调用) /// 释放全部资源(Feed 页 dispose 时调用)
Future<void> dispose() async { Future<void> dispose() async {
for (final sub in _subs) { for (final sub in _subs) {
+27 -1
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'core/media/download_manager.dart';
import 'core/media/local_media_server.dart';
import 'core/storage/local_store.dart'; import 'core/storage/local_store.dart';
import 'core/storage/search_history_store.dart'; import 'core/storage/search_history_store.dart';
import 'core/storage/watch_dedup_store.dart'; import 'core/storage/watch_dedup_store.dart';
@@ -22,8 +24,32 @@ Future<void> main() async {
await SearchHistoryStore.init(); await SearchHistoryStore.init();
// 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效) // 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效)
await WatchDedupStore.init(); await WatchDedupStore.init();
// 本地媒体服务:HLS 1G 分片缓存(回看免下载)+ 已下载视频离线播放
await LocalMediaServer.I.init();
runApp(const ProviderScope(child: VideoApp())); runApp(ProviderScope(
overrides: const [],
child: const _AppInitializer(),
));
}
/// 初始化需要 Provider 环境的单例(下载管理器扫描本地已下载列表)
class _AppInitializer extends ConsumerStatefulWidget {
const _AppInitializer();
@override
ConsumerState<_AppInitializer> createState() => _AppInitializerState();
}
class _AppInitializerState extends ConsumerState<_AppInitializer> {
@override
void initState() {
super.initState();
ref.read(downloadManagerProvider.notifier).init();
}
@override
Widget build(BuildContext context) => const VideoApp();
} }
class VideoApp extends ConsumerWidget { class VideoApp extends ConsumerWidget {
+7
View File
@@ -36,6 +36,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = state.copyWith(restored: true); state = state.copyWith(restored: true);
return; return;
} }
// 数据完整性防线:有 access 无 refresh 是不完整登录态(2h 后必然 401
// 且无法静默刷新),直接清理回游客态引导重新登录,避免僵尸登录态
if (TokenStore.refreshToken == null || TokenStore.refreshToken!.isEmpty) {
await TokenStore.clear();
state = state.copyWith(restored: true);
return;
}
try { try {
final user = await UserService.fetchMe(); final user = await UserService.fetchMe();
state = AuthState(user: user, restored: true); state = AuthState(user: user, restored: true);
+23 -1
View File
@@ -14,7 +14,9 @@ import 'auth_provider.dart';
enum FeedStrategy { enum FeedStrategy {
recommend('recommend', '推荐'), recommend('recommend', '推荐'),
latest('latest', '最新'), latest('latest', '最新'),
hot('hot', '热度'); hot('hot', '热度'),
liked('liked', '口碑'),
viewed('viewed', '热播');
const FeedStrategy(this.apiValue, this.label); const FeedStrategy(this.apiValue, this.label);
@@ -22,6 +24,15 @@ enum FeedStrategy {
final String label; final String label;
} }
/// Feed 顶部当前选中的策略:HomeShell 切底部 Tab 回来时,
/// 只恢复该策略对应的播放器池(其余池保持挂起,防止幽灵声)
final activeFeedStrategyProvider =
StateProvider<FeedStrategy>((_) => FeedStrategy.recommend);
/// 首页底部 Tab 当前是否可见:App 回前台时仅在可见状态下恢复播放
/// (否则在"我的"等页面退后台再回来会误恢复声音)
final feedTabVisibleProvider = StateProvider<bool>((_) => true);
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast) /// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
sealed class FeedEvent { sealed class FeedEvent {
const FeedEvent(); const FeedEvent();
@@ -148,6 +159,17 @@ class FeedNotifier extends StateNotifier<FeedState> {
} }
} }
/// 从全屏播放器返回后恢复:stopAll 清空了槽位,重新绑定当前页并播放。
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
Future<void> rebindCurrent() async {
final index = state.currentIndex;
if (index < 0 || index >= state.items.length) return;
await pool.bindWindow(
index,
[for (final v in state.items) (videoId: v.videoId, url: resolvePlayUrl(v))],
);
}
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3) /// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
void onPageChanged(int index) { void onPageChanged(int index) {
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声 // 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
+6
View File
@@ -8,6 +8,7 @@ import '../screens/assets/assets_screen.dart';
import '../screens/assets/withdraw_screen.dart'; import '../screens/assets/withdraw_screen.dart';
import '../screens/auth/login_screen.dart'; import '../screens/auth/login_screen.dart';
import '../screens/auth/register_screen.dart'; import '../screens/auth/register_screen.dart';
import '../screens/downloads/downloads_screen.dart';
import '../screens/home/home_shell.dart'; import '../screens/home/home_shell.dart';
import '../screens/membership/membership_screen.dart'; import '../screens/membership/membership_screen.dart';
import '../screens/notification/notifications_screen.dart'; import '../screens/notification/notifications_screen.dart';
@@ -94,6 +95,11 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (_, __) => VideoGridScreen.favorites(), builder: (_, __) => VideoGridScreen.favorites(),
), ),
GoRoute(path: '/mine/history', builder: (_, __) => const HistoryScreen()), GoRoute(path: '/mine/history', builder: (_, __) => const HistoryScreen()),
// 已下载(离线可看)
GoRoute(
path: '/mine/downloads',
builder: (_, __) => const DownloadsScreen(),
),
// 编辑资料 / 头像(页面内兜底未登录引导) // 编辑资料 / 头像(页面内兜底未登录引导)
GoRoute( GoRoute(
path: '/profile/edit', path: '/profile/edit',
@@ -0,0 +1,135 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/media/download_manager.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
/// 已下载视频列表(我的 → 已下载):
/// 下载中任务(进度/取消)+ 已完成列表(点击播放 / 左滑或长按删除)
class DownloadsScreen extends ConsumerWidget {
const DownloadsScreen({super.key});
static String _fmtSize(int bytes) {
if (bytes >= 1 << 30) return '${(bytes / (1 << 30)).toStringAsFixed(2)} GB';
if (bytes >= 1 << 20) return '${(bytes / (1 << 20)).toStringAsFixed(1)} MB';
return '${(bytes / 1024).toStringAsFixed(0)} KB';
}
static String _fmtDuration(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}
void _play(BuildContext context, DownloadItem item) {
context.push('/player',
extra: VideoItem(
videoId: item.videoId,
title: item.title,
coverUrl: item.coverUrl,
videoUrl: item.playUrl,
duration: item.duration,
author: VideoAuthor(userId: 0),
));
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, DownloadItem item) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除下载'),
content: Text('删除「${item.title}」?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除', style: TextStyle(color: Colors.red))),
],
),
);
if (ok == true) {
ref.read(downloadManagerProvider.notifier).delete(item.videoId);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(downloadManagerProvider);
final downloading = state.progress.entries.toList();
return Scaffold(
appBar: AppBar(title: const Text('已下载')),
body: ListView(
children: [
// 下载中
for (final e in downloading)
ListTile(
leading: const SizedBox(
width: 56, height: 56,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
title: Text('视频 #${e.key}',
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(value: e.value),
),
trailing: Text('${(e.value * 100).toStringAsFixed(0)}%'),
onLongPress: () =>
ref.read(downloadManagerProvider.notifier).cancel(e.key),
),
if (downloading.isNotEmpty) const Divider(),
// 已完成
if (state.items.isEmpty && downloading.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 120),
child: Center(
child: Text('暂无下载\n长按视频可下载',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textWeak)),
),
),
for (final item in state.items)
ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 72, height: 48,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl, fit: BoxFit.cover)
: Container(
color: Colors.grey[800],
child: const Icon(Icons.play_circle_outline,
color: Colors.white54),
),
),
),
title: Text(item.title.isEmpty ? '视频 #${item.videoId}' : item.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'${_fmtDuration(item.duration)} · ${_fmtSize(item.sizeBytes)}',
style: const TextStyle(fontSize: 12),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline,
color: AppColors.textWeak),
onPressed: () => _confirmDelete(context, ref, item),
),
onTap: () => _play(context, item),
),
],
),
);
}
}
+30 -5
View File
@@ -32,30 +32,46 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
// 登记当前策略,HomeShell 切回首页时只恢复这个策略的播放器池
ref.read(activeFeedStrategyProvider.notifier).state = _strategy;
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动) // 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
_eventSub = ref _eventSub = ref
.read(feedProvider(_strategy).notifier) .read(feedProvider(_strategy).notifier)
.events .events
.listen(_handleEvent); .listen(_handleEvent);
// 从登录页等返回时恢复播放:provider 全局常驻保留了列表,
// 但槽位已在 dispose 时 stopAll,需重新绑定(冷启动列表为空时为 no-op)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ref.read(feedProvider(_strategy).notifier).rebindCurrent();
});
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_eventSub?.cancel(); _eventSub?.cancel();
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
_pageController.dispose(); _pageController.dispose();
super.dispose(); super.dispose();
} }
/// App 退后台暂停、回前台恢复播放 /// App 退后台挂起、回前台恢复播放(suspend/resume 防在途 open 竞态)
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
final notifier = ref.read(feedProvider(_strategy).notifier); final notifier = ref.read(feedProvider(_strategy).notifier);
if (state == AppLifecycleState.paused || if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) { state == AppLifecycleState.inactive) {
notifier.pool.pauseCurrent(); notifier.pool.suspend();
} else if (state == AppLifecycleState.resumed) { } else if (state == AppLifecycleState.resumed) {
notifier.pool.playCurrent(); // 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播)
if (ref.read(feedTabVisibleProvider)) {
unawaited(() async {
await notifier.pool.resume();
await notifier.rebindCurrent();
}());
}
} }
} }
@@ -73,15 +89,23 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
void _switchStrategy(FeedStrategy next) { void _switchStrategy(FeedStrategy next) {
if (next == _strategy) return; if (next == _strategy) return;
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池 // 挂起旧策略池(防在途 open 完成后自动出声)
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent(); ref.read(feedProvider(_strategy).notifier).pool.suspend();
setState(() => _strategy = next); setState(() => _strategy = next);
ref.read(activeFeedStrategyProvider.notifier).state = next;
_eventSub?.cancel(); _eventSub?.cancel();
_eventSub = ref _eventSub = ref
.read(feedProvider(_strategy).notifier) .read(feedProvider(_strategy).notifier)
.events .events
.listen(_handleEvent); .listen(_handleEvent);
_pageController.jumpToPage(0); _pageController.jumpToPage(0);
// jumpToPage 触发新策略 bindWindow 后再 resume:等其在途 open 完成再播放,
// 同时解除新策略池可能残留的挂起态
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(ref.read(feedProvider(_strategy).notifier).pool.resume());
}
});
} }
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复 /// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
@@ -142,6 +166,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
onLike: notifier.toggleLike, onLike: notifier.toggleLike,
onFavorite: notifier.toggleFavorite, onFavorite: notifier.toggleFavorite,
onComment: _openComments, onComment: _openComments,
onReturnFromPlayer: notifier.rebindCurrent,
); );
}, },
), ),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../../core/media/download_manager.dart';
import '../../../core/player/feed_player_pool.dart'; import '../../../core/player/feed_player_pool.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart'; import '../../../models/video_item.dart';
@@ -25,6 +26,7 @@ class FeedVideoPage extends ConsumerStatefulWidget {
required this.onLike, required this.onLike,
this.onFavorite, this.onFavorite,
this.onComment, this.onComment,
this.onReturnFromPlayer,
}); });
final VideoItem item; final VideoItem item;
@@ -35,6 +37,9 @@ class FeedVideoPage extends ConsumerStatefulWidget {
final void Function(int videoId)? onFavorite; final void Function(int videoId)? onFavorite;
final void Function(VideoItem item)? onComment; final void Function(VideoItem item)? onComment;
/// 从全屏播放器返回后的恢复回调(重新绑定 Feed 当前页)
final VoidCallback? onReturnFromPlayer;
@override @override
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState(); ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
} }
@@ -89,7 +94,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (sheetContext) => Padding( builder: (sheetContext) => Theme(
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
data: ThemeData.light(),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -165,8 +173,38 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
_share(); _share();
}, },
), ),
// 下载(已下载/下载中置灰)
Builder(builder: (_) {
final dm = ref.read(downloadManagerProvider);
final downloaded = dm.items
.any((e) => e.videoId == widget.item.videoId);
final downloading =
dm.progress.containsKey(widget.item.videoId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(downloaded
? Icons.check_circle_outline
: Icons.download_outlined),
title: Text(downloaded
? '已下载'
: (downloading ? '下载中…' : '下载')),
enabled: !downloaded && !downloading,
onTap: () {
Navigator.of(sheetContext).pop();
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
ref
.read(downloadManagerProvider.notifier)
.start(widget.item)
.catchError((_) {
if (mounted) showTopToast(context, '下载失败,请重试');
});
},
);
}),
], ],
), ),
),
), ),
); );
} }
@@ -185,7 +223,9 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (_) => DraggableScrollableSheet( builder: (_) => Theme(
data: ThemeData.light(),
child: DraggableScrollableSheet(
initialChildSize: 0.6, initialChildSize: 0.6,
minChildSize: 0.4, minChildSize: 0.4,
maxChildSize: 0.9, maxChildSize: 0.9,
@@ -273,6 +313,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
), ),
], ],
), ),
),
), ),
); );
} }
@@ -287,10 +328,13 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
duration: (ep['duration'] as num?)?.toInt() ?? 0, duration: (ep['duration'] as num?)?.toInt() ?? 0,
author: widget.item.author, author: widget.item.author,
); );
// 先暂停 Feed 页播放器,再打开 SimplePlayerScreen // 彻底停止 Feed 层播放器:stopAll 断音 + 断缓存下载(pause 不停止缓冲)
widget.pool.pauseExcept(slotIndex: -1); widget.pool.stopAll();
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
context.push('/player', extra: newItem); context.push('/player', extra: newItem).then((_) {
// 从播放器返回:恢复 Feed 当前页(槽位已清空,需重新绑定)
if (mounted) widget.onReturnFromPlayer?.call();
});
} }
@override @override
@@ -327,6 +371,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
slotIndex: widget.slotIndex, slotIndex: widget.slotIndex,
coverUrl: widget.item.coverUrl, coverUrl: widget.item.coverUrl,
isCurrent: widget.isCurrent, isCurrent: widget.isCurrent,
durationSeconds: widget.item.duration,
), ),
// 双击爱心动画 // 双击爱心动画
@@ -18,6 +18,13 @@ class VideoInfoSection extends StatefulWidget {
class _VideoInfoSectionState extends State<VideoInfoSection> { class _VideoInfoSectionState extends State<VideoInfoSection> {
bool _expanded = false; bool _expanded = false;
static String _fmtSeconds(int seconds) {
final h = seconds ~/ 3600;
final m = ((seconds % 3600) ~/ 60).toString().padLeft(2, '0');
final s = (seconds % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final item = widget.item; final item = widget.item;
@@ -37,7 +44,7 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// 标题 + #标签(点击标签跳转标签视频页) // 标题 + 时长 + #标签(点击标签跳转标签视频页)
Wrap( Wrap(
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6, spacing: 6,
@@ -52,6 +59,25 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
shadows: [Shadow(color: Colors.black54, blurRadius: 4)], shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
), ),
), ),
// 标题后显示视频时长
if (item.duration > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white24, width: 0.5),
),
child: Text(
_fmtSeconds(item.duration),
style: const TextStyle(
color: Colors.white70,
fontSize: 11,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
),
for (final tag in item.tags) for (final tag in item.tags)
GestureDetector( GestureDetector(
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'), onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
@@ -14,6 +14,7 @@ class VideoPlayerView extends StatefulWidget {
required this.slotIndex, required this.slotIndex,
required this.coverUrl, required this.coverUrl,
required this.isCurrent, required this.isCurrent,
this.durationSeconds = 0,
}); });
final FeedPlayerPool pool; final FeedPlayerPool pool;
@@ -23,6 +24,10 @@ class VideoPlayerView extends StatefulWidget {
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标) /// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
final bool isCurrent; final bool isCurrent;
/// DB 里的视频时长(秒):流 duration 异常(如被当直播流报 0)时兜底,
/// 保证进度条/拖动可用
final int durationSeconds;
@override @override
State<VideoPlayerView> createState() => _VideoPlayerViewState(); State<VideoPlayerView> createState() => _VideoPlayerViewState();
} }
@@ -45,9 +50,15 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
}); });
} }
/// 有效时长:流上报优先,异常(duration=0)回退 DB 时长
int get _totalMs {
final streamMs = _duration.inMilliseconds;
return streamMs > 0 ? streamMs : widget.durationSeconds * 1000;
}
double get _value { double get _value {
if (_seeking) return _seekValue; if (_seeking) return _seekValue;
final total = _duration.inMilliseconds; final total = _totalMs;
if (total <= 0) return 0; if (total <= 0) return 0;
final cur = _position.inMilliseconds.clamp(0, total); final cur = _position.inMilliseconds.clamp(0, total);
return cur / total; return cur / total;
@@ -66,7 +77,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
void _onSeekEnd(double v) { void _onSeekEnd(double v) {
setState(() => _seeking = false); setState(() => _seeking = false);
final totalMs = _duration.inMilliseconds; final totalMs = _totalMs;
if (totalMs > 0) { if (totalMs > 0) {
_slot.player.seek(Duration(milliseconds: (v * totalMs).round())); _slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
} }
@@ -120,7 +131,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
), ),
), ),
// 抖音风格细进度条(贴底,常驻) // 抖音风格细进度条(贴底,常驻;拖动时显示目标时间气泡)
Positioned( Positioned(
left: 0, right: 0, bottom: 0, left: 0, right: 0, bottom: 0,
child: _ThinProgressBar( child: _ThinProgressBar(
@@ -128,6 +139,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
onChanged: _onSeek, onChanged: _onSeek,
onChangeStart: _onSeekStart, onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd, onChangeEnd: _onSeekEnd,
totalMs: _totalMs,
), ),
), ),
], ],
@@ -141,16 +153,29 @@ class _ThinProgressBar extends StatefulWidget {
required this.onChanged, required this.onChanged,
required this.onChangeStart, required this.onChangeStart,
required this.onChangeEnd, required this.onChangeEnd,
this.totalMs = 0,
}); });
final double value; final double value;
final ValueChanged<double> onChanged; final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart; final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd; final ValueChanged<double> onChangeEnd;
/// 视频总时长(毫秒):拖动气泡显示目标位置用时/分/秒
final int totalMs;
@override @override
State<_ThinProgressBar> createState() => _ThinProgressBarState(); State<_ThinProgressBar> createState() => _ThinProgressBarState();
} }
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
String _seekBubbleText(double value, int totalMs) {
final d = Duration(milliseconds: (value * totalMs).round());
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
}
class _ThinProgressBarState extends State<_ThinProgressBar> { class _ThinProgressBarState extends State<_ThinProgressBar> {
bool _dragging = false; bool _dragging = false;
double _dragValue = 0; double _dragValue = 0;
@@ -187,15 +212,44 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
setState(() => _dragging = false); setState(() => _dragging = false);
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0)); widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
}, },
child: Container( child: SizedBox(
height: 32, // 触摸热区 32px(视觉 2px,好点) height: 32, // 触摸热区 32px(视觉 2px,好点)
alignment: Alignment.bottomCenter, child: Stack(
child: CustomPaint( clipBehavior: Clip.none,
painter: _ProgressPainter( alignment: Alignment.bottomCenter,
value: _dragging ? _dragValue : widget.value, children: [
dragging: _dragging, Align(
), alignment: Alignment.bottomCenter,
size: Size(w, _dragging ? 20 : 4), child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
),
),
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
if (_dragging && widget.totalMs > 0)
Positioned(
bottom: 14,
left: (_dragValue * w - 38)
.clamp(0.0, (w - 76).clamp(0.0, w)),
child: Container(
width: 76,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_seekBubbleText(_dragValue, widget.totalMs),
style: const TextStyle(
color: Colors.white, fontSize: 11.5),
),
),
),
],
), ),
), ),
); );
+14 -4
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -26,16 +28,24 @@ class _HomeShellState extends ConsumerState<HomeShell> {
void _onTabSelected(int i) { void _onTabSelected(int i) {
if (i == _index) return; if (i == _index) return;
// 切到非首页:暂停所有 Feed 策略的播放器池(IndexedStack 保活,否则声音会继续) // 切到非首页:挂起所有 Feed 策略的播放器池(IndexedStack 保活,
// suspend 会同时阻止在途 open 完成后自动播放,杜绝后台幽灵声)
if (i != 0) { if (i != 0) {
for (final s in _feedStrategies) { for (final s in _feedStrategies) {
ref.read(feedProvider(s).notifier).pool.pauseCurrent(); ref.read(feedProvider(s).notifier).pool.suspend();
} }
} }
ref.read(feedTabVisibleProvider.notifier).state = i == 0;
setState(() => _index = i); setState(() => _index = i);
// 切回首页:恢复当前策略播放(FeedScreen 内部 _strategy 是 recommend) // 切回首页:只恢复当前选中策略的池(其余保持挂起)
if (i == 0) { if (i == 0) {
ref.read(feedProvider(FeedStrategy.recommend).notifier).pool.playCurrent(); final active = ref.read(activeFeedStrategyProvider);
final notifier = ref.read(feedProvider(active).notifier);
unawaited(() async {
await notifier.pool.resume();
// 兜底:挂起期间首屏 bind 可能尚未发生,补一次当前页绑定播放
await notifier.rebindCurrent();
}());
} }
} }
+150 -24
View File
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../core/config/test_stream.dart'; import '../../core/config/test_stream.dart';
import '../../core/media/download_manager.dart';
import '../../core/media/local_media_server.dart';
import '../../core/network/api_exception.dart'; import '../../core/network/api_exception.dart';
import '../../core/storage/watch_dedup_store.dart'; import '../../core/storage/watch_dedup_store.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
@@ -100,7 +102,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
} }
void _open(VideoItem item) { void _open(VideoItem item) {
_player.open(Media(resolvePlayUrl(item)), play: true); // 本地已下载地址(127.0.0.1)原样播放;网络 m3u8 经本地代理走 1G 分片缓存
_player.open(Media(LocalMediaServer.I.wrap(resolvePlayUrl(item))),
play: true);
} }
void _toggle() { void _toggle() {
@@ -317,7 +321,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (sheetContext) => Padding( builder: (sheetContext) => Theme(
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
data: ThemeData.light(),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -395,21 +402,64 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
_share(); _share();
}, },
), ),
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
Builder(builder: (_) {
final dm = ref.read(downloadManagerProvider);
final downloaded =
dm.items.any((e) => e.videoId == _currentItem.videoId);
final downloading =
dm.progress.containsKey(_currentItem.videoId);
final isLocal = _currentItem.videoUrl.contains('127.0.0.1');
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(downloaded || isLocal
? Icons.check_circle_outline
: Icons.download_outlined),
title: Text(downloaded || isLocal
? '已下载'
: (downloading ? '下载中…' : '下载')),
enabled: !downloaded && !downloading && !isLocal,
onTap: () {
Navigator.of(sheetContext).pop();
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
ref
.read(downloadManagerProvider.notifier)
.start(_currentItem)
.catchError((_) {
if (mounted) showTopToast(context, '下载失败,请重试');
});
},
);
}),
], ],
), ),
),
), ),
); );
} }
// ---------- 进度条 ---------- // ---------- 进度条 ----------
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
Duration get _effectiveDuration => _duration.inMilliseconds > 0
? _duration
: Duration(seconds: _currentItem.duration);
double get _sliderValue { double get _sliderValue {
if (_seeking) return _seekValue; if (_seeking) return _seekValue;
if (_duration.inMilliseconds <= 0) return 0; if (_effectiveDuration.inMilliseconds <= 0) return 0;
return (_position.inMilliseconds / _duration.inMilliseconds) return (_position.inMilliseconds / _effectiveDuration.inMilliseconds)
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
} }
static String _fmtDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
void _onSeekStart(double v) { void _onSeekStart(double v) {
setState(() { setState(() {
_seeking = true; _seeking = true;
@@ -423,9 +473,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
void _onSeekEnd(double v) { void _onSeekEnd(double v) {
setState(() => _seeking = false); setState(() => _seeking = false);
if (_duration.inMilliseconds > 0) { if (_effectiveDuration.inMilliseconds > 0) {
final target = Duration( final target = Duration(
milliseconds: (v * _duration.inMilliseconds).round(), milliseconds: (v * _effectiveDuration.inMilliseconds).round(),
); );
_player.seek(target); _player.seek(target);
} }
@@ -591,14 +641,45 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
), ),
), ),
// ---------- 进度条:抖音风格一条细线 ---------- // ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
Positioned( Positioned(
left: 0, right: 0, bottom: 0, left: 0, right: 0, bottom: 0,
child: _ThinProgressBar( child: Column(
value: _sliderValue, mainAxisSize: MainAxisSize.min,
onChanged: _onSeek, children: [
onChangeStart: _onSeekStart, // 时间刻度:左当前(拖动时为目标位置),右总时长
onChangeEnd: _onSeekEnd, Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_fmtDuration(_seeking
? Duration(
milliseconds: (_seekValue *
_effectiveDuration.inMilliseconds)
.round())
: _position),
style: const TextStyle(
color: Colors.white70, fontSize: 11),
),
Text(
_fmtDuration(_effectiveDuration),
style: const TextStyle(
color: Colors.white70, fontSize: 11),
),
],
),
),
_ThinProgressBar(
value: _sliderValue,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
totalMs: _effectiveDuration.inMilliseconds,
),
],
), ),
), ),
], ],
@@ -648,7 +729,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (_) => DraggableScrollableSheet( builder: (_) => Theme(
data: ThemeData.light(),
child: DraggableScrollableSheet(
initialChildSize: 0.6, initialChildSize: 0.6,
minChildSize: 0.4, minChildSize: 0.4,
maxChildSize: 0.9, maxChildSize: 0.9,
@@ -744,6 +827,7 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
), ),
], ],
), ),
),
), ),
); );
} }
@@ -757,6 +841,7 @@ class _ThinProgressBar extends StatefulWidget {
required this.onChanged, required this.onChanged,
required this.onChangeStart, required this.onChangeStart,
required this.onChangeEnd, required this.onChangeEnd,
this.totalMs = 0,
}); });
final double value; final double value;
@@ -764,10 +849,22 @@ class _ThinProgressBar extends StatefulWidget {
final ValueChanged<double> onChangeStart; final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd; final ValueChanged<double> onChangeEnd;
/// 视频总时长(毫秒):拖动时气泡显示目标位置用时/分/秒
final int totalMs;
@override @override
State<_ThinProgressBar> createState() => _ThinProgressBarState(); State<_ThinProgressBar> createState() => _ThinProgressBarState();
} }
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
String _seekBubbleText(double value, int totalMs) {
final d = Duration(milliseconds: (value * totalMs).round());
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
}
class _ThinProgressBarState extends State<_ThinProgressBar> { class _ThinProgressBarState extends State<_ThinProgressBar> {
bool _dragging = false; bool _dragging = false;
double _dragValue = 0; double _dragValue = 0;
@@ -805,19 +902,48 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
_start(d.localPosition, w); _start(d.localPosition, w);
}, },
onTapUp: (_) => _end(w), onTapUp: (_) => _end(w),
child: Container( child: SizedBox(
height: 32, // 触摸热区 32px(比视觉线条大,提升交互) height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
alignment: Alignment.bottomCenter, child: Stack(
child: AnimatedContainer( clipBehavior: Clip.none,
duration: const Duration(milliseconds: 150), alignment: Alignment.bottomCenter,
height: _dragging ? 4 : 2, children: [
width: double.infinity, Align(
child: CustomPaint( alignment: Alignment.bottomCenter,
painter: _ProgressPainter( child: AnimatedContainer(
value: _dragging ? _dragValue : widget.value, duration: const Duration(milliseconds: 150),
dragging: _dragging, height: _dragging ? 4 : 2,
width: double.infinity,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
),
),
), ),
), // 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
if (_dragging && widget.totalMs > 0)
Positioned(
bottom: 14,
left: (_dragValue * w - 38)
.clamp(0.0, (w - 76).clamp(0.0, w)),
child: Container(
width: 76,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_seekBubbleText(_dragValue, widget.totalMs),
style: const TextStyle(
color: Colors.white, fontSize: 11.5),
),
),
),
],
), ),
), ),
); );
+5
View File
@@ -142,6 +142,11 @@ class _MineScreenState extends ConsumerState<MineScreen> {
label: '观看历史', label: '观看历史',
onTap: () => context.push('/mine/history'), onTap: () => context.push('/mine/history'),
), ),
_Entry(
icon: Icons.download_outlined,
label: '已下载',
onTap: () => context.push('/mine/downloads'),
),
_Entry( _Entry(
icon: Icons.account_balance_wallet_outlined, icon: Icons.account_balance_wallet_outlined,
label: '钱包', label: '钱包',
+6 -1
View File
@@ -2,7 +2,7 @@ name: video_app
description: "短视频应用(Flutter,iOS/Android 双端)" description: "短视频应用(Flutter,iOS/Android 双端)"
publish_to: 'none' publish_to: 'none'
version: 1.2.0+13 version: 1.3.7+23
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -59,6 +59,11 @@ dependencies:
image_picker: ^1.1.2 image_picker: ^1.1.2
# 外部浏览器/商店跳转(强制更新下载页) # 外部浏览器/商店跳转(强制更新下载页)
url_launcher: ^6.3.1 url_launcher: ^6.3.1
# 本地媒体服务:HLS 分片缓存(1G LRU)+ 已下载视频离线播放
shelf: ^1.4.2
shelf_router: ^1.1.4
path_provider: ^2.1.5
path: ^1.9.0
tobias: ^5.3.4 tobias: ^5.3.4
fluwx: ^5.7.7 fluwx: ^5.7.7
+15 -5
View File
@@ -41,10 +41,10 @@ VERSION_CODE="${PUBSPEC_VERSION##*+}" # 2
echo " pubspec: $VERSION_NAME versionCode=$VERSION_CODE" echo " pubspec: $VERSION_NAME versionCode=$VERSION_CODE"
# DB 当前 latest_version_code(用于参考) # DB 当前 latest_version_code(用于参考)
DB_CURRENT_CODE=$(mysql -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -Nse \ DB_CURRENT_CODE=$(mysql -h 127.0.0.1 -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -Nse \
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version_code')) \ "SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version_code')) \
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "0") FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "0")
DB_CURRENT_VER=$(mysql -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -Nse \ DB_CURRENT_VER=$(mysql -h 127.0.0.1 -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -Nse \
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version')) \ "SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version')) \
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "unknown") FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "unknown")
echo " DB 当前 latest: $DB_CURRENT_VER code=$DB_CURRENT_CODE" echo " DB 当前 latest: $DB_CURRENT_VER code=$DB_CURRENT_CODE"
@@ -65,6 +65,10 @@ cd "$APP_DIR"
flutter build apk --release 2>&1 | tail -5 flutter build apk --release 2>&1 | tail -5
echo echo
# 停掉 Gradle Daemon(编译残留常驻 ~3GB 内存,下次编译会自动重启)
echo "▶ 清理 Gradle Daemon..."
(cd "$APP_DIR/android" && ./gradlew --stop >/dev/null 2>&1) || true
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 4. 部署 APK # 4. 部署 APK
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -84,15 +88,21 @@ if [ -n "${1:-}" ]; then
NEW_CODE="$1" NEW_CODE="$1"
fi fi
UPDATE_JSON="{\"latest_version\":\"$VERSION_NAME\",\"latest_version_code\":$NEW_CODE,\"min_version_code\":1,\"update_url\":\"https://h5.256kb.top/downloads/video-app.apk\",\"notice\":\"\"}" # 读出当前 min_version_code,发版时保留不重置
MIN_CODE=$(mysql -h 127.0.0.1 -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -Nse \
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.min_version_code')) \
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "")
MIN_CODE="${MIN_CODE:-1}"
UPDATE_JSON="{\"latest_version\":\"$VERSION_NAME\",\"latest_version_code\":$NEW_CODE,\"min_version_code\":$MIN_CODE,\"update_url\":\"https://h5.256kb.top/downloads/video-app.apk\",\"notice\":\"\"}"
echo "▶ 同步 DB app.version.android → $UPDATE_JSON" echo "▶ 同步 DB app.version.android → $UPDATE_JSON"
mysql -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -e " mysql -h 127.0.0.1 -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -e "
INSERT INTO system_configs (config_key, config_value, description) INSERT INTO system_configs (config_key, config_value, description)
VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置') VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置')
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW(); ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW();
" 2>/dev/null " 2>/dev/null
redis-cli DEL config:system >/dev/null 2>&1 || true docker exec my-redis redis-cli DEL config:system >/dev/null 2>&1 || true
echo echo
echo "✅ 发版完成" echo "✅ 发版完成"
@@ -0,0 +1,33 @@
"""add collection_records
Revision ID: b3c7d9e21f4a
Revises: d659ecc1b6e7
Create Date: 2026-10-17 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
revision = 'b3c7d9e21f4a'
down_revision = 'd659ecc1b6e7'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table('collection_records',
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False),
sa.Column('source_key', sa.String(length=50), nullable=False, comment='内容源标识'),
sa.Column('source_id', mysql.BIGINT(unsigned=True), nullable=False, comment='源站内容 id'),
sa.Column('series_id', mysql.BIGINT(unsigned=True), nullable=False, comment='本站系列 id'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('uk_source', 'collection_records', ['source_key', 'source_id'], unique=True)
op.create_index('idx_record_series', 'collection_records', ['series_id'], unique=False)
def downgrade() -> None:
op.drop_index('idx_record_series', table_name='collection_records')
op.drop_index('uk_source', table_name='collection_records')
op.drop_table('collection_records')
@@ -0,0 +1,37 @@
"""add collection_sources + collection_records.video_id
Revision ID: c8f2a15e93b7
Revises: b3c7d9e21f4a
Create Date: 2026-10-17 13:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
revision = 'c8f2a15e93b7'
down_revision = 'b3c7d9e21f4a'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table('collection_sources',
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=50), nullable=False, comment='分类名'),
sa.Column('list_url', sa.String(length=500), nullable=False, comment='列表URL模板, {{page}}为页码占位'),
sa.Column('category_id', mysql.INTEGER(unsigned=True), nullable=False, comment='本站分类id'),
sa.Column('status', mysql.SMALLINT(), nullable=False, server_default='1', comment='1启用 0停用'),
sa.Column('last_sync_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('list_url', name='uk_list_url')
)
op.add_column('collection_records', sa.Column('video_id', mysql.BIGINT(unsigned=True), nullable=True, comment='本站视频id(单视频类采集)'))
op.alter_column('collection_records', 'series_id', existing_type=mysql.BIGINT(unsigned=True), nullable=True)
def downgrade() -> None:
op.alter_column('collection_records', 'series_id', existing_type=mysql.BIGINT(unsigned=True), nullable=False)
op.drop_column('collection_records', 'video_id')
op.drop_table('collection_sources')
Binary file not shown.
+327
View File
@@ -5,6 +5,8 @@
- operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送 - operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送
- 其余接口仅 super - 其余接口仅 super
""" """
import logging
import redis.asyncio as aioredis import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, BaseModel as _BM, Field as _F, Field as _Field from pydantic import BaseModel, BaseModel as _BM, Field as _F, Field as _Field
@@ -19,6 +21,8 @@ from app.services import admin_service
router = APIRouter(prefix="/admin", tags=["管理后台"]) router = APIRouter(prefix="/admin", tags=["管理后台"])
logger = logging.getLogger(__name__)
# 角色依赖:super 自动放行 # 角色依赖:super 自动放行
require_reviewer = require_roles("reviewer") require_reviewer = require_roles("reviewer")
require_operator = require_roles("operator") require_operator = require_roles("operator")
@@ -341,6 +345,15 @@ async def set_user_status(user_id: int, req: schema.UserStatusReq, request: Requ
return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request))) return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request)))
@router.put("/users/{user_id}/password")
async def reset_user_password(user_id: int, req: schema.UserPasswordResetReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.reset_user_password(db, redis, admin, user_id, req, _ip(request))
return ok()
@router.post("/points/adjust") @router.post("/points/adjust")
async def adjust_points(req: schema.PointsAdjustReq, request: Request, async def adjust_points(req: schema.PointsAdjustReq, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -558,6 +571,19 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
rows = (await db.execute( rows = (await db.execute(
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size) stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all() )).scalars().all()
# 关联采集记录(标记采集来源,供"同步更新"按钮使用)
from app.models.video import CollectionRecord
series_ids = [s.id for s in rows]
source_map: dict[int, int] = {}
if series_ids:
recs = (await db.execute(
select(CollectionRecord.series_id, CollectionRecord.source_id).where(
CollectionRecord.series_id.in_(series_ids)
)
)).all()
source_map = {r.series_id: r.source_id for r in recs}
return ok({ return ok({
"total": total, "total": total,
"list": [{ "list": [{
@@ -565,6 +591,7 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
"description": s.description or "", "description": s.description or "",
"category_id": s.category_id or 0, "category_id": s.category_id or 0,
"total_episodes": s.total_episodes, "status": s.status, "total_episodes": s.total_episodes, "status": s.status,
"source_id": source_map.get(s.id),
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"), "created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
} for s in rows], } for s in rows],
}) })
@@ -664,6 +691,306 @@ async def bind_video_series(video_id: int, req: _VideoSeriesBind,
return ok() return ok()
# ---------- 短剧采集 ----------
_collect_sync_running = False
@router.post("/collection/sync/{series_id}")
async def collect_sync_one(series_id: int, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""手动同步单部采集短剧(补充源站新出的集数)"""
from app.models.admin import AdminLog
from app.models.video import CollectionRecord
from app.services import collection_service
from sqlalchemy import select
source_id = (await db.execute(
select(CollectionRecord.source_id).where(
CollectionRecord.source_key == collection_service.SOURCE_KEY,
CollectionRecord.series_id == series_id,
)
)).scalar_one_or_none()
if not source_id:
raise BizException(errors.NOT_FOUND, "该系列不是采集内容")
stat = await collection_service.sync_drama_by_id(db, source_id)
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sync",
target=f"series:{series_id}", detail=str(stat), ip=_ip(request)))
await db.commit()
return ok(stat)
class _CollectSyncAllReq(BaseModel):
pages: int = _Field(2, ge=1, le=20) # 增量同步的列表页数
@router.post("/collection/sync-all")
async def collect_sync_all(req: _CollectSyncAllReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""触发增量同步(后台异步执行,立即返回)"""
import asyncio
from app.database import SessionFactory
from app.models.admin import AdminLog
from app.services import collection_service
global _collect_sync_running
if _collect_sync_running:
raise BizException(errors.RATE_LIMITED, "已有同步任务进行中")
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sync_all",
target="", detail=f"pages={req.pages}", ip=_ip(request)))
await db.commit()
async def _run() -> None:
global _collect_sync_running
_collect_sync_running = True
try:
async with SessionFactory() as s:
await collection_service.sync_short_dramas(s, max_pages=req.pages)
finally:
_collect_sync_running = False
asyncio.create_task(_run())
return ok({"started": True, "pages": req.pages})
# ---------- 采集源管理(单视频类,自助添加)----------
class _SourceCreate(BaseModel):
name: str = _Field(min_length=1, max_length=50)
list_url: str = _Field(min_length=10, max_length=500)
class _SourceUpdate(BaseModel):
name: str | None = _Field(None, min_length=1, max_length=50)
list_url: str | None = _Field(None, min_length=10, max_length=500)
status: int | None = _Field(None, ge=0, le=1)
def _source_dict(s) -> dict:
return {
"id": s.id, "name": s.name, "list_url": s.list_url,
"category_id": s.category_id, "status": s.status,
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else "",
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
async def _resolve_category(db: AsyncSession, name: str) -> int:
"""按名称找分类,不存在则创建"""
from app.models.video import Category
from sqlalchemy import func, select
cid = (await db.execute(select(Category.id).where(Category.name == name))).scalar_one_or_none()
if cid:
return cid
max_sort = (await db.execute(select(func.coalesce(func.max(Category.sort), 0)))).scalar_one()
c = Category(name=name, sort=max_sort + 1, status=1)
db.add(c)
await db.flush()
return c.id
@router.get("/collection/sources")
async def list_sources(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
from app.models.video import CollectionSource
from sqlalchemy import select
rows = (await db.execute(select(CollectionSource).order_by(CollectionSource.id.desc()))).scalars().all()
return ok({"list": [_source_dict(s) for s in rows]})
@router.post("/collection/sources")
async def create_source(req: _SourceCreate, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""添加采集源:自动按名称建/找分类"""
from app.models.admin import AdminLog
from app.models.video import CollectionSource
if "{{page}}" not in req.list_url:
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
cid = await _resolve_category(db, req.name.strip())
s = CollectionSource(name=req.name.strip(), list_url=req.list_url.strip(), category_id=cid)
db.add(s)
await db.flush()
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_create",
target=f"source:{s.id}", detail=f"{s.name} {s.list_url}", ip=_ip(request)))
await db.commit()
await db.refresh(s)
return ok(_source_dict(s))
@router.put("/collection/sources/{source_id}")
async def update_source(source_id: int, req: _SourceUpdate, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
from app.models.video import CollectionSource
s = await db.get(CollectionSource, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "采集源不存在")
d = req.model_dump(exclude_unset=True)
if "list_url" in d and "{{page}}" not in d["list_url"]:
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
if "name" in d:
d["category_id"] = await _resolve_category(db, d["name"].strip())
d["name"] = d["name"].strip()
for k, v in d.items():
setattr(s, k, v)
await db.commit()
await db.refresh(s)
return ok(_source_dict(s))
@router.delete("/collection/sources/{source_id}")
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.video import CollectionSource
s = await db.get(CollectionSource, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "采集源不存在")
await db.delete(s)
await db.commit()
return ok()
class _SourceBatchReq(BaseModel):
text: str = _Field(min_length=1, max_length=20000)
@router.post("/collection/sources/batch")
async def batch_create_sources(req: _SourceBatchReq, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""批量添加采集源:每行一条,格式 "分类名 URL"(URL 可带反引号),成功后自动触发采集"""
import re
from app.models.admin import AdminLog
from app.models.video import CollectionSource
from sqlalchemy import select
line_re = re.compile(r"^\s*(?P<name>.+?)[\s]+[`'\"]*(?P<url>https?://[^\s`'\"]+)[`'\"]*\s*$")
created, skipped, failed = [], [], []
for raw in req.text.splitlines():
line = raw.strip()
if not line:
continue
m = line_re.match(line)
if not m or "{{page}}" not in m.group("url"):
failed.append({"line": line[:80], "reason": "格式错误或缺少 {{page}} 占位"})
continue
name, url = m.group("name").strip().strip("`'\" "), m.group("url")
if not name or len(name) > 50:
failed.append({"line": line[:80], "reason": "分类名为空或过长"})
continue
exists = (await db.execute(
select(CollectionSource.id).where(CollectionSource.list_url == url)
)).scalar_one_or_none()
if exists:
skipped.append({"name": name, "reason": "URL 已存在"})
continue
cid = await _resolve_category(db, name)
s = CollectionSource(name=name, list_url=url, category_id=cid)
db.add(s)
await db.flush()
created.append({"id": s.id, "name": name})
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_batch",
target="", detail=f"created={len(created)} skipped={len(skipped)} failed={len(failed)}",
ip=_ip(request)))
await db.commit()
# 新建的源自动触发后台采集
if created:
import asyncio
for c in created:
if c["id"] not in _sync_running_sources:
_sync_running_sources.add(c["id"])
asyncio.create_task(_run_source_sync(c["id"], 0))
return ok({"created": created, "skipped": skipped, "failed": failed,
"sync_started": [c["id"] for c in created]})
_sync_running_sources: set[int] = set()
async def _run_source_sync(source_id: int, max_pages: int) -> None:
from app.database import SessionFactory
from app.models.video import CollectionSource
from app.services import collection_service
try:
async with SessionFactory() as s:
source = await s.get(CollectionSource, source_id)
if source:
stat = await collection_service.sync_source(s, source, max_pages=max_pages)
logger.info("采集源 %s 同步完成: %s", source_id, stat)
except Exception:
logger.exception("采集源 %s 同步失败", source_id)
finally:
_sync_running_sources.discard(source_id)
class _SourceSyncReq(BaseModel):
max_pages: int = _Field(0, ge=0, le=200) # 0=不限(增量会自动提前停止)
@router.post("/collection/sources/{source_id}/sync")
async def sync_one_source(source_id: int, req: _SourceSyncReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""同步单个采集源(后台异步,立即返回)"""
import asyncio
from app.models.admin import AdminLog
from app.models.video import CollectionSource
s = await db.get(CollectionSource, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "采集源不存在")
if source_id in _sync_running_sources:
raise BizException(errors.RATE_LIMITED, "该源正在同步中")
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_sync",
target=f"source:{source_id}", detail=f"max_pages={req.max_pages}", ip=_ip(request)))
await db.commit()
_sync_running_sources.add(source_id)
asyncio.create_task(_run_source_sync(source_id, req.max_pages))
return ok({"started": True, "source_id": source_id})
@router.post("/collection/sources/sync-all")
async def sync_all_sources(request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""同步全部启用的采集源(后台异步)"""
import asyncio
from app.models.admin import AdminLog
from app.models.video import CollectionSource
from sqlalchemy import select
ids = (await db.execute(
select(CollectionSource.id).where(CollectionSource.status == 1)
)).scalars().all()
started = [i for i in ids if i not in _sync_running_sources]
for i in started:
_sync_running_sources.add(i)
asyncio.create_task(_run_source_sync(i, 0))
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sources_sync_all",
target="", detail=f"sources={started}", ip=_ip(request)))
await db.commit()
return ok({"started": started, "skipped_running": [i for i in ids if i not in started]})
# ---------- 分类管理 ---------- # ---------- 分类管理 ----------
from pydantic import BaseModel as _BM from pydantic import BaseModel as _BM
+34
View File
@@ -0,0 +1,34 @@
"""媒体代理:m3u8 播放列表转发(源站 playlist 内 TS 链接带时效签名,每次回源拉取新鲜版本)"""
import re
import httpx
from fastapi import APIRouter, Query, Response
from app.core.errors import NOT_FOUND, PARAM_ERROR
from app.core.response import BizException
from app.services.collection_service import HEADERS, UPSTREAM
router = APIRouter(prefix="/m3u8", tags=["媒体"])
# 只允许相对资源路径,防 SSRF
_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/._-]*$")
@router.get("/proxy")
async def m3u8_proxy(path: str = Query(min_length=1, max_length=300)):
if ".." in path or not _PATH_RE.match(path):
raise BizException(PARAM_ERROR, "非法路径")
url = f"{UPSTREAM}/api/v1/m3u8/proxy"
async with httpx.AsyncClient(follow_redirects=True) as client:
resp = await client.get(url, params={"path": path}, headers=HEADERS, timeout=20)
text = resp.text
if resp.status_code != 200 or not text.lstrip().startswith("#EXTM3U"):
raise BizException(NOT_FOUND, "播放列表获取失败")
return Response(
content=text,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
)
+1 -1
View File
@@ -25,7 +25,7 @@ UPLOAD_RATE_LIMIT = 5
@router.get("/feed") @router.get("/feed")
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20), async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
category_id: int | None = Query(None), category_id: int | None = Query(None),
type: str = Query("recommend", pattern="^(recommend|latest|hot)$"), type: str = Query("recommend", pattern="^(recommend|latest|hot|liked|viewed)$"),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)): identity: Identity = Depends(get_identity)):
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id)) return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
+2 -1
View File
@@ -69,8 +69,9 @@ from app.config import BASE_DIR
app.mount("/static", StaticFiles(directory=BASE_DIR / "uploads"), name="static") app.mount("/static", StaticFiles(directory=BASE_DIR / "uploads"), name="static")
# 业务路由(按模块逐步注册) # 业务路由(按模块逐步注册)
from app.api.v1 import admin, auth, common, incentives, interactions, invite, membership, payments, users, videos from app.api.v1 import admin, auth, common, incentives, interactions, invite, media, membership, payments, users, videos
app.include_router(media.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1") app.include_router(users.router, prefix="/api/v1")
app.include_router(videos.router, prefix="/api/v1") app.include_router(videos.router, prefix="/api/v1")
Binary file not shown.
+34
View File
@@ -114,3 +114,37 @@ class Series(Base):
Index("idx_series_cat_status", "category_id", "status"), Index("idx_series_cat_status", "category_id", "status"),
Index("idx_series_status_created", "status", "created_at"), Index("idx_series_status_created", "status", "created_at"),
) )
class CollectionRecord(Base):
"""采集记录:外部内容源 → 本站系列的映射(幂等导入)"""
__tablename__ = "collection_records"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
source_key: Mapped[str] = mapped_column(String(50), comment="内容源标识")
source_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="源站内容 id")
series_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True), comment="本站系列 id")
video_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True), comment="本站视频 id(单视频类)")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("uk_source", "source_key", "source_id", unique=True),
Index("idx_record_series", "series_id"),
Index("idx_record_video", "video_id"),
)
class CollectionSource(Base):
"""采集源配置:分类名 + 列表URL模板(后台自助添加)"""
__tablename__ = "collection_sources"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), comment="分类名")
list_url: Mapped[str] = mapped_column(String(500), comment="列表URL模板,{{page}}为页码占位")
category_id: Mapped[int] = mapped_column(INTEGER(unsigned=True), comment="本站分类 id")
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1启用 0停用")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
Binary file not shown.
+7
View File
@@ -37,6 +37,13 @@ class PointsAdjustReq(BaseModel):
reason: str = Field(min_length=1, max_length=200) reason: str = Field(min_length=1, max_length=200)
class UserPasswordResetReq(BaseModel):
"""后台重置用户密码"""
new_password: str = Field(min_length=6, max_length=100)
reason: str = Field(min_length=1, max_length=200)
class ResettleReq(BaseModel): class ResettleReq(BaseModel):
"""人工补单(11.12)""" """人工补单(11.12)"""
+20
View File
@@ -462,6 +462,26 @@ async def set_user_status(
return {"user_id": user_id, "status": req.status, "status_text": USER_STATUS_TEXT[req.status]} return {"user_id": user_id, "status": req.status, "status_text": USER_STATUS_TEXT[req.status]}
async def reset_user_password(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
user_id: int, req, ip: str,
) -> None:
"""后台重置用户密码【敏感】:重置后全端 Token 立即失效"""
user = await db.get(User, user_id)
if user is None:
raise BizException(errors.NOT_FOUND, "用户不存在")
if user.status == 3:
raise BizException(errors.PARAM_ERROR, "账号已注销,不可操作")
user.password_hash = hash_password(req.new_password)
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
await redis.incr(f"auth:epoch:{user_id}")
await redis.delete(f"auth:refresh:{user_id}")
await _log(db, admin.admin_id, "user_password_reset", target=str(user_id),
detail=req.reason, ip=ip)
await db.commit()
# ---------- 积分 ---------- # ---------- 积分 ----------
async def adjust_points( async def adjust_points(
+269
View File
@@ -0,0 +1,269 @@
"""短剧采集服务:从内容源拉取短剧列表/详情,导入为 series + videos(幂等)"""
import asyncio
import logging
from datetime import datetime
from urllib.parse import quote
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.video import CollectionRecord, CollectionSource, Series, Video
logger = logging.getLogger(__name__)
# 内容源配置(域名轮换时只需改这里)
SOURCE_KEY = "mdcmai4"
UPSTREAM = "https://mdcmai4.xyz"
PRODUCT_ID = 1
CATEGORY_ID = 12 # 本站分类:AI短剧
AUTHOR_USER_ID = 1 # 导入内容归属用户
SELF_API_BASE = "https://vapi.256kb.top" # 本站 API 对外地址
HEADERS = {"User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36"}
async def _fetch_json(client: httpx.AsyncClient, url: str, params: dict | None = None) -> dict:
resp = await client.get(url, params=params, headers=HEADERS, timeout=20)
resp.raise_for_status()
body = resp.json()
if body.get("code") != 200 or not isinstance(body.get("data"), dict):
raise RuntimeError(f"上游响应异常: {url} -> {str(body)[:200]}")
return body["data"]
def _proxy_url(path: str) -> str:
"""把源站相对 m3u8 路径转为本站代理地址(域名轮换只改 SELF_API_BASE/上游常量)"""
return f"{SELF_API_BASE}/api/v1/m3u8/proxy?path={quote(path, safe='')}"
async def _import_drama(client: httpx.AsyncClient, db: AsyncSession, drama_id: int) -> tuple[int, int]:
"""导入单部短剧,返回 (新增集数, 跳过集数)"""
detail = await _fetch_json(client, f"{UPSTREAM}/api/v1/short-dramas/{drama_id}", {"productId": PRODUCT_ID})
record = (
await db.execute(
select(CollectionRecord).where(
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == drama_id
)
)
).scalar_one_or_none()
published_at = None
try:
published_at = datetime.fromisoformat(detail.get("publishedAt", "")) or None
except ValueError:
pass
series = await db.get(Series, record.series_id) if record else None
if series is None:
series = Series(
title=(detail.get("title") or f"短剧{drama_id}")[:100],
cover_url=f"{UPSTREAM}{detail.get('coverUrl', '')}",
description=(detail.get("description") or "")[:500],
author_id=AUTHOR_USER_ID,
category_id=CATEGORY_ID,
total_episodes=0,
view_count=int(detail.get("heatCount") or 0),
status=1,
)
db.add(series)
await db.flush()
if record: # 系列曾被删除:重挂映射
record.series_id = series.id
else:
db.add(CollectionRecord(source_key=SOURCE_KEY, source_id=drama_id, series_id=series.id))
await db.flush()
# 已有集数去重
existing = set(
(
await db.execute(
select(Video.episode_no).where(Video.series_id == series.id, Video.episode_no.is_not(None))
)
).scalars().all()
)
created = skipped = 0
for ep in detail.get("episodes", []):
ep_no = int(ep.get("episodeNo") or 0)
if not ep_no or ep_no in existing:
skipped += 1
continue
db.add(
Video(
user_id=AUTHOR_USER_ID,
category_id=CATEGORY_ID,
series_id=series.id,
episode_no=ep_no,
title=(ep.get("titleOverride") or ep.get("title") or f"第{ep_no}集")[:100],
description=(detail.get("description") or "")[:500],
cover_url=f"{UPSTREAM}{detail.get('coverUrl', '')}",
video_url=_proxy_url(ep.get("videoUrl", "")),
duration=int(ep.get("durationSec") or 0),
status=2,
published_at=published_at,
)
)
existing.add(ep_no)
created += 1
series.total_episodes = len(existing)
return created, skipped
async def sync_drama_by_id(db: AsyncSession, drama_id: int) -> dict:
"""手动同步单部短剧(新增或补充缺失集数);返回统计"""
async with httpx.AsyncClient(follow_redirects=True) as client:
created, skipped = await _import_drama(client, db, drama_id)
await db.commit()
return {"drama_id": drama_id, "episodes_created": created, "episodes_skipped": skipped}
async def sync_short_dramas(
db: AsyncSession,
max_pages: int = 0,
page_size: int = 20,
sort_by: str = "heat",
delay: float = 0.25,
) -> dict:
"""按列表页增量同步短剧;返回统计"""
stat = {"dramas_new": 0, "dramas_exist": 0, "episodes_created": 0, "episodes_skipped": 0, "pages": 0}
async with httpx.AsyncClient(follow_redirects=True) as client:
page = 1
while True:
data = await _fetch_json(
client,
f"{UPSTREAM}/api/v1/short-dramas",
{"productId": PRODUCT_ID, "sortBy": sort_by, "page": page, "size": page_size},
)
items = data.get("items", [])
stat["pages"] = page
logger.info("列表第 %s/%s 页,%s 部", page, data.get("totalPages"), len(items))
for item in items:
drama_id = int(item.get("id") or 0)
if not drama_id:
continue
exists = (
await db.execute(
select(CollectionRecord.id).where(
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == drama_id
)
)
).scalar_one_or_none()
if exists:
stat["dramas_exist"] += 1
continue
try:
created, skipped = await _import_drama(client, db, drama_id)
await db.commit()
stat["dramas_new"] += 1
stat["episodes_created"] += created
stat["episodes_skipped"] += skipped
logger.info("导入短剧 %s《%s》:新增 %s 集", drama_id, item.get("title"), created)
except Exception:
await db.rollback()
logger.exception("导入短剧 %s 失败,跳过", drama_id)
await asyncio.sleep(delay)
total_pages = int(data.get("totalPages") or 1)
if page >= total_pages or (max_pages and page >= max_pages):
break
page += 1
await asyncio.sleep(delay)
return stat
# ---------- 单视频采集(通用 URL 模板,后台自助配置的采集源)----------
def _abs_cover(item: dict) -> str:
"""封面转绝对地址:源站 /api/... 代理路径或 /uploads/... 静态路径"""
u = item.get("coverUrl") or ""
if u.startswith("http"):
return u
return f"{UPSTREAM}{u}" if u else ""
async def _import_video_item(db: AsyncSession, source: CollectionSource, item: dict) -> bool:
"""导入单条视频,已存在返回 False"""
vid = int(item.get("id") or 0)
if not vid or not (item.get("videoUrl") or "").endswith(".m3u8"):
return False
exists = (
await db.execute(
select(CollectionRecord.id).where(
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == vid
)
)
).scalar_one_or_none()
if exists:
return False
published_at = None
try:
published_at = datetime.fromisoformat(item.get("publishedAt", "")) or None
except ValueError:
pass
v = Video(
user_id=AUTHOR_USER_ID,
category_id=source.category_id,
title=(item.get("title") or f"视频{vid}")[:100],
description=(item.get("description") or "")[:500],
cover_url=_abs_cover(item)[:500],
video_url=_proxy_url(item["videoUrl"]),
duration=int(item.get("durationSec") or 0),
status=2,
view_count=int(item.get("viewCount") or 0),
like_count=int(item.get("likeCount") or 0),
favorite_count=int(item.get("favoriteCount") or 0),
published_at=published_at,
)
db.add(v)
await db.flush()
db.add(CollectionRecord(source_key=SOURCE_KEY, source_id=vid, video_id=v.id))
return True
async def sync_source(
db: AsyncSession,
source: CollectionSource,
max_pages: int = 0,
stop_after_existing: int = 30,
delay: float = 0.25,
) -> dict:
"""按源的 URL 模板翻页采集;连续 stop_after_existing 条已存在则提前停止(增量)"""
stat = {"created": 0, "skipped": 0, "pages": 0, "stopped_early": False}
consecutive_existing = 0
async with httpx.AsyncClient(follow_redirects=True) as client:
page = 1
while True:
url = source.list_url.replace("{{page}}", str(page))
data = await _fetch_json(client, url)
items = data.get("items", [])
if not items:
break
stat["pages"] = page
for item in items:
if await _import_video_item(db, source, item):
stat["created"] += 1
consecutive_existing = 0
else:
stat["skipped"] += 1
consecutive_existing += 1
if consecutive_existing >= stop_after_existing:
stat["stopped_early"] = True
break
await db.commit()
if stat["stopped_early"]:
break
total_pages = int(data.get("totalPages") or page)
if page >= total_pages or (max_pages and page >= max_pages):
break
page += 1
await asyncio.sleep(delay)
source.last_sync_at = datetime.now()
await db.commit()
return stat
+28
View File
@@ -160,9 +160,37 @@ async def get_feed(
) -> dict: ) -> dict:
if feed_type == "latest": if feed_type == "latest":
return await _feed_latest(db, identity, cursor, limit, category_id) return await _feed_latest(db, identity, cursor, limit, category_id)
if feed_type in ("liked", "viewed"):
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id)
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id) return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id)
async def _feed_ranked(
db: AsyncSession, identity: Identity, feed_type: str, cursor: str | None,
limit: int, category_id: int | None,
) -> dict:
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
col = Video.like_count if feed_type == "liked" else Video.view_count
stmt = await _base_pool_stmt(category_id)
c = _decode_cursor(cursor)
if c:
val, vid = int(c["n"]), int(c["i"])
stmt = stmt.where((col < val) | ((col == val) & (Video.id < vid)))
rows = list(
(await db.execute(stmt.order_by(col.desc(), Video.id.desc()).limit(limit + 1))).scalars()
)
page, rows = (len(rows) > limit), rows[:limit]
next_cursor = None
if page and rows:
last = rows[-1]
last_val = last.like_count if feed_type == "liked" else last.view_count
next_cursor = _encode_cursor({"n": last_val, "i": last.id})
return {
"next_cursor": next_cursor,
"list": await serialize_videos(db, rows, identity.user_id),
}
async def _base_pool_stmt(category_id: int | None): async def _base_pool_stmt(category_id: int | None):
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None)) stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
if category_id: if category_id:
+157
View File
@@ -0,0 +1,157 @@
"""一次性脚本:根据现有视频标题拆分关键词写入 tags / video_tags。
用法:
cd /root/project/video/server
.venv/bin/python scripts/build_tags_from_titles.py [--dry-run]
提取规则:
1. 番号(如 CAWD-601)及其系列前缀(CAWD)
2. 书名号/方括号内容(≤12 字,去掉「无码破解」「中文字幕」等标记词)
3. jieba TF-IDF 关键词 topK=6(过滤停用词、纯数字、单字)
"""
import argparse
import asyncio
import logging
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import jieba.analyse # noqa: E402
from sqlalchemy import select # noqa: E402
from app.database import SessionFactory, engine # noqa: E402
from app.models.video import Tag, Video, VideoTag # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger(__name__)
STOP = set(
"第 集 部 篇 全集 完结 中文字幕 无码 破解 无码破解 字幕 高清 最新 精选 视频 在线 观看 "
"一个 一起 没有 为了 开始 结果 只能 直接 就是 没想到 真爽".split()
)
CODE_RE = re.compile(r"[A-Za-z]{2,6}-\d{2,4}")
EPISODE_RE = re.compile(r"第[0-9一二三四五六七八九十百千零]+[集部期话卷章]")
PUNCT_RE = re.compile(r"[《》「」『』\[\]【】()()_\-::,,.。!!??…、/\\|~~\s]+")
BRACKET_RE = re.compile(r"[《「『\[【((]([^《》「」『』\[\]【】()()]{2,30})[》」』\]】))]")
def extract_keywords(title: str) -> list[str]:
kws: list[str] = []
# 1. 番号 + 系列前缀
for code in CODE_RE.findall(title.upper()):
kws.append(code)
prefix = code.split("-")[0]
if len(prefix) >= 2:
kws.append(prefix)
# 2. 书名号/括号内容(剧名/主题)
for inner in BRACKET_RE.findall(title):
inner = inner.strip()
if 2 <= len(inner) <= 12 and inner not in STOP and not CODE_RE.search(inner):
kws.append(inner)
# 3. jieba TF-IDF
t = EPISODE_RE.sub(" ", title)
t = PUNCT_RE.sub(" ", t)
for w in jieba.analyse.extract_tags(t, topK=6):
w = w.strip()
if not (2 <= len(w) <= 12):
continue
if w in STOP or re.fullmatch(r"\d+", w):
continue
kws.append(w)
# 去重保序(按 casefold 键,避免 MySQL 不区分大小写的唯一键冲突)
seen: set[str] = set()
out: list[str] = []
for k in kws:
if k.isascii():
k = k.upper()
key = k.casefold()
if key not in seen:
seen.add(key)
out.append(k)
return out
async def main(dry_run: bool) -> None:
async with SessionFactory() as db:
videos = (await db.execute(select(Video.id, Video.title))).all()
log.info("共 %d 个视频", len(videos))
tag_map: dict[str, int] = {} # name -> tag_id(含新建暂存)
video_count: dict[str, int] = {} # name -> 关联视频数
links: list[tuple[int, str]] = [] # (video_id, tag_name)
for vid, title in videos:
if not title:
continue
kws = extract_keywords(title)
for k in kws:
links.append((vid, k))
video_count[k] = video_count.get(k, 0) + 1
log.info("提取出 %d 个不同标签,%d 条视频-标签关联", len(video_count), len(links))
top = sorted(video_count.items(), key=lambda x: -x[1])[:15]
log.info("Top15 标签: %s", top)
if dry_run:
await engine.dispose()
return
# 预载已存在的 tags(运行中的后端也可能并发写 tags,全程幂等)
existing = (await db.execute(select(Tag.name, Tag.id))).all()
tag_map: dict[str, int] = {n.casefold(): i for n, i in existing}
# 写入 tags:INSERT IGNORE 防并发/重复,再取回 id
from sqlalchemy import text
new_names = [n[:20] for n in video_count if n[:20].casefold() not in tag_map]
for i in range(0, len(new_names), 500):
chunk = new_names[i:i + 500]
await db.execute(
text("INSERT IGNORE INTO tags (name, video_count) VALUES " +
",".join("(:n%d, 0)" % j for j in range(len(chunk)))),
{f"n{j}": v for j, v in enumerate(chunk)},
)
await db.flush()
existing = (await db.execute(select(Tag.name, Tag.id))).all()
tag_map = {n.casefold(): i for n, i in existing}
# 写入 video_tags:INSERT IGNORE(uk_video_tag 自动去重)
seen_link: set[tuple[int, int]] = set()
rows: list[dict] = []
for vid, name in links:
tid = tag_map.get(name[:20].casefold())
if tid is None or (vid, tid) in seen_link:
continue
seen_link.add((vid, tid))
rows.append({"v": vid, "t": tid})
for i in range(0, len(rows), 1000):
chunk = rows[i:i + 1000]
await db.execute(
text("INSERT IGNORE INTO video_tags (video_id, tag_id) VALUES " +
",".join("(:v%d, :t%d)" % (j, j) for j in range(len(chunk)))),
{**{f"v{j}": r["v"] for j, r in enumerate(chunk)},
**{f"t{j}": r["t"] for j, r in enumerate(chunk)}},
)
# 统一回算冗余计数
await db.execute(text(
"UPDATE tags t SET t.video_count = "
"(SELECT COUNT(*) FROM video_tags vt WHERE vt.tag_id = t.id)"
))
await db.commit()
log.info("写入完成:tags=%d(新增 %d),video_tags=%d", len(tag_map), len(new_names), len(rows))
await engine.dispose()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="只打印统计不写库")
args = parser.parse_args()
asyncio.run(main(args.dry_run))
+41
View File
@@ -0,0 +1,41 @@
"""短剧采集 CLI
用法:
.venv/bin/python scripts/collect_short_dramas.py # 全量增量同步
.venv/bin/python scripts/collect_short_dramas.py --pages 1 --size 3 # 试跑
.venv/bin/python scripts/collect_short_dramas.py --drama-id 130 # 手动更新单部
"""
import argparse
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.database import SessionFactory # noqa: E402
from app.services.collection_service import sync_drama_by_id, sync_short_dramas # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
async def main() -> None:
parser = argparse.ArgumentParser(description="短剧采集")
parser.add_argument("--drama-id", type=int, default=0, help="只同步指定源站短剧 id")
parser.add_argument("--pages", type=int, default=0, help="最多同步的列表页数,0=全部")
parser.add_argument("--size", type=int, default=20, help="每页条数")
parser.add_argument("--sort", choices=["heat", "latest"], default="heat")
parser.add_argument("--delay", type=float, default=0.25, help="上游请求间隔秒")
args = parser.parse_args()
async with SessionFactory() as db:
if args.drama_id:
stat = await sync_drama_by_id(db, args.drama_id)
else:
stat = await sync_short_dramas(
db, max_pages=args.pages, page_size=args.size, sort_by=args.sort, delay=args.delay
)
print("同步完成:", stat)
if __name__ == "__main__":
asyncio.run(main())