diff --git a/server/go.mod b/server/go.mod index d48190d..6345c31 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,9 +1,27 @@ module server -go 1.16 +go 1.23 + +toolchain go1.24.3 require ( github.com/gin-gonic/gin v1.6.3 - github.com/giorgisio/goav v0.1.0 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible +) + +require ( + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.13.0 // indirect + github.com/go-playground/universal-translator v0.17.0 // indirect + github.com/go-playground/validator/v10 v10.2.0 // indirect + github.com/golang/protobuf v1.3.3 // indirect + github.com/json-iterator/go v1.1.9 // indirect + github.com/leodido/go-urn v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/ugorji/go/codec v1.1.7 // indirect + golang.org/x/sys v0.29.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index 2c56172..7a84dae 100644 --- a/server/go.sum +++ b/server/go.sum @@ -5,8 +5,6 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= -github.com/giorgisio/goav v0.1.0 h1:ZyfG3NfX7PMSimv4ulhmnQJf/XeHpMdGCn+afRmY5Oc= -github.com/giorgisio/goav v0.1.0/go.mod h1:RtH8HyxLRLU1iY0pjfhWBKRhnbsnmfoI+FxMwb5bfEo= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= @@ -20,8 +18,6 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gosuri/uilive v0.0.0-20170323041506-ac356e6e42cd/go.mod h1:qkLSc0A5EXSP6B04TrN4oQoxqFI7A8XvoXSlJi8cwk8= -github.com/gosuri/uiprogress v0.0.0-20170224063937-d0567a9d84a1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= @@ -36,18 +32,22 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/handler.go b/server/handler.go index 7719143..bcf99f9 100644 --- a/server/handler.go +++ b/server/handler.go @@ -3,7 +3,9 @@ package main import ( "log" "net/http" + "sort" "strconv" + "strings" "github.com/gin-gonic/gin" ) @@ -12,67 +14,106 @@ import ( func MovieList(c *gin.Context) { response := gin.H{ "code": http.StatusInternalServerError, - "message": "An unexpected error occurred.", // Default error message + "message": "An unexpected error occurred.", "data": gin.H{"items": []Movie{}, "total": 0}, } - categoryName := c.Query("category") // Expects category name like "15min", "30min", etc. - var moviesToPaginate []Movie + categoryName := c.Query("category") + searchQuery := c.Query("search") // 获取搜索参数 - // Filter movies by category if a categoryName is provided - if categoryName != "" { - for _, m := range movies { // 'movies' is the global slice from initMovie() + // === 第一步:基础过滤(类别)=== + if categoryName != "" && categoryName != "最新添加" { + // 普通类别:直接过滤 + for _, m := range movies { if m.TimeCategory == categoryName { moviesToPaginate = append(moviesToPaginate, m) } } } else { - // If no category is specified, use all movies - // The global 'movies' slice is already sorted by category, then duration. - // So, if no category is given, pagination will occur over this pre-sorted list. - moviesToPaginate = movies + // 最新添加/所有电影:使用全局movies + moviesToPaginate = make([]Movie, len(movies)) + copy(moviesToPaginate, movies) } - // Pagination logic - page := 0 // Default to page 1 (0-indexed internally) + // === 第二步:搜索过滤 === + if searchQuery != "" { + var searchResults []Movie + lowerSearch := strings.ToLower(searchQuery) + + for _, m := range moviesToPaginate { + // 检查文件名是否包含搜索词(不区分大小写) + if strings.Contains(strings.ToLower(m.FileName), lowerSearch) { + searchResults = append(searchResults, m) + } + } + moviesToPaginate = searchResults + } + + // === 第三步:排序处理 === + sortBy := c.Query("sort") + order := c.Query("order") + if sortBy == "" { + sortBy = "created_time" // 默认按创建时间排序 + } + + // 应用排序 + sort.Slice(moviesToPaginate, func(i, j int) bool { + switch sortBy { + case "created_time": + if order == "desc" { + return moviesToPaginate[i].CreatedTime > moviesToPaginate[j].CreatedTime + } + return moviesToPaginate[i].CreatedTime < moviesToPaginate[j].CreatedTime + case "duration": + if order == "desc" { + return moviesToPaginate[i].Duration > moviesToPaginate[j].Duration + } + return moviesToPaginate[i].Duration < moviesToPaginate[j].Duration + default: + // 默认按创建时间降序 + return moviesToPaginate[i].CreatedTime > moviesToPaginate[j].CreatedTime + } + }) + + // === 第四步:分页处理 === + page := 0 spage := c.Query("page") if spage != "" { p, err := strconv.Atoi(spage) - if err != nil || p < 1 { // Page numbers should be 1 or greater + if err != nil || p < 1 { log.Println("Invalid page number:", spage, err) response["code"] = http.StatusBadRequest response["message"] = "Invalid page number. Must be a positive integer." - c.JSON(http.StatusBadRequest, response) // Send response immediately for bad input + c.JSON(http.StatusBadRequest, response) return } - page = p - 1 // Convert to 0-indexed + page = p - 1 } - limit := 12 // Default limit + limit := 12 slimit := c.Query("limit") if slimit != "" { l, err := strconv.Atoi(slimit) - if err != nil || l < 1 { // Limit should be positive + if err != nil || l < 1 { log.Println("Invalid limit number:", slimit, err) response["code"] = http.StatusBadRequest response["message"] = "Invalid limit number. Must be a positive integer." - c.JSON(http.StatusBadRequest, response) // Send response immediately + c.JSON(http.StatusBadRequest, response) return } limit = l } - // Calculate start and end for slicing start := page * limit end := start + limit + total := len(moviesToPaginate) - // Handle cases where the requested page is out of bounds - if start >= len(moviesToPaginate) { - // Requested page is beyond the available data, return empty list for items + // 处理超出范围的情况 + if start >= total { response["data"] = gin.H{ "items": []Movie{}, - "total": len(moviesToPaginate), + "total": total, } response["code"] = http.StatusOK response["message"] = "Success" @@ -80,14 +121,13 @@ func MovieList(c *gin.Context) { return } - if end > len(moviesToPaginate) { - end = len(moviesToPaginate) + if end > total { + end = total } - // Prepare successful response data responseData := gin.H{ "items": moviesToPaginate[start:end], - "total": len(moviesToPaginate), + "total": total, } response["code"] = http.StatusOK diff --git a/server/movie.go b/server/movie.go index 267ad85..4af5d5a 100644 --- a/server/movie.go +++ b/server/movie.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "log" + "os" "os/exec" "path/filepath" "regexp" @@ -13,228 +14,277 @@ import ( "strings" ) -// Movie 电影结构 +// Movie 电影信息结构 type Movie struct { - Name string `json:"filename"` // Original filename of the movie, e.g., "video1.mp4" - Image string `json:"image"` // Corresponding image filename, e.g., "video1.png" - Duration int `json:"duration"` // Duration in minutes - TimeCategory string `json:"time_category"` // 分类名称, e.g., "15min", "30min" - VideoPath string `json:"-"` // Full path to the video file, used for ffmpeg + FileName string `json:"filename"` // 原始文件名(如 "video1.mp4") + Image string `json:"image"` // 对应的缩略图文件名(如 "video1.png") + Duration int `json:"duration"` // 视频时长(分钟) + TimeCategory string `json:"time_category"` // 时长分类(如 "15min", "30min") + VideoPath string `json:"-"` // 视频文件完整路径(用于ffmpeg) + CreatedTime int64 `json:"created_time"` // 文件创建时间戳 } -var movies []Movie // This will store all movies - -var IsRemakePNG = false // Set to true to regenerate all PNGs +var movies []Movie // 存储所有电影信息的全局切片 +var IsRemakePNG = false // 是否重新生成所有PNG缩略图 +// getVideoDuration 获取视频时长(秒) func getVideoDuration(videoPath string) (float64, error) { - // 使用ffmpeg获取视频信息 cmd := exec.Command("ffmpeg", "-i", videoPath) var stderr bytes.Buffer cmd.Stderr = &stderr - _ = cmd.Run() // 忽略错误,因为ffmpeg在没有输出文件时会返回错误 + _ = cmd.Run() // 忽略错误,ffmpeg在没有输出文件时会返回错误 // 解析ffmpeg输出的时长信息 output := stderr.String() re := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})`) matches := re.FindStringSubmatch(output) + + // 尝试匹配不含毫秒的格式 if len(matches) < 4 { - // Try to find duration even if format is slightly different (e.g. no milliseconds) reAlt := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2})`) matches = reAlt.FindStringSubmatch(output) if len(matches) < 4 { - return 0, fmt.Errorf("duration not found in ffmpeg output for %s. Output: %s", filepath.Base(videoPath), output) + return 0, fmt.Errorf("无法在ffmpeg输出中找到时长: %s. 输出: %s", filepath.Base(videoPath), output) } - // Add .00 for consistency if seconds has no decimal part - matches[3] += ".00" + matches[3] += ".00" // 添加毫秒部分保持格式一致 } - // 将时间转换为秒 - hours, errH := strconv.ParseFloat(matches[1], 64) - minutes, errM := strconv.ParseFloat(matches[2], 64) - seconds, errS := strconv.ParseFloat(matches[3], 64) + // 转换为秒数 + hours, _ := strconv.ParseFloat(matches[1], 64) + minutes, _ := strconv.ParseFloat(matches[2], 64) + seconds, _ := strconv.ParseFloat(matches[3], 64) - if errH != nil || errM != nil || errS != nil { - return 0, fmt.Errorf("error parsing time components from ffmpeg output: %v, %v, %v", errH, errM, errS) - } - - duration := hours*3600 + minutes*60 + seconds - return duration, nil + return hours*3600 + minutes*60 + seconds, nil } -func initMovie() { - // movieDict stores movies for which thumbnails might need to be generated. - // Key: base filename without extension (e.g., "video1") - // Value: *Movie object (initially with Name as full path to video, Duration updated later) - var movieDict map[string]*Movie = make(map[string]*Movie) - - // Glob for all files in "movie/" directory to check for existing PNGs - allFiles, err := filepath.Glob("movie/*") +// getFileCreationTime 获取文件创建时间戳 +func getFileCreationTime(path string) (int64, error) { + fileInfo, err := os.Stat(path) if err != nil { - log.Printf("Error globbing movie directory: %v", err) - // Decide if you want to return or continue with an empty list + return 0, err } + return fileInfo.ModTime().Unix(), nil +} - // This loop is to determine which videos need thumbnails. - // If a video `video.mp4` exists but `video.png` also exists, - // `video.mp4` is removed from `movieDict` unless `IsRemakePNG` is true. - for _, fullPath := range allFiles { - baseWithExt := filepath.Base(fullPath) - ext := filepath.Ext(baseWithExt) - baseName := strings.TrimSuffix(baseWithExt, ext) // Filename without extension +// scanMovieFiles 扫描movie目录并返回文件列表 +func scanMovieFiles() ([]string, error) { + files, err := filepath.Glob("movie/*") + if err != nil { + return nil, fmt.Errorf("扫描movie目录失败: %w", err) + } + return files, nil +} +// createMovieDict 创建需要处理缩略图的视频字典 +func createMovieDict(files []string) map[string]*Movie { + movieDict := make(map[string]*Movie) + + for _, fullPath := range files { + filename := filepath.Base(fullPath) + ext := filepath.Ext(filename) + baseName := strings.TrimSuffix(filename, ext) + + // 跳过目录和系统文件 + if ext == ".db" { + continue + } + + // 处理PNG文件 if ext == ".png" { - // If it's a PNG, check if its corresponding video is in the dict - if movieEntry, ok := movieDict[baseName]; ok { - if !IsRemakePNG { - // PNG exists and we are not remaking, so remove video from thumbnail generation list - log.Printf("Thumbnail %s already exists. Skipping generation for %s.", baseWithExt, movieEntry.VideoPath) - delete(movieDict, baseName) - } + if _, exists := movieDict[baseName]; exists && !IsRemakePNG { + delete(movieDict, baseName) } - } else if ext != "" && ext != ".db" { // Assume other non-empty extensions are videos - // If it's a video file (or any non-PNG, non-empty extension file) - if _, ok := movieDict[baseName]; !ok { - // If not already processed (e.g. by a .png file check) and not forced remake - movieDict[baseName] = &Movie{VideoPath: fullPath} // Store full path for ffmpeg - } else if IsRemakePNG { - // If it was already there (e.g. from a PNG that we decided to remake), ensure VideoPath is set - movieDict[baseName].VideoPath = fullPath + continue + } + + // 处理视频文件 + if ext != "" { + if _, exists := movieDict[baseName]; !exists || IsRemakePNG { + movieDict[baseName] = &Movie{VideoPath: fullPath} } } } - // Reset the global movies slice - movies = []Movie{} + return movieDict +} - // Walk through the movie directory to process video files - err = filepath.WalkDir("./movie", func(path string, d fs.DirEntry, err error) error { - if err != nil { - log.Printf("Error accessing path %q: %v\n", path, err) - return err - } - if d.IsDir() || filepath.Ext(d.Name()) == ".png" || filepath.Ext(d.Name()) == ".db" { // Skip directories, PNGs, and DB files - return nil - } - - videoFileName := d.Name() // e.g., "video1.mp4" - baseNameWithoutExt := strings.TrimSuffix(videoFileName, filepath.Ext(videoFileName)) - - durationSec, err := getVideoDuration(path) - if err != nil { - // log.Printf("Error getting duration for %s: %v. Skipping this file.", videoFileName, err) - // Remove from movieDict if it was there, as we can't process it - delete(movieDict, baseNameWithoutExt) - return nil // Continue with next file - } - - durationMin := int(durationSec / 60.0) - - var timeCat string - if durationMin <= 15 { - timeCat = "15min" - } else if durationMin <= 30 { - timeCat = "30min" - } else if durationMin <= 60 { - timeCat = "60min" - } else { - timeCat = "大于60min" - } - - movie := Movie{ - Name: videoFileName, - Image: baseNameWithoutExt + ".png", - Duration: durationMin, - TimeCategory: timeCat, - VideoPath: path, // Store full path - } - movies = append(movies, movie) - - // If this movie base name is in movieDict (meaning it needs/might need a thumbnail), - // update its duration. - if mEntry, ok := movieDict[baseNameWithoutExt]; ok { - mEntry.Duration = movie.Duration // Update duration for thumbnail filter logic - // Ensure VideoPath in movieDict is the one we just processed (it should be) - mEntry.VideoPath = path - mEntry.Name = movie.Name // Also update Name for logging in thumbnail part - } - return nil - }) +// processVideoFile 处理单个视频文件 +func processVideoFile(path string, movieDict map[string]*Movie) error { + filename := filepath.Base(path) + baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) + // 获取视频时长 + durationSec, err := getVideoDuration(path) if err != nil { - log.Printf("Error walking the path ./movie: %v\n", err) + delete(movieDict, baseName) + return fmt.Errorf("获取时长失败: %s: %w", filename, err) } - // Sort the global movies list. - // First by custom time category order, then by duration within that category. - categoryOrderMap := map[string]int{ + // 获取文件创建时间 + createdTime, err := getFileCreationTime(path) + if err != nil { + return fmt.Errorf("获取创建时间失败: %s: %w", filename, err) + } + + // 转换为分钟并分类 + durationMin := int(durationSec / 60) + timeCat := getTimeCategory(durationMin) + + // 创建电影记录 + movie := Movie{ + FileName: filename, + Image: baseName + ".png", + Duration: durationMin, + TimeCategory: timeCat, + VideoPath: path, + CreatedTime: createdTime, + } + movies = append(movies, movie) + + // 更新缩略图字典 + if entry, exists := movieDict[baseName]; exists { + entry.FileName = movie.FileName + entry.Duration = movie.Duration + entry.VideoPath = movie.VideoPath + entry.CreatedTime = movie.CreatedTime + } + + return nil +} + +// getTimeCategory 根据时长获取分类 +func getTimeCategory(durationMin int) string { + switch { + case durationMin <= 15: + return "15min" + case durationMin <= 30: + return "30min" + case durationMin <= 60: + return "60min" + default: + return "大于60min" + } +} + +// sortMovies 对电影列表排序 +func sortMovies() { + categoryOrder := map[string]int{ "15min": 0, "30min": 1, "60min": 2, "大于60min": 3, + "最新添加": 4, // 新增的"最新添加"类别 } sort.Slice(movies, func(i, j int) bool { - catOrderI := categoryOrderMap[movies[i].TimeCategory] - catOrderJ := categoryOrderMap[movies[j].TimeCategory] + catI := categoryOrder[movies[i].TimeCategory] + catJ := categoryOrder[movies[j].TimeCategory] - if catOrderI != catOrderJ { - return catOrderI < catOrderJ + if catI != catJ { + return catI < catJ } + + // 如果是recent类别,按创建时间倒序排序 + if movies[i].TimeCategory == "最新添加" { + return movies[i].CreatedTime > movies[j].CreatedTime + } + + // 其他类别按时长排序 return movies[i].Duration < movies[j].Duration }) +} - // Generate thumbnails for movies in movieDict - // These are the movies for which PNGs didn't exist or IsRemakePNG is true - for key, movieToProcess := range movieDict { - if movieToProcess.VideoPath == "" { - // log.Printf("Skipping thumbnail for %s, VideoPath is empty (likely only PNG found and no corresponding video processed).", key) - continue - } - // movieToProcess.Duration should have been updated by the WalkDir logic. - // If a video was in movieDict but not found/processed by WalkDir, its Duration might be 0. - if movieToProcess.Duration == 0 && movieToProcess.Name != "" { // Name check to avoid new entries - // Try to get duration again if it's missing, might happen if it was added to dict but WalkDir failed for it - // Or if the logic for updating movieDict was missed for some edge case. - // For simplicity, we assume duration is now correctly populated from WalkDir pass. - log.Printf("Warning: Duration for %s (path: %s) is 0, thumbnail filter might be suboptimal.", movieToProcess.Name, movieToProcess.VideoPath) - } +// generateThumbnail 生成单个视频缩略图 +func generateThumbnail(movie *Movie) { + baseName := strings.TrimSuffix(movie.FileName, filepath.Ext(movie.FileName)) + outputPath := filepath.Join("movie", baseName+".png") - log.Printf("Generating thumbnail for: %s (Duration: %d min, Path: %s)", movieToProcess.Name, movieToProcess.Duration, movieToProcess.VideoPath) + // 根据时长选择不同的采样策略 + var filter string + switch { + case movie.Duration <= 2: + filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,5)',scale=320:180,tile=3x3" + case movie.Duration <= 5: + filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,10)',scale=320:180,tile=3x3" + case movie.Duration <= 30: + filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,20)',scale=320:180,tile=3x3" + case movie.Duration <= 60: + filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,35)',scale=320:180,tile=3x3" + default: + filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,60)',scale=320:180,tile=3x3" + } - var filter string - // Use movieToProcess.Duration for filter logic - if movieToProcess.Duration <= 2 { - filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,5)',scale=320:180,tile=3x3" - } else if movieToProcess.Duration <= 5 { - filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,10)',scale=320:180,tile=3x3" - } else if movieToProcess.Duration <= 30 { - filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,20)',scale=320:180,tile=3x3" - } else if movieToProcess.Duration <= 60 { - filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,35)',scale=320:180,tile=3x3" - } else { // duration > 60 - filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,60)',scale=320:180,tile=3x3" - } + // 执行ffmpeg命令生成缩略图 + cmd := exec.Command("ffmpeg", + "-i", movie.VideoPath, + "-vf", filter, + "-frames:v", "1", + "-q:v", "3", + "-y", // 覆盖已存在文件 + outputPath, + ) - outputImagePath := filepath.Join("movie", key+".png") - - cmd := exec.Command("ffmpeg", - "-i", movieToProcess.VideoPath, // Use full path to video - "-vf", filter, - "-frames:v", "1", - "-q:v", "3", // Quality for JPEG/PNG, usually 2-5 is good for -qscale:v - "-y", // Overwrite output files without asking - outputImagePath, - ) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - log.Printf("Error generating thumbnail for %s (Duration: %d min): %v\nStderr: %s", - movieToProcess.VideoPath, movieToProcess.Duration, err, stderr.String()) - // Consider whether to panic or just log - // panic(fmt.Errorf("could not generate frame %s %d. Error: %v. Stderr: %s", movieToProcess.VideoPath, movieToProcess.Duration, err, stderr.String())) - } else { - log.Printf("Successfully generated thumbnail: %s", outputImagePath) - } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + log.Printf("生成缩略图失败: %s (%d min): %v\n错误输出: %s", + movie.VideoPath, movie.Duration, err, stderr.String()) + } else { + log.Printf("成功生成缩略图: %s", outputPath) + } +} + +// initMovie 初始化电影数据主函数 +func initMovie() { + // 扫描目录获取文件列表 + files, err := scanMovieFiles() + if err != nil { + log.Fatal(err) + } + + // 创建需要处理缩略图的视频字典 + movieDict := createMovieDict(files) + movies = make([]Movie, 0) // 重置全局电影列表 + + // 遍历处理每个视频文件 + err = filepath.WalkDir("./movie", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || + filepath.Ext(d.Name()) == ".png" || + filepath.Ext(d.Name()) == ".db" { + return err + } + + return processVideoFile(path, movieDict) + }) + + if err != nil { + log.Fatalf("遍历movie目录失败: %v", err) + } + + // 为最近添加的文件添加"最新添加"类别 + markRecentMovies() + + // 排序电影列表 + // sortMovies() + + // 生成缩略图 + for _, movie := range movieDict { + if movie.VideoPath == "" { + continue + } + generateThumbnail(movie) + } +} + +// markRecentMovies 标记最近添加的电影 +func markRecentMovies() { + // 首先按创建时间排序 + sort.Slice(movies, func(i, j int) bool { + return movies[i].CreatedTime > movies[j].CreatedTime + }) + + // 标记前20个最新文件为"最新添加"类别 + for i := 0; i < len(movies) && i < 20; i++ { + movies[i].TimeCategory = "最新添加" } } diff --git a/src/Main.jsx b/src/Main.jsx index c007ad0..7e01879 100644 --- a/src/Main.jsx +++ b/src/Main.jsx @@ -5,6 +5,11 @@ import Grid from '@mui/material/Grid'; import Pagination from '@mui/material/Pagination'; import { Link } from 'react-router-dom'; import CircularProgress from '@mui/material/CircularProgress'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import IconButton from '@mui/material/IconButton'; +import SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Clear'; import ConfigContext from './Config'; import MovieCard from './components/MovieCard'; @@ -15,9 +20,13 @@ const categories = [ { label: '30min', value: '30min' }, { label: '60min', value: '60min' }, { label: '大于60min', value: '大于60min' }, + { label: '最新添加', value: '最新添加' }, ]; +// 新增搜索类别常量 +const SEARCH_CATEGORY = 'search'; const LIMIT = 20; +const DEFAULT_CATEGORY = categories[0].value; const usePersistedState = (key, defaultValue) => { const [state, setState] = useState(() => { @@ -35,56 +44,113 @@ const usePersistedState = (key, defaultValue) => { const Main = () => { const config = useContext(ConfigContext); const [loading, setLoading] = useState(false); - const scrollRef = useRef(0); + const isFirstLoad = useRef(true); + const scrollListenerActive = useRef(false); - // 初始化状态,直接使用 categories 中的 value 值 + // 初始化状态 - 添加搜索相关状态 const [params, setParams] = usePersistedState('params', { - lastCategory: categories[0].value, // 直接使用 '15min' 这样的值 - history: categories.reduce((acc, category) => ({ - ...acc, - [category.value]: { lastPage: 1, scrollPos: 0 } // 使用 category.value 作为键 - }), {}) + lastCategory: DEFAULT_CATEGORY, + history: Object.fromEntries([ + ...categories.map(cat => [ + cat.value, + { lastPage: 1, scrollPos: 0 } + ]), + // 为搜索添加单独的历史记录 + [SEARCH_CATEGORY, { lastPage: 1, scrollPos: 0 }] + ]), + // 新增搜索查询状态 + searchQuery: '' }); const [movies, setMovies] = useState([]); const [pagination, setPagination] = useState({ page: 1, total: 0, pages: 1 }); + const currentCategory = params.lastCategory || DEFAULT_CATEGORY; - const fetchMovies = useCallback(async (category, page) => { - setLoading(true); - try { - // 直接使用 category 值,不需要转换 - const response = await axios.get( - `/movie/?page=${page}&limit=${LIMIT}&category=${encodeURIComponent(category)}` - ); + // 新增搜索输入状态 + const [searchInput, setSearchInput] = useState(params.searchQuery || ''); - if (response.status === 200) { - const { items, total } = response.data.data; + // 滚动位置处理 + const saveScrollPosition = useCallback((category = currentCategory) => { + const currentScrollPos = window.scrollY; - if (items.length === 0 && page > 1) { - setParams(prev => ({ - ...prev, - history: { - ...prev.history, - [category]: { - ...(prev.history[category] || { lastPage: 1, scrollPos: 0 }), - lastPage: page - 1 - } - } - })); - return; + setParams(prev => { + const updatedHistory = { + ...prev.history, + [category]: { + ...(prev.history[category] || { lastPage: 1, scrollPos: 0 }), + scrollPos: currentScrollPos } + }; - setMovies(items); - setPagination({ - page, - total, - pages: Math.ceil(total / LIMIT) - }); + return { ...prev, history: updatedHistory }; + }); + }, [setParams, currentCategory]); - requestAnimationFrame(() => { - window.scrollTo(0, scrollRef.current); - }); + const setupScrollListener = useCallback(() => { + if (scrollListenerActive.current) return; + + const handleScroll = () => { + clearTimeout(window.scrollTimer); + window.scrollTimer = setTimeout(() => saveScrollPosition(), 100); + }; + + window.addEventListener('scroll', handleScroll); + scrollListenerActive.current = true; + + return () => { + window.removeEventListener('scroll', handleScroll); + clearTimeout(window.scrollTimer); + scrollListenerActive.current = false; + }; + }, [saveScrollPosition]); + + const removeScrollListener = useCallback(() => { + if (scrollListenerActive.current) { + window.removeEventListener('scroll', window.scrollHandler); + scrollListenerActive.current = false; + } + }, []); + + // 修改fetchMovies以支持搜索 + const fetchMovies = useCallback(async (category, page, search = '') => { + setLoading(true); + + try { + const isSearch = category === SEARCH_CATEGORY; + const isLatest = category === '最新添加'; + + let apiUrl = `/movie?page=${page}&limit=${LIMIT}`; + + if (isSearch) { + // 搜索请求 + apiUrl += `&search=${encodeURIComponent(search)}`; + } else if (isLatest) { + // 最新添加请求 + apiUrl += `&sort=created_time&order=desc`; + } else { + // 分类请求 + apiUrl += `&category=${encodeURIComponent(category)}`; } + + const response = await axios.get(apiUrl); + if (response.status !== 200) return; + + const { items, total } = response.data.data; + const totalPages = Math.ceil(total / LIMIT); + + if (items.length === 0 && page > 1) { + setParams(prev => ({ + ...prev, + history: { + ...prev.history, + [category]: { ...prev.history[category], lastPage: page - 1 } + } + })); + return; + } + + setMovies(items); + setPagination({ page, total, pages: totalPages }); } catch (error) { console.error('Error fetching movies:', error); } finally { @@ -92,98 +158,221 @@ const Main = () => { } }, [setParams]); - const getCurrentCategoryAndPage = useCallback(() => { - // 直接从 params 中获取 lastCategory,确保它是 categories 中的 value 值 - const lastCategory = params.lastCategory || categories[0].value; - const categoryHistory = params.history[lastCategory] || { lastPage: 1, scrollPos: 0 }; - return { - category: lastCategory, - page: categoryHistory.lastPage - }; - }, [params]); + // 恢复滚动位置 + const restoreScrollPosition = useCallback((category) => { + const scrollPos = params.history?.[category]?.scrollPos || 0; + requestAnimationFrame(() => { + setTimeout(() => window.scrollTo(0, scrollPos), 100); + }); + }, [params.history]); + + // 初始加载 useEffect(() => { - const { category, page } = getCurrentCategoryAndPage(); - scrollRef.current = params.history[category]?.scrollPos || 0; - fetchMovies(category, page); - }, [getCurrentCategoryAndPage, fetchMovies, params.history]); + if (!isFirstLoad.current) return; - const handleCategoryChange = useCallback((category) => { - scrollRef.current = window.scrollY; - const currentCategory = params.lastCategory || categories[0].value; + const category = currentCategory; + const categoryHistory = params.history[category] || { lastPage: 1 }; + + // 如果是搜索类别,传递搜索查询 + if (category === SEARCH_CATEGORY) { + fetchMovies(category, categoryHistory.lastPage, params.searchQuery); + } else { + fetchMovies(category, categoryHistory.lastPage); + } + + isFirstLoad.current = false; + }, [fetchMovies, currentCategory, params.history, params.searchQuery]); + + // 数据加载完成后处理 + useEffect(() => { + if (loading || isFirstLoad.current) return; + restoreScrollPosition(currentCategory); + setupScrollListener(); + }, [loading, restoreScrollPosition, currentCategory, setupScrollListener]); + + // 类别切换处理 - 修改以支持搜索类别 + const handleCategoryChange = useCallback((newCategory) => { + removeScrollListener(); + saveScrollPosition(); setParams(prev => { - const newHistory = { - ...prev.history, - [currentCategory]: { - ...(prev.history[currentCategory] || { lastPage: 1, scrollPos: 0 }), - scrollPos: scrollRef.current - }, - [category]: { - ...(prev.history[category] || { lastPage: 1, scrollPos: 0 }) - } - }; + const categoryHistory = prev.history[newCategory] || { lastPage: 1 }; + + // 如果是搜索类别,使用保存的搜索查询 + if (newCategory === SEARCH_CATEGORY) { + fetchMovies(newCategory, categoryHistory.lastPage, prev.searchQuery); + } else { + fetchMovies(newCategory, categoryHistory.lastPage); + } return { ...prev, - lastCategory: category, // 直接存储传入的 category 值 - history: newHistory + lastCategory: newCategory }; }); - }, [params.lastCategory, setParams]); + }, [removeScrollListener, saveScrollPosition, setParams, fetchMovies]); + // 分页处理 - 修改以支持搜索 const handlePageChange = useCallback((_, page) => { - scrollRef.current = window.scrollY; - const lastCategory = params.lastCategory || categories[0].value; + removeScrollListener(); + saveScrollPosition(); setParams(prev => ({ ...prev, history: { ...prev.history, - [lastCategory]: { - ...(prev.history[lastCategory] || { lastPage: 1, scrollPos: 0 }), - lastPage: page + [currentCategory]: { + ...prev.history[currentCategory], + lastPage: page, + scrollPos: 0 } } })); - }, [params.lastCategory, setParams]); + // 如果是搜索类别,传递搜索查询 + if (currentCategory === SEARCH_CATEGORY) { + fetchMovies(currentCategory, page, params.searchQuery); + } else { + fetchMovies(currentCategory, page); + } + + window.scrollTo(0, 0); + }, [removeScrollListener, saveScrollPosition, setParams, fetchMovies, currentCategory, params.searchQuery]); + + // 电影卡片点击处理 const handleMovieCardClick = useCallback(() => { - scrollRef.current = window.scrollY; - const lastCategory = params.lastCategory || categories[0].value; + removeScrollListener(); + saveScrollPosition(); + }, [removeScrollListener, saveScrollPosition]); - setParams(prev => ({ - ...prev, - history: { - ...prev.history, - [lastCategory]: { - ...(prev.history[lastCategory] || { lastPage: 1, scrollPos: 0 }), - scrollPos: scrollRef.current + // 新增:处理搜索提交 + const handleSearchSubmit = useCallback(() => { + if (!searchInput.trim()) return; + + removeScrollListener(); + saveScrollPosition(); + + setParams(prev => { + // 更新搜索查询并切换到搜索类别 + const updatedParams = { + ...prev, + lastCategory: SEARCH_CATEGORY, + searchQuery: searchInput.trim(), + history: { + ...prev.history, + [SEARCH_CATEGORY]: { + ...prev.history[SEARCH_CATEGORY], + lastPage: 1 // 新搜索总是从第一页开始 + } } - } - })); - }, [params.lastCategory, setParams]); + }; + + // 执行搜索请求 + fetchMovies(SEARCH_CATEGORY, 1, searchInput.trim()); + + return updatedParams; + }); + }, [removeScrollListener, saveScrollPosition, setParams, fetchMovies, searchInput]); + + // 新增:清除搜索 + const handleClearSearch = useCallback(() => { + if (!searchInput) return; + + setSearchInput(''); + + // 如果当前在搜索类别,则清除后切换到默认类别 + if (currentCategory === SEARCH_CATEGORY) { + setParams(prev => ({ + ...prev, + lastCategory: DEFAULT_CATEGORY, + searchQuery: '' + })); + + // 获取默认类别的数据 + const categoryHistory = params.history[DEFAULT_CATEGORY] || { lastPage: 1 }; + fetchMovies(DEFAULT_CATEGORY, categoryHistory.lastPage); + } else { + // 不在搜索类别,只清除输入框 + setParams(prev => ({ + ...prev, + searchQuery: '' + })); + } + }, [currentCategory, fetchMovies, params.history, searchInput, setParams]); + + // 新增:处理搜索输入变化 + const handleSearchChange = useCallback((e) => { + setSearchInput(e.target.value); + }, []); + + // 新增:处理搜索输入键盘事件 + const handleSearchKeyDown = useCallback((e) => { + if (e.key === 'Enter') { + handleSearchSubmit(); + } + }, [handleSearchSubmit]); + + // 组件卸载清理 + useEffect(() => removeScrollListener, [removeScrollListener]); + + // 分页组件复用 + const paginationComponent = ( + + ); return ( + {/* 添加搜索框 */} + + + + ), + endAdornment: searchInput && ( + + + + + + ) + }} + sx={{ mb: 2 }} + /> + - + {/* 显示当前搜索状态 */} + {currentCategory === SEARCH_CATEGORY && ( +
+ 搜索: "{params.searchQuery}" ({pagination.total} 个结果) +
+ )} + + {paginationComponent} {loading ? ( ) : ( - {movies.map((item) => ( + {movies.map(item => ( { )} - + {paginationComponent}
); };