411 lines
12 KiB
Go
411 lines
12 KiB
Go
// promotion-import:将 web/promotion/视频发布 下中文路径素材导入到 uploads + site_assets(与后台上传到 promotion/social 一致)
|
||
//
|
||
// 用法(在项目 server 目录,已配置 server/.env 中 MONGODB_URI / MONGODB_DB):
|
||
//
|
||
// go run -mod=vendor ./cmd/promotion-import/ -site=站点MongoID
|
||
//
|
||
// 或指定路径:
|
||
//
|
||
// go run -mod=vendor ./cmd/promotion-import/ -site=xxx -src=/www/yh_web/web/promotion/视频发布 -upload=/www/yh_web/data/uploads
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"yh_web/server/config"
|
||
|
||
"github.com/joho/godotenv"
|
||
"go.mongodb.org/mongo-driver/v2/bson"
|
||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||
)
|
||
|
||
func loadEnv() {
|
||
wd, _ := os.Getwd()
|
||
serverDir := wd
|
||
if !strings.HasSuffix(filepath.Clean(wd), "server") {
|
||
serverDir = filepath.Join(wd, "server")
|
||
}
|
||
envPath := filepath.Clean(filepath.Join(serverDir, ".env"))
|
||
if _, err := os.Stat(envPath); err == nil {
|
||
_ = godotenv.Load(envPath)
|
||
log.Printf("已加载: %s", envPath)
|
||
}
|
||
}
|
||
|
||
func mimeForExt(ext string) string {
|
||
switch strings.ToLower(ext) {
|
||
case ".mov":
|
||
return "video/quicktime"
|
||
case ".mp4":
|
||
return "video/mp4"
|
||
case ".webm":
|
||
return "video/webm"
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".png":
|
||
return "image/png"
|
||
default:
|
||
return "application/octet-stream"
|
||
}
|
||
}
|
||
|
||
// importRule:按顺序尝试 SrcRels;再 FallbackScanDir 下智能选文件;EpisodeScan 在非空时扫描 视频发布 下含「实例+一/二」的子目录(兼容半角括号、文件夹名略有差异)
|
||
type importRule struct {
|
||
SrcRels []string
|
||
Dst string
|
||
FallbackScanDir string
|
||
EpisodeScan string // "一" 或 "二"
|
||
}
|
||
|
||
// 与 sync-video-assets-to-social.sh 对齐,并增加备选路径(线上常见「实例(一)」内不叫宣传片.mov 的情况)
|
||
var mappings = []importRule{
|
||
{[]string{
|
||
"宇恒一号操作计算软件实例(一)/宣传片-封面.jpg",
|
||
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg",
|
||
"宇恒一号操作计算软件实例(一)/宣传片-封面.jpg",
|
||
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg",
|
||
}, "video-calc-demo-1-cover.jpg", "宇恒一号操作计算软件实例(一)", "一"},
|
||
{[]string{
|
||
"宇恒一号操作计算软件实例(一)/宣传片.mov",
|
||
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov",
|
||
"宇恒一号操作计算软件实例(一)/宣传片.mov",
|
||
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov",
|
||
}, "video-calc-demo-1.mov", "宇恒一号操作计算软件实例(一)", "一"},
|
||
{[]string{
|
||
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg",
|
||
"宇恒一号操作计算软件实例(二)/宣传片-封面.jpg",
|
||
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg",
|
||
"宇恒一号操作计算软件实例(二)/宣传片-封面.jpg",
|
||
}, "video-calc-demo-2-cover.jpg", "宇恒一号操作计算软件实例(二)", "二"},
|
||
{[]string{
|
||
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov",
|
||
"宇恒一号操作计算软件实例(二)/宣传片.mov",
|
||
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov",
|
||
"宇恒一号操作计算软件实例(二)/宣传片.mov",
|
||
}, "video-calc-demo-2.mov", "宇恒一号操作计算软件实例(二)", "二"},
|
||
{[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg"}, "video-aiword-cover.jpg", "宇恒一号AIWord简介", ""},
|
||
{[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介.mov"}, "video-aiword.mov", "宇恒一号AIWord简介", ""},
|
||
{[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg"}, "video-voice-office-cover.jpg", "宇恒一号语音办公实例", ""},
|
||
{[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例.mov"}, "video-voice-office.mov", "宇恒一号语音办公实例", ""},
|
||
{[]string{"宇恒一号,AI 全自动办发票/宇恒一号,AI 全自动办发票-封面.jpg"}, "video-invoice-ai-cover.jpg", "宇恒一号,AI 全自动办发票", ""},
|
||
{[]string{"宇恒一号,AI 全自动办发票/宇恒一号,AI 全自动办发票.mov"}, "video-invoice-ai.mov", "宇恒一号,AI 全自动办发票", ""},
|
||
}
|
||
|
||
// 在 视频发布 下找名称同时含「实例」与「(一)」或「(一)」等的子目录(排除另一集)
|
||
func discoverEpisodeDir(videoPublish, episode string) (dirName string, ok bool) {
|
||
full := "(" + episode + ")"
|
||
half := "(" + episode + ")"
|
||
entries, err := os.ReadDir(videoPublish)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
var hits []string
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
n := e.Name()
|
||
if !strings.Contains(n, "实例") {
|
||
continue
|
||
}
|
||
marked := strings.Contains(n, full) || strings.Contains(n, half)
|
||
if !marked {
|
||
continue
|
||
}
|
||
if episode == "一" && (strings.Contains(n, "(二)") || strings.Contains(n, "(二)")) {
|
||
continue
|
||
}
|
||
if episode == "二" && (strings.Contains(n, "(一)") || strings.Contains(n, "(一)")) {
|
||
continue
|
||
}
|
||
hits = append(hits, n)
|
||
}
|
||
if len(hits) == 0 {
|
||
return "", false
|
||
}
|
||
if len(hits) == 1 {
|
||
return hits[0], true
|
||
}
|
||
for _, h := range hits {
|
||
if strings.Contains(h, "软件") {
|
||
return h, true
|
||
}
|
||
}
|
||
return hits[0], true
|
||
}
|
||
|
||
func pickMediaInDir(videoPublish, dirName string, dstFile string) (absPath, relChosen string, ok bool) {
|
||
ext := strings.ToLower(filepath.Ext(dstFile))
|
||
if ext != ".mov" && ext != ".jpg" && ext != ".jpeg" {
|
||
return "", "", false
|
||
}
|
||
dir := filepath.Join(videoPublish, filepath.FromSlash(dirName))
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return "", "", false
|
||
}
|
||
type cand struct {
|
||
name string
|
||
size int64
|
||
}
|
||
var movs, imgs []cand
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
ne := strings.ToLower(filepath.Ext(e.Name()))
|
||
p := filepath.Join(dir, e.Name())
|
||
st, err := os.Stat(p)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
sz := st.Size()
|
||
if ne == ".mov" {
|
||
movs = append(movs, cand{e.Name(), sz})
|
||
}
|
||
if ne == ".jpg" || ne == ".jpeg" {
|
||
imgs = append(imgs, cand{e.Name(), sz})
|
||
}
|
||
}
|
||
pickMov := func() (string, bool) {
|
||
if len(movs) == 0 {
|
||
return "", false
|
||
}
|
||
best := movs[0]
|
||
for _, c := range movs[1:] {
|
||
if c.size > best.size {
|
||
best = c
|
||
}
|
||
}
|
||
return best.name, true
|
||
}
|
||
pickImg := func() (string, bool) {
|
||
if len(imgs) == 0 {
|
||
return "", false
|
||
}
|
||
for _, c := range imgs {
|
||
if strings.Contains(c.name, "封面") {
|
||
return c.name, true
|
||
}
|
||
}
|
||
best := imgs[0]
|
||
for _, c := range imgs[1:] {
|
||
if c.size > best.size {
|
||
best = c
|
||
}
|
||
}
|
||
return best.name, true
|
||
}
|
||
var name string
|
||
var found bool
|
||
switch ext {
|
||
case ".mov":
|
||
name, found = pickMov()
|
||
case ".jpg", ".jpeg":
|
||
name, found = pickImg()
|
||
default:
|
||
return "", "", false
|
||
}
|
||
if !found {
|
||
return "", "", false
|
||
}
|
||
rel := filepath.ToSlash(filepath.Join(dirName, name))
|
||
return filepath.Join(videoPublish, filepath.FromSlash(rel)), rel, true
|
||
}
|
||
|
||
func resolveSourceFile(videoPublish string, rule importRule) (absPath, relChosen string, ok bool) {
|
||
for _, rel := range rule.SrcRels {
|
||
p := filepath.Join(videoPublish, filepath.FromSlash(rel))
|
||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||
return p, rel, true
|
||
}
|
||
}
|
||
tryDirs := []string{}
|
||
if rule.FallbackScanDir != "" {
|
||
tryDirs = append(tryDirs, rule.FallbackScanDir)
|
||
}
|
||
if rule.EpisodeScan != "" {
|
||
if d, ok := discoverEpisodeDir(videoPublish, rule.EpisodeScan); ok {
|
||
// 避免与固定目录重复
|
||
dup := false
|
||
for _, x := range tryDirs {
|
||
if x == d {
|
||
dup = true
|
||
break
|
||
}
|
||
}
|
||
if !dup {
|
||
tryDirs = append(tryDirs, d)
|
||
}
|
||
}
|
||
}
|
||
for _, dirName := range tryDirs {
|
||
if abs, rel, ok := pickMediaInDir(videoPublish, dirName, rule.Dst); ok {
|
||
return abs, rel, true
|
||
}
|
||
}
|
||
return "", "", false
|
||
}
|
||
|
||
func main() {
|
||
loadEnv()
|
||
|
||
siteID := flag.String("site", "", "站点 MongoDB ObjectID(必填,与 /web/routes 的 site_id 一致)")
|
||
srcRoot := flag.String("src", "", "「视频发布」目录绝对路径;默认尝试项目 web/promotion/视频发布")
|
||
uploadRoot := flag.String("upload", "", "上传根目录(内含 sites/);默认 data/uploads 或环境变量 UPLOAD_DIR")
|
||
dryRun := flag.Bool("dry-run", false, "只打印计划,不写盘、不写库")
|
||
flag.Parse()
|
||
|
||
if strings.TrimSpace(*siteID) == "" {
|
||
log.Fatal("请指定 -site=站点ID")
|
||
}
|
||
|
||
wd, _ := os.Getwd()
|
||
projectRoot := wd
|
||
if strings.HasSuffix(filepath.Clean(wd), "server") {
|
||
projectRoot = filepath.Join(wd, "..")
|
||
}
|
||
projectRoot = filepath.Clean(projectRoot)
|
||
|
||
videoPublish := *srcRoot
|
||
if videoPublish == "" {
|
||
videoPublish = filepath.Join(projectRoot, "web", "promotion", "视频发布")
|
||
}
|
||
videoPublish = filepath.Clean(videoPublish)
|
||
|
||
uploadDir := *uploadRoot
|
||
if uploadDir == "" {
|
||
uploadDir = os.Getenv("UPLOAD_DIR")
|
||
}
|
||
if uploadDir == "" {
|
||
uploadDir = filepath.Join(projectRoot, "data", "uploads")
|
||
}
|
||
uploadDir = filepath.Clean(uploadDir)
|
||
|
||
mongoURI := os.Getenv("MONGODB_URI")
|
||
if mongoURI == "" {
|
||
mongoURI = "mongodb://localhost:27017"
|
||
}
|
||
if dbName := os.Getenv("MONGODB_DB"); dbName != "" {
|
||
config.DBName = dbName
|
||
}
|
||
|
||
if *dryRun {
|
||
log.Printf("[dry-run] 视频发布源: %s", videoPublish)
|
||
log.Printf("[dry-run] 上传根: %s", uploadDir)
|
||
log.Printf("[dry-run] site_id: %s", *siteID)
|
||
}
|
||
|
||
if !*dryRun {
|
||
if err := config.ConnectMongoDB(mongoURI); err != nil {
|
||
log.Fatalf("MongoDB: %v", err)
|
||
}
|
||
defer config.CloseMongoDB()
|
||
}
|
||
|
||
db := config.GetDB(config.DBName)
|
||
if db == nil && !*dryRun {
|
||
log.Fatal("数据库未连接")
|
||
}
|
||
var coll *mongo.Collection
|
||
if db != nil {
|
||
coll = db.Collection("site_assets")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||
defer cancel()
|
||
|
||
ok, skip, fail := 0, 0, 0
|
||
for _, m := range mappings {
|
||
from, srcRelUsed, found := resolveSourceFile(videoPublish, m)
|
||
if !found {
|
||
log.Printf("SKIP 源文件不存在(已试备选路径/扫描子目录): dst=%s episode=%s", m.Dst, m.EpisodeScan)
|
||
skip++
|
||
continue
|
||
}
|
||
|
||
destDir := filepath.Join(uploadDir, "sites", *siteID, "promotion", "social")
|
||
destPath := filepath.Join(destDir, m.Dst)
|
||
relPath := filepath.ToSlash(filepath.Join("sites", *siteID, "promotion", "social", m.Dst))
|
||
|
||
if *dryRun {
|
||
log.Printf("COPY %s -> %s | DB file_path=%s", from, destPath, relPath)
|
||
ok++
|
||
continue
|
||
}
|
||
|
||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||
log.Printf("FAIL 创建目录 %s: %v", destDir, err)
|
||
fail++
|
||
continue
|
||
}
|
||
|
||
if err := copyFile(from, destPath); err != nil {
|
||
log.Printf("FAIL 复制 %s: %v", srcRelUsed, err)
|
||
fail++
|
||
continue
|
||
}
|
||
_ = os.Chmod(destPath, 0644)
|
||
|
||
fi, _ := os.Stat(destPath)
|
||
size := int64(0)
|
||
if fi != nil {
|
||
size = fi.Size()
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(m.Dst))
|
||
ct := mimeForExt(ext)
|
||
|
||
_, _ = coll.DeleteMany(ctx, bson.M{"site_id": *siteID, "file_path": relPath})
|
||
|
||
doc := bson.M{
|
||
"site_id": *siteID,
|
||
"name": m.Dst,
|
||
"file_path": relPath,
|
||
"size": size,
|
||
"content_type": ct,
|
||
"downloadable": false,
|
||
"created_at": time.Now().Format(time.RFC3339),
|
||
"import_source": "video_publish_legacy",
|
||
"source_relpath": srcRelUsed,
|
||
"promotion_alias": filepath.ToSlash(filepath.Join("promotion", "social", m.Dst)),
|
||
}
|
||
if _, err := coll.InsertOne(ctx, doc); err != nil {
|
||
log.Printf("FAIL 写库 %s: %v", relPath, err)
|
||
fail++
|
||
continue
|
||
}
|
||
log.Printf("OK %s -> %s", srcRelUsed, relPath)
|
||
ok++
|
||
}
|
||
|
||
fmt.Printf("\n完成: 成功=%d 跳过=%d 失败=%d\n", ok, skip, fail)
|
||
if fail > 0 {
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func copyFile(src, dst string) error {
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = out.Close() }()
|
||
if _, err := io.Copy(out, in); err != nil {
|
||
return err
|
||
}
|
||
return out.Sync()
|
||
}
|