// 媒体库的两个接口:查列表、查统计。 // 「媒体库」页面查列表(带筛选和分页),「统计看板」页面查统计。 // 这两个接口只读数据库,不干别的活。 package api import ( "fmt" "net/http" "os" "path/filepath" "strings" "github.com/dustin/go-humanize" "github.com/gin-gonic/gin" "kenaito-media-picker/internal/classify" "kenaito-media-picker/internal/store" ) // MediaController 媒体库相关接口的"窗口"。 type MediaController struct { store *store.Store // 数据库(只读) } // NewMediaController 创建媒体库控制器,注入数据库。 func NewMediaController(st *store.Store) *MediaController { return &MediaController{store: st} } // mediaListReq 媒体库列表的查询条件(前端「媒体库」页面传过来的)。 type mediaListReq struct { Page int `json:"page"` PageSize int `json:"pageSize"` MediaType string `json:"mediaType"` Orientation string `json:"orientation"` Keyword string `json:"keyword"` Codec string `json:"codec"` Format string `json:"format"` Resolution string `json:"resolution"` NsfwOnly interface{} `json:"nsfwOnly"` SortField string `json:"sortField"` SortOrder string `json:"sortOrder"` } // mediaView 列表里展示的一条记录:在数据库记录的基础上, // 再补几个"现算"的字段(方向、分辨率档位、人类可读的体积和时长),方便页面直接显示。 type mediaView struct { store.MediaInfo Orientation string `json:"orientation"` // 横屏/竖屏/等比 Resolution string `json:"resolution"` // 分辨率档位(如 1080P) SizeText string `json:"sizeText"` // 人类可读体积(如 1.5 MB) DurationText string `json:"durationText"` // 人类可读时长(如 05:30) } // List 处理 POST /api/media/list:按条件分页查媒体库列表。 func (c *MediaController) List(ctx *gin.Context) { var req mediaListReq if err := ctx.ShouldBindJSON(&req); err != nil { Error(ctx, http.StatusBadRequest, "请求参数错误:"+err.Error()) return } if req.Page < 1 { req.Page = 1 } q := store.MediaQuery{ MediaType: req.MediaType, Orientation: req.Orientation, Keyword: req.Keyword, Codec: req.Codec, Format: req.Format, Resolution: req.Resolution, NsfwOnly: req.NsfwOnly == true, // interface{} 转 bool:只有明确的 true 才算 Page: req.Page, PageSize: req.PageSize, SortField: req.SortField, SortOrder: req.SortOrder, } // 查这一页的数据,同时查总条数(分页条要用) list, err := c.store.ListMedia(q) if err != nil { Error(ctx, http.StatusInternalServerError, "查询媒体列表失败:"+err.Error()) return } total, err := c.store.CountMedia(q) if err != nil { Error(ctx, http.StatusInternalServerError, "统计媒体数量失败:"+err.Error()) return } // 每条记录补上"现算"的展示字段再返回 views := make([]interface{}, 0, len(list)) for _, m := range list { views = append(views, toMediaView(m)) } Success(ctx, map[string]interface{}{"list": views, "total": total}) } // Stats 处理 POST /api/media/stats:统计看板要用的全部数据。 func (c *MediaController) Stats(ctx *gin.Context) { st, err := c.store.Stats() if err != nil { Error(ctx, http.StatusInternalServerError, "统计失败:"+err.Error()) return } Success(ctx, st) } // Preview 处理 GET /api/media/preview?path=xxx:预览图片文件。 // 只允许预览图片类型(jpeg/jpg/png/gif/webp/bmp),其他类型返回 403。 func (c *MediaController) Preview(ctx *gin.Context) { path := strings.TrimSpace(ctx.Query("path")) if path == "" { Error(ctx, http.StatusBadRequest, "缺少 path 参数") return } // 检查文件是否存在 info, err := os.Stat(path) if err != nil { Error(ctx, http.StatusNotFound, "文件不存在: "+path) return } // 只允许预览图片 ext := strings.ToLower(filepath.Ext(info.Name())) allowedExts := map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".bmp": true, } if !allowedExts[ext] { Error(ctx, http.StatusForbidden, "仅支持预览图片文件") return } // 返回图片文件 ctx.File(path) } // AvgManualNsfwScore 处理 GET /api/media/manual-nsfw-avg:返回手动标记均值。 func (c *MediaController) AvgManualNsfwScore(ctx *gin.Context) { avg, err := c.store.AvgManualNsfwScore() if err != nil { Error(ctx, http.StatusInternalServerError, "查询失败:"+err.Error()) return } Success(ctx, gin.H{"avg": avg}) } // UpdateManualNsfw 处理 PUT /api/media/:id/manual-nsfw:更新手动色情标记。 func (c *MediaController) UpdateManualNsfw(ctx *gin.Context) { idStr := ctx.Param("id") var id int64 if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil || id <= 0 { Error(ctx, http.StatusBadRequest, "无效的 ID") return } var req struct { Value string `json:"value"` } if err := ctx.ShouldBindJSON(&req); err != nil { Error(ctx, http.StatusBadRequest, "请求参数错误:"+err.Error()) return } if req.Value != "Y" && req.Value != "N" { Error(ctx, http.StatusBadRequest, "值必须是 Y 或 N") return } if err := c.store.UpdateManualNsfw(id, req.Value); err != nil { Error(ctx, http.StatusInternalServerError, "更新失败:"+err.Error()) return } Success(ctx, nil) } // toMediaView 给一条数据库记录补上展示用的派生字段。 func toMediaView(m store.MediaInfo) mediaView { v := mediaView{MediaInfo: m} // 宽高有效才算方向和分辨率档位 if m.Width > 0 && m.Height > 0 { v.Orientation = classify.OrientationOf(m.Width, m.Height).String() v.Resolution = classify.ResLabel(m.Width, m.Height) } if m.FileSize > 0 { v.SizeText = humanize.Bytes(uint64(m.FileSize)) } if m.Duration > 0 { v.DurationText = formatDuration(m.Duration) } return v } // formatDuration 把秒数转成人类可读的时:分:秒。 func formatDuration(sec float64) string { s := int(sec) h := s / 3600 m := (s % 3600) / 60 secs := s % 60 if h > 0 { return fmt.Sprintf("%d:%02d:%02d", h, m, secs) } return fmt.Sprintf("%02d:%02d", m, secs) }