package handler import ( "log/slog" "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" "vidarchive" "vidarchive/internal/models" "vidarchive/internal/service" ) func (h *Handler) Library(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") filter := r.URL.Query().Get("filter") sortBy := sortFromRequest(w, r, "library_sort", "date") items, folders, err := h.librarySvc.GetAll(path, sortBy, filter) if err != nil { h.serverError(w, r, "list library", err) return } total := len(items) // Folders are navigation, not content, so they stay on every page. page := pageFromRequest(r) start := min((page-1)*pageSize, len(items)) end := min(start+pageSize, len(items)) items = items[start:end] settings := h.settingsOrDefault() h.renderWithRequest(w, r, "library", PageData{ Title: "Library", ActiveTab: "library", AutoRefresh: settings.AutoRefreshLibrary, RefreshSec: settings.RefreshInterval, Data: struct { Items []*models.LibraryItem Folders []string Path string SortBy string Filter string Pagination Pagination }{ Items: items, Folders: folders, Path: path, SortBy: sortBy, Filter: filter, Pagination: newPagination(r, page, end < total), }, }) } func normalizeRelPath(r *http.Request) string { // chi hands over the still-encoded wildcard. Decoding it as a path, not a // query, keeps '+' literal, so an item directory named "a+b" round-trips. relPath := chi.URLParam(r, "*") relPath = strings.Trim(relPath, "/") if decoded, err := url.PathUnescape(relPath); err == nil { relPath = decoded } else { slog.Warn("undecodable path", "path", relPath, "err", err) } return relPath } func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) { relPath := normalizeRelPath(r) if r.Method == "POST" && strings.HasSuffix(relPath, "/delete") { relPath = strings.TrimSuffix(relPath, "/delete") h.deleteMedia(relPath, w, r) return } h.libraryDetail(relPath, w, r) } const commentPreviewLimit = 50 func (h *Handler) LibraryComments(w http.ResponseWriter, r *http.Request) { relPath := normalizeRelPath(r) h.libraryComments(relPath, w, r) } func (h *Handler) libraryComments(relPath string, w http.ResponseWriter, r *http.Request) { item, err := h.librarySvc.GetByRelPath(relPath) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } comments, _, err := h.librarySvc.GetEngagement(relPath) if err != nil { h.serverError(w, r, "load comments", err) return } h.renderWithRequest(w, r, "library_comments", PageData{ Title: "Comments - " + item.Name, ActiveTab: "library", Data: struct { Item *models.LibraryItem Comments []models.Comment }{Item: item, Comments: comments}, }) } func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) { item, err := h.librarySvc.GetByRelPath(relPath) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } selectedFilename := r.URL.Query().Get("file") if selectedFilename == "" && len(item.MediaFiles) > 0 { selectedFilename = item.MediaFiles[0].Filename } meta, err := h.librarySvc.GetMetadata(relPath, selectedFilename) if err != nil { slog.Warn("metadata unavailable", "path", relPath, "err", err) } subtitles, err := h.librarySvc.GetSubtitles(relPath) if err != nil { slog.Warn("subtitles unavailable", "path", relPath, "err", err) } comments, heatmap, err := h.librarySvc.GetEngagement(relPath) if err != nil { slog.Warn("engagement unavailable", "path", relPath, "err", err) } previewComments := comments if len(previewComments) > commentPreviewLimit { previewComments = previewComments[:commentPreviewLimit] } h.renderWithRequest(w, r, "library_detail", PageData{ Title: item.Name, ActiveTab: "library", Data: struct { Item *models.LibraryItem SelectedFilename string Metadata *service.MediaMetadata Subtitles []models.SubtitleTrack Comments []models.Comment CommentTotal int Heatmap []models.HeatmapSegment }{ Item: item, SelectedFilename: selectedFilename, Metadata: meta, Subtitles: subtitles, Comments: previewComments, CommentTotal: len(comments), Heatmap: heatmap, }, }) } func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) { relPath := normalizeRelPath(r) if strings.HasSuffix(relPath, "/thumbnail") { h.serveThumbnail(strings.TrimSuffix(relPath, "/thumbnail"), w, r) return } // Subtitle tracks are addressed as /subtitles/. if i := strings.LastIndex(relPath, "/subtitles/"); i >= 0 { item := relPath[:i] lang := relPath[i+len("/subtitles/"):] h.serveSubtitles(item, lang, w, r) return } h.serveMedia(relPath, w, r) } func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Request) { filename := r.URL.Query().Get("file") if filename == "" { http.Error(w, "Missing file", http.StatusBadRequest) return } mediaPath, err := h.librarySvc.GetMediaFile(relPath, filename) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } http.ServeFile(w, r, mediaPath) } func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) { filename := r.URL.Query().Get("file") if thumb, ok := h.librarySvc.ThumbnailForFile(relPath, filename); ok { http.ServeFile(w, r, thumb) return } // Fall back to an icon matching the requested file's type. icon := "video-icon.svg" if item, err := h.librarySvc.GetByRelPath(relPath); err == nil { if isAudioFile(item, filename) { icon = "audio-icon.svg" } } data, err := vidarchive.StaticFS.ReadFile("web/static/icons/" + icon) if err != nil { http.Error(w, "icon not found", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/svg+xml") w.Write(data) } // isAudioFile checks the named file, or the first media file when unnamed. func isAudioFile(item *models.LibraryItem, filename string) bool { if filename != "" { for _, mf := range item.MediaFiles { if mf.Filename == filename { return mf.IsAudio } } return false } return len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio } func (h *Handler) serveSubtitles(relPath, lang string, w http.ResponseWriter, r *http.Request) { if lang == "" { http.Error(w, "Missing language", http.StatusBadRequest) return } if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") { http.Error(w, "Invalid language", http.StatusBadRequest) return } subtitlePath, err := h.librarySvc.GetSubtitlePath(relPath, lang) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "text/vtt") http.ServeFile(w, r, subtitlePath) } func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) { if err := h.librarySvc.Delete(relPath); err != nil { redirectWithError(w, r, "/library", "Couldn't delete this item.", err) return } redirectWithSuccess(w, r, "/library", "Item deleted.") }