1
This commit is contained in:
@@ -15,3 +15,8 @@ export function getUserDetail(userId) {
|
||||
export function setUserStatus(userId, 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)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ const menuConfig = [
|
||||
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
|
||||
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
||||
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
|
||||
{ title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] },
|
||||
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
|
||||
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
|
||||
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
|
||||
|
||||
@@ -58,6 +58,12 @@ const routes = [
|
||||
component: () => import('@/views/SeriesManage.vue'),
|
||||
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
|
||||
},
|
||||
{
|
||||
path: 'collect-sources',
|
||||
name: 'collect-sources',
|
||||
component: () => import('@/views/CollectSources.vue'),
|
||||
meta: { title: '采集源管理', icon: 'Download', roles: [] },
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'category-manage',
|
||||
|
||||
@@ -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>
|
||||
@@ -7,6 +7,7 @@
|
||||
<div>
|
||||
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,6 +24,12 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
@@ -31,9 +38,11 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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 }">
|
||||
<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)">
|
||||
{{ row.status === 1 ? '隐藏' : '恢复' }}
|
||||
</el-button>
|
||||
@@ -116,6 +125,8 @@ const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const submitting = ref(false)
|
||||
const coverUploading = ref(false)
|
||||
const syncAllLoading = ref(false)
|
||||
const syncingId = ref(0)
|
||||
|
||||
const form = reactive({
|
||||
title: '', cover_url: '', description: '', category_id: 0,
|
||||
@@ -224,6 +235,28 @@ async function del(row) {
|
||||
load()
|
||||
} 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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -48,9 +48,18 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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 }">
|
||||
<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
|
||||
v-if="row.status === 1"
|
||||
link
|
||||
@@ -84,16 +93,52 @@
|
||||
@current-change="load"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { listUsers, setUserStatus } from '@/api/users'
|
||||
import { listUsers, resetUserPassword, setUserStatus } from '@/api/users'
|
||||
import { askReason } from '@/utils/reason'
|
||||
|
||||
const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' }
|
||||
@@ -154,5 +199,43 @@ async function toggleStatus(row, target) {
|
||||
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)
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,13 @@ plugins {
|
||||
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 {
|
||||
namespace = "com.jsnbhh.video_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -30,11 +40,23 @@ android {
|
||||
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 {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// key.properties 存在时用正式签名;否则回退 debug(保证 CI/新环境能编译)
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -199,49 +199,52 @@ class _ErrorInterceptor extends Interceptor {
|
||||
final apiError = err.error;
|
||||
final status = err.response?.statusCode;
|
||||
|
||||
// 401:静默刷新重试一次
|
||||
// 401:静默刷新重试一次;无法刷新(无 refresh / 刷新被拒 / 重试仍 401)
|
||||
// 统一按会话失效处理,否则 APP 会卡在"本地有 user 但 access 已过期"的
|
||||
// 僵尸登录态:我的页正常显示资料卡,点会员/钱包等全部 401 加载失败
|
||||
if (status == 401 &&
|
||||
err.requestOptions.extra['retried'] != true &&
|
||||
TokenStore.refreshToken != null) {
|
||||
String? newToken;
|
||||
try {
|
||||
newToken = await _refreshToken();
|
||||
} on ApiException catch (refreshErr) {
|
||||
// 刷新请求本身网络失败:不清登录态,透传错误
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: refreshErr,
|
||||
));
|
||||
}
|
||||
if (newToken != null) {
|
||||
err.requestOptions.extra['retried'] != true) {
|
||||
if (TokenStore.refreshToken != null) {
|
||||
String? newToken;
|
||||
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 失败:视为网络异常,不清登录态
|
||||
newToken = await _refreshToken();
|
||||
} on ApiException catch (refreshErr) {
|
||||
// 刷新请求本身网络失败:不清登录态,透传错误
|
||||
return handler.next(DioException(
|
||||
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();
|
||||
ApiClient.onSessionExpired?.call();
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:async';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../media/local_media_server.dart';
|
||||
|
||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||
class PlayerSlot {
|
||||
PlayerSlot(this.player);
|
||||
@@ -44,6 +46,20 @@ class FeedPlayerPool {
|
||||
int _currentIndex = 0;
|
||||
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 槽位并订阅各自进度流)
|
||||
void init() {
|
||||
for (var i = 0; i < slotCount; i++) {
|
||||
@@ -65,26 +81,40 @@ class FeedPlayerPool {
|
||||
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
||||
|
||||
/// 绑定窗口: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 / 完成回调都依赖它)
|
||||
_currentIndex = index;
|
||||
|
||||
// 窗口内应保留的槽位集合 [index-1, index, index+1]
|
||||
// 窗口内应保留的槽位集合 [index, index+1]
|
||||
final activeSet = <int>{};
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
for (var offset = 0; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
||||
}
|
||||
|
||||
// 2. 立即静音(同步执行,不等后续网络 open):
|
||||
// - 出窗槽位:stop(断音 + 清缓存),并复位
|
||||
// - 窗口内槽位:全部 pause(含刚划走的上一页,防止切页期间串音)
|
||||
// - 出窗槽位:stop(断音 + 断缓存 + 清媒体),并复位
|
||||
// - 窗口内非当前槽位:pause(下一页预缓冲不自动播)
|
||||
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
|
||||
for (var s = 0; s < slotCount; s++) {
|
||||
final slot = _slots[s];
|
||||
if (slot.videoId < 0) continue;
|
||||
if (activeSet.contains(s)) {
|
||||
try { slot.player.pause(); } catch (_) {}
|
||||
if (s != slotOf(index)) {
|
||||
try { slot.player.pause(); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
unawaited(slot.player.stop());
|
||||
slot.videoId = -1;
|
||||
@@ -93,7 +123,7 @@ class FeedPlayerPool {
|
||||
}
|
||||
|
||||
// 3. 窗口内槽位按需加载(open 完才 play)
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
for (var offset = 0; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
final slot = _slots[slotOf(i)];
|
||||
@@ -102,14 +132,44 @@ class FeedPlayerPool {
|
||||
slot.videoId = item.videoId;
|
||||
slot.completeNotified = false;
|
||||
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();
|
||||
}
|
||||
|
||||
/// 挂起(切出首页底部 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 回调
|
||||
void _hookSlot(PlayerSlot slot) {
|
||||
var lastPosition = Duration.zero;
|
||||
@@ -135,7 +195,7 @@ class FeedPlayerPool {
|
||||
}
|
||||
}
|
||||
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
|
||||
if (slot == currentSlot) {
|
||||
if (slot == currentSlot && !_suspended) {
|
||||
slot.player.seek(Duration.zero);
|
||||
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 时调用)
|
||||
Future<void> dispose() async {
|
||||
for (final sub in _subs) {
|
||||
|
||||
+27
-1
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/search_history_store.dart';
|
||||
import 'core/storage/watch_dedup_store.dart';
|
||||
@@ -22,8 +24,32 @@ Future<void> main() async {
|
||||
await SearchHistoryStore.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 {
|
||||
|
||||
@@ -36,6 +36,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
state = state.copyWith(restored: true);
|
||||
return;
|
||||
}
|
||||
// 数据完整性防线:有 access 无 refresh 是不完整登录态(2h 后必然 401
|
||||
// 且无法静默刷新),直接清理回游客态引导重新登录,避免僵尸登录态
|
||||
if (TokenStore.refreshToken == null || TokenStore.refreshToken!.isEmpty) {
|
||||
await TokenStore.clear();
|
||||
state = state.copyWith(restored: true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final user = await UserService.fetchMe();
|
||||
state = AuthState(user: user, restored: true);
|
||||
|
||||
@@ -14,7 +14,9 @@ import 'auth_provider.dart';
|
||||
enum FeedStrategy {
|
||||
recommend('recommend', '推荐'),
|
||||
latest('latest', '最新'),
|
||||
hot('hot', '热度');
|
||||
hot('hot', '热度'),
|
||||
liked('liked', '口碑'),
|
||||
viewed('viewed', '热播');
|
||||
|
||||
const FeedStrategy(this.apiValue, this.label);
|
||||
|
||||
@@ -22,6 +24,15 @@ enum FeedStrategy {
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Feed 顶部当前选中的策略:HomeShell 切底部 Tab 回来时,
|
||||
/// 只恢复该策略对应的播放器池(其余池保持挂起,防止幽灵声)
|
||||
final activeFeedStrategyProvider =
|
||||
StateProvider<FeedStrategy>((_) => FeedStrategy.recommend);
|
||||
|
||||
/// 首页底部 Tab 当前是否可见:App 回前台时仅在可见状态下恢复播放
|
||||
/// (否则在"我的"等页面退后台再回来会误恢复声音)
|
||||
final feedTabVisibleProvider = StateProvider<bool>((_) => true);
|
||||
|
||||
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
|
||||
sealed class 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)
|
||||
void onPageChanged(int index) {
|
||||
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../screens/assets/assets_screen.dart';
|
||||
import '../screens/assets/withdraw_screen.dart';
|
||||
import '../screens/auth/login_screen.dart';
|
||||
import '../screens/auth/register_screen.dart';
|
||||
import '../screens/downloads/downloads_screen.dart';
|
||||
import '../screens/home/home_shell.dart';
|
||||
import '../screens/membership/membership_screen.dart';
|
||||
import '../screens/notification/notifications_screen.dart';
|
||||
@@ -94,6 +95,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (_, __) => VideoGridScreen.favorites(),
|
||||
),
|
||||
GoRoute(path: '/mine/history', builder: (_, __) => const HistoryScreen()),
|
||||
// 已下载(离线可看)
|
||||
GoRoute(
|
||||
path: '/mine/downloads',
|
||||
builder: (_, __) => const DownloadsScreen(),
|
||||
),
|
||||
// 编辑资料 / 头像(页面内兜底未登录引导)
|
||||
GoRoute(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,30 +32,46 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 登记当前策略,HomeShell 切回首页时只恢复这个策略的播放器池
|
||||
ref.read(activeFeedStrategyProvider.notifier).state = _strategy;
|
||||
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
|
||||
_eventSub = ref
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
// 从登录页等返回时恢复播放:provider 全局常驻保留了列表,
|
||||
// 但槽位已在 dispose 时 stopAll,需重新绑定(冷启动列表为空时为 no-op)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.read(feedProvider(_strategy).notifier).rebindCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_eventSub?.cancel();
|
||||
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
|
||||
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
|
||||
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 退后台暂停、回前台恢复播放
|
||||
/// App 退后台挂起、回前台恢复播放(suspend/resume 防在途 open 竞态)
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
notifier.pool.pauseCurrent();
|
||||
notifier.pool.suspend();
|
||||
} 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) {
|
||||
if (next == _strategy) return;
|
||||
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池
|
||||
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent();
|
||||
// 挂起旧策略池(防在途 open 完成后自动出声)
|
||||
ref.read(feedProvider(_strategy).notifier).pool.suspend();
|
||||
setState(() => _strategy = next);
|
||||
ref.read(activeFeedStrategyProvider.notifier).state = next;
|
||||
_eventSub?.cancel();
|
||||
_eventSub = ref
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
_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,
|
||||
onFavorite: notifier.toggleFavorite,
|
||||
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:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../core/media/download_manager.dart';
|
||||
import '../../../core/player/feed_player_pool.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/video_item.dart';
|
||||
@@ -25,6 +26,7 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
required this.onLike,
|
||||
this.onFavorite,
|
||||
this.onComment,
|
||||
this.onReturnFromPlayer,
|
||||
});
|
||||
|
||||
final VideoItem item;
|
||||
@@ -35,6 +37,9 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
final void Function(int videoId)? onFavorite;
|
||||
final void Function(VideoItem item)? onComment;
|
||||
|
||||
/// 从全屏播放器返回后的恢复回调(重新绑定 Feed 当前页)
|
||||
final VoidCallback? onReturnFromPlayer;
|
||||
|
||||
@override
|
||||
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
|
||||
}
|
||||
@@ -89,7 +94,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
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),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -165,8 +173,38 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
_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(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => DraggableScrollableSheet(
|
||||
builder: (_) => Theme(
|
||||
data: ThemeData.light(),
|
||||
child: DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
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,
|
||||
author: widget.item.author,
|
||||
);
|
||||
// 先暂停 Feed 页播放器,再打开 SimplePlayerScreen
|
||||
widget.pool.pauseExcept(slotIndex: -1);
|
||||
// 彻底停止 Feed 层播放器:stopAll 断音 + 断缓存下载(pause 不停止缓冲)
|
||||
widget.pool.stopAll();
|
||||
// ignore: use_build_context_synchronously
|
||||
context.push('/player', extra: newItem);
|
||||
context.push('/player', extra: newItem).then((_) {
|
||||
// 从播放器返回:恢复 Feed 当前页(槽位已清空,需重新绑定)
|
||||
if (mounted) widget.onReturnFromPlayer?.call();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -327,6 +371,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
slotIndex: widget.slotIndex,
|
||||
coverUrl: widget.item.coverUrl,
|
||||
isCurrent: widget.isCurrent,
|
||||
durationSeconds: widget.item.duration,
|
||||
),
|
||||
|
||||
// 双击爱心动画
|
||||
|
||||
@@ -18,6 +18,13 @@ class VideoInfoSection extends StatefulWidget {
|
||||
class _VideoInfoSectionState extends State<VideoInfoSection> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
@@ -37,7 +44,7 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 标题 + #标签(点击标签跳转标签视频页)
|
||||
// 标题 + 时长 + #标签(点击标签跳转标签视频页)
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 6,
|
||||
@@ -52,6 +59,25 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
|
||||
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)
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
|
||||
|
||||
@@ -14,6 +14,7 @@ class VideoPlayerView extends StatefulWidget {
|
||||
required this.slotIndex,
|
||||
required this.coverUrl,
|
||||
required this.isCurrent,
|
||||
this.durationSeconds = 0,
|
||||
});
|
||||
|
||||
final FeedPlayerPool pool;
|
||||
@@ -23,6 +24,10 @@ class VideoPlayerView extends StatefulWidget {
|
||||
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
|
||||
final bool isCurrent;
|
||||
|
||||
/// DB 里的视频时长(秒):流 duration 异常(如被当直播流报 0)时兜底,
|
||||
/// 保证进度条/拖动可用
|
||||
final int durationSeconds;
|
||||
|
||||
@override
|
||||
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 {
|
||||
if (_seeking) return _seekValue;
|
||||
final total = _duration.inMilliseconds;
|
||||
final total = _totalMs;
|
||||
if (total <= 0) return 0;
|
||||
final cur = _position.inMilliseconds.clamp(0, total);
|
||||
return cur / total;
|
||||
@@ -66,7 +77,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
||||
|
||||
void _onSeekEnd(double v) {
|
||||
setState(() => _seeking = false);
|
||||
final totalMs = _duration.inMilliseconds;
|
||||
final totalMs = _totalMs;
|
||||
if (totalMs > 0) {
|
||||
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
|
||||
}
|
||||
@@ -120,7 +131,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
||||
),
|
||||
),
|
||||
|
||||
// 抖音风格细进度条(贴底,常驻)
|
||||
// 抖音风格细进度条(贴底,常驻;拖动时显示目标时间气泡)
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: _ThinProgressBar(
|
||||
@@ -128,6 +139,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
||||
onChanged: _onSeek,
|
||||
onChangeStart: _onSeekStart,
|
||||
onChangeEnd: _onSeekEnd,
|
||||
totalMs: _totalMs,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -141,16 +153,29 @@ class _ThinProgressBar extends StatefulWidget {
|
||||
required this.onChanged,
|
||||
required this.onChangeStart,
|
||||
required this.onChangeEnd,
|
||||
this.totalMs = 0,
|
||||
});
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
final ValueChanged<double> onChangeStart;
|
||||
final ValueChanged<double> onChangeEnd;
|
||||
|
||||
/// 视频总时长(毫秒):拖动气泡显示目标位置用时/分/秒
|
||||
final int totalMs;
|
||||
|
||||
@override
|
||||
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> {
|
||||
bool _dragging = false;
|
||||
double _dragValue = 0;
|
||||
@@ -187,15 +212,44 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
|
||||
setState(() => _dragging = false);
|
||||
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
|
||||
},
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
height: 32, // 触摸热区 32px(视觉 2px,好点)
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: CustomPaint(
|
||||
painter: _ProgressPainter(
|
||||
value: _dragging ? _dragValue : widget.value,
|
||||
dragging: _dragging,
|
||||
),
|
||||
size: Size(w, _dragging ? 20 : 4),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -26,16 +28,24 @@ class _HomeShellState extends ConsumerState<HomeShell> {
|
||||
|
||||
void _onTabSelected(int i) {
|
||||
if (i == _index) return;
|
||||
// 切到非首页:暂停所有 Feed 策略的播放器池(IndexedStack 保活,否则声音会继续)
|
||||
// 切到非首页:挂起所有 Feed 策略的播放器池(IndexedStack 保活,
|
||||
// suspend 会同时阻止在途 open 完成后自动播放,杜绝后台幽灵声)
|
||||
if (i != 0) {
|
||||
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);
|
||||
// 切回首页:恢复当前策略播放(FeedScreen 内部 _strategy 是 recommend)
|
||||
// 切回首页:只恢复当前选中策略的池(其余保持挂起)
|
||||
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();
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:share_plus/share_plus.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/storage/watch_dedup_store.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
@@ -100,7 +102,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -317,7 +321,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
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),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -395,21 +402,64 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
_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 {
|
||||
if (_seeking) return _seekValue;
|
||||
if (_duration.inMilliseconds <= 0) return 0;
|
||||
return (_position.inMilliseconds / _duration.inMilliseconds)
|
||||
if (_effectiveDuration.inMilliseconds <= 0) return 0;
|
||||
return (_position.inMilliseconds / _effectiveDuration.inMilliseconds)
|
||||
.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) {
|
||||
setState(() {
|
||||
_seeking = true;
|
||||
@@ -423,9 +473,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
|
||||
void _onSeekEnd(double v) {
|
||||
setState(() => _seeking = false);
|
||||
if (_duration.inMilliseconds > 0) {
|
||||
if (_effectiveDuration.inMilliseconds > 0) {
|
||||
final target = Duration(
|
||||
milliseconds: (v * _duration.inMilliseconds).round(),
|
||||
milliseconds: (v * _effectiveDuration.inMilliseconds).round(),
|
||||
);
|
||||
_player.seek(target);
|
||||
}
|
||||
@@ -591,14 +641,45 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// ---------- 进度条:抖音风格一条细线 ----------
|
||||
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: _ThinProgressBar(
|
||||
value: _sliderValue,
|
||||
onChanged: _onSeek,
|
||||
onChangeStart: _onSeekStart,
|
||||
onChangeEnd: _onSeekEnd,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 时间刻度:左当前(拖动时为目标位置),右总时长
|
||||
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(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => DraggableScrollableSheet(
|
||||
builder: (_) => Theme(
|
||||
data: ThemeData.light(),
|
||||
child: DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
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.onChangeStart,
|
||||
required this.onChangeEnd,
|
||||
this.totalMs = 0,
|
||||
});
|
||||
|
||||
final double value;
|
||||
@@ -764,10 +849,22 @@ class _ThinProgressBar extends StatefulWidget {
|
||||
final ValueChanged<double> onChangeStart;
|
||||
final ValueChanged<double> onChangeEnd;
|
||||
|
||||
/// 视频总时长(毫秒):拖动时气泡显示目标位置用时/分/秒
|
||||
final int totalMs;
|
||||
|
||||
@override
|
||||
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> {
|
||||
bool _dragging = false;
|
||||
double _dragValue = 0;
|
||||
@@ -805,19 +902,48 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
|
||||
_start(d.localPosition, w);
|
||||
},
|
||||
onTapUp: (_) => _end(w),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: _dragging ? 4 : 2,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _ProgressPainter(
|
||||
value: _dragging ? _dragValue : widget.value,
|
||||
dragging: _dragging,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -142,6 +142,11 @@ class _MineScreenState extends ConsumerState<MineScreen> {
|
||||
label: '观看历史',
|
||||
onTap: () => context.push('/mine/history'),
|
||||
),
|
||||
_Entry(
|
||||
icon: Icons.download_outlined,
|
||||
label: '已下载',
|
||||
onTap: () => context.push('/mine/downloads'),
|
||||
),
|
||||
_Entry(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: '钱包',
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@ name: video_app
|
||||
description: "短视频应用(Flutter,iOS/Android 双端)"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.2.0+13
|
||||
version: 1.3.7+23
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -59,6 +59,11 @@ dependencies:
|
||||
image_picker: ^1.1.2
|
||||
# 外部浏览器/商店跳转(强制更新下载页)
|
||||
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
|
||||
fluwx: ^5.7.7
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ VERSION_CODE="${PUBSPEC_VERSION##*+}" # 2
|
||||
echo " pubspec: $VERSION_NAME versionCode=$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')) \
|
||||
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')) \
|
||||
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "unknown")
|
||||
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
|
||||
echo
|
||||
|
||||
# 停掉 Gradle Daemon(编译残留常驻 ~3GB 内存,下次编译会自动重启)
|
||||
echo "▶ 清理 Gradle Daemon..."
|
||||
(cd "$APP_DIR/android" && ./gradlew --stop >/dev/null 2>&1) || true
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. 部署 APK
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -84,15 +88,21 @@ if [ -n "${1:-}" ]; then
|
||||
NEW_CODE="$1"
|
||||
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"
|
||||
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)
|
||||
VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置')
|
||||
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW();
|
||||
" 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 "✅ 发版完成"
|
||||
|
||||
@@ -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.
@@ -5,6 +5,8 @@
|
||||
- operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送
|
||||
- 其余接口仅 super
|
||||
"""
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
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=["管理后台"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 角色依赖:super 自动放行
|
||||
require_reviewer = require_roles("reviewer")
|
||||
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)))
|
||||
|
||||
|
||||
@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")
|
||||
async def adjust_points(req: schema.PointsAdjustReq, request: Request,
|
||||
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(
|
||||
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)).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({
|
||||
"total": total,
|
||||
"list": [{
|
||||
@@ -565,6 +591,7 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
||||
"description": s.description or "",
|
||||
"category_id": s.category_id or 0,
|
||||
"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"),
|
||||
} for s in rows],
|
||||
})
|
||||
@@ -664,6 +691,306 @@ async def bind_video_series(video_id: int, req: _VideoSeriesBind,
|
||||
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
|
||||
|
||||
@@ -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": "*"},
|
||||
)
|
||||
@@ -25,7 +25,7 @@ UPLOAD_RATE_LIMIT = 5
|
||||
@router.get("/feed")
|
||||
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
|
||||
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),
|
||||
identity: Identity = Depends(get_identity)):
|
||||
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
|
||||
|
||||
+2
-1
@@ -69,8 +69,9 @@ from app.config import BASE_DIR
|
||||
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(users.router, prefix="/api/v1")
|
||||
app.include_router(videos.router, prefix="/api/v1")
|
||||
|
||||
Binary file not shown.
@@ -114,3 +114,37 @@ class Series(Base):
|
||||
Index("idx_series_cat_status", "category_id", "status"),
|
||||
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.
@@ -37,6 +37,13 @@ class PointsAdjustReq(BaseModel):
|
||||
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):
|
||||
"""人工补单(11.12)"""
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -462,6 +462,26 @@ async def set_user_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(
|
||||
|
||||
@@ -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
|
||||
@@ -160,9 +160,37 @@ async def get_feed(
|
||||
) -> dict:
|
||||
if feed_type == "latest":
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
|
||||
if category_id:
|
||||
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user