library.go
⎇
Raw
1package handler
2
3import (
4 "log/slog"
5 "net/http"
6 "net/url"
7 "strings"
8
9 "github.com/go-chi/chi/v5"
10
11 "vidarchive"
12 "vidarchive/internal/models"
13 "vidarchive/internal/service"
14)
15
16func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
17 path := r.URL.Query().Get("path")
18 filter := r.URL.Query().Get("filter")
19 sortBy := sortFromRequest(w, r, "library_sort", "date")
20
21 items, folders, err := h.librarySvc.GetAll(path, sortBy, filter)
22 if err != nil {
23 h.serverError(w, r, "list library", err)
24 return
25 }
26 total := len(items)
27
28 // Folders are navigation, not content, so they stay on every page.
29 page := pageFromRequest(r)
30 start := min((page-1)*pageSize, len(items))
31 end := min(start+pageSize, len(items))
32 items = items[start:end]
33
34 settings := h.settingsOrDefault()
35
36 h.renderWithRequest(w, r, "library", PageData{
37 Title: "Library",
38 ActiveTab: "library",
39 AutoRefresh: settings.AutoRefreshLibrary,
40 RefreshSec: settings.RefreshInterval,
41 Data: struct {
42 Items []*models.LibraryItem
43 Folders []string
44 Path string
45 SortBy string
46 Filter string
47 Pagination Pagination
48 }{
49 Items: items,
50 Folders: folders,
51 Path: path,
52 SortBy: sortBy,
53 Filter: filter,
54 Pagination: newPagination(r, page, end < total),
55 },
56 })
57}
58
59func normalizeRelPath(r *http.Request) string {
60 // chi hands over the still-encoded wildcard. Decoding it as a path, not a
61 // query, keeps '+' literal, so an item directory named "a+b" round-trips.
62 relPath := chi.URLParam(r, "*")
63 relPath = strings.Trim(relPath, "/")
64 if decoded, err := url.PathUnescape(relPath); err == nil {
65 relPath = decoded
66 } else {
67 slog.Warn("undecodable path", "path", relPath, "err", err)
68 }
69 return relPath
70}
71
72func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
73 relPath := normalizeRelPath(r)
74 if r.Method == "POST" && strings.HasSuffix(relPath, "/delete") {
75 relPath = strings.TrimSuffix(relPath, "/delete")
76 h.deleteMedia(relPath, w, r)
77 return
78 }
79 h.libraryDetail(relPath, w, r)
80}
81
82const commentPreviewLimit = 50
83
84func (h *Handler) LibraryComments(w http.ResponseWriter, r *http.Request) {
85 relPath := normalizeRelPath(r)
86 h.libraryComments(relPath, w, r)
87}
88
89func (h *Handler) libraryComments(relPath string, w http.ResponseWriter, r *http.Request) {
90 item, err := h.librarySvc.GetByRelPath(relPath)
91 if err != nil {
92 http.Error(w, "Not found", http.StatusNotFound)
93 return
94 }
95 comments, _, err := h.librarySvc.GetEngagement(relPath)
96 if err != nil {
97 h.serverError(w, r, "load comments", err)
98 return
99 }
100
101 h.renderWithRequest(w, r, "library_comments", PageData{
102 Title: "Comments - " + item.Name,
103 ActiveTab: "library",
104 Data: struct {
105 Item *models.LibraryItem
106 Comments []models.Comment
107 }{Item: item, Comments: comments},
108 })
109}
110
111func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
112 item, err := h.librarySvc.GetByRelPath(relPath)
113 if err != nil {
114 http.Error(w, "Not found", http.StatusNotFound)
115 return
116 }
117
118 selectedFilename := r.URL.Query().Get("file")
119 if selectedFilename == "" && len(item.MediaFiles) > 0 {
120 selectedFilename = item.MediaFiles[0].Filename
121 }
122
123 meta, err := h.librarySvc.GetMetadata(relPath, selectedFilename)
124 if err != nil {
125 slog.Warn("metadata unavailable", "path", relPath, "err", err)
126 }
127 subtitles, err := h.librarySvc.GetSubtitles(relPath)
128 if err != nil {
129 slog.Warn("subtitles unavailable", "path", relPath, "err", err)
130 }
131 comments, heatmap, err := h.librarySvc.GetEngagement(relPath)
132 if err != nil {
133 slog.Warn("engagement unavailable", "path", relPath, "err", err)
134 }
135 previewComments := comments
136 if len(previewComments) > commentPreviewLimit {
137 previewComments = previewComments[:commentPreviewLimit]
138 }
139
140 h.renderWithRequest(w, r, "library_detail", PageData{
141 Title: item.Name,
142 ActiveTab: "library",
143 Data: struct {
144 Item *models.LibraryItem
145 SelectedFilename string
146 Metadata *service.MediaMetadata
147 Subtitles []models.SubtitleTrack
148 Comments []models.Comment
149 CommentTotal int
150 Heatmap []models.HeatmapSegment
151 }{
152 Item: item,
153 SelectedFilename: selectedFilename,
154 Metadata: meta,
155 Subtitles: subtitles,
156 Comments: previewComments,
157 CommentTotal: len(comments),
158 Heatmap: heatmap,
159 },
160 })
161}
162
163func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) {
164 relPath := normalizeRelPath(r)
165 if strings.HasSuffix(relPath, "/thumbnail") {
166 h.serveThumbnail(strings.TrimSuffix(relPath, "/thumbnail"), w, r)
167 return
168 }
169 // Subtitle tracks are addressed as <item>/subtitles/<lang>.
170 if i := strings.LastIndex(relPath, "/subtitles/"); i >= 0 {
171 item := relPath[:i]
172 lang := relPath[i+len("/subtitles/"):]
173 h.serveSubtitles(item, lang, w, r)
174 return
175 }
176 h.serveMedia(relPath, w, r)
177}
178
179func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Request) {
180 filename := r.URL.Query().Get("file")
181 if filename == "" {
182 http.Error(w, "Missing file", http.StatusBadRequest)
183 return
184 }
185
186 mediaPath, err := h.librarySvc.GetMediaFile(relPath, filename)
187 if err != nil {
188 http.Error(w, "Not found", http.StatusNotFound)
189 return
190 }
191
192 http.ServeFile(w, r, mediaPath)
193}
194
195func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) {
196 filename := r.URL.Query().Get("file")
197
198 if thumb, ok := h.librarySvc.ThumbnailForFile(relPath, filename); ok {
199 http.ServeFile(w, r, thumb)
200 return
201 }
202
203 // Fall back to an icon matching the requested file's type.
204 icon := "video-icon.svg"
205 if item, err := h.librarySvc.GetByRelPath(relPath); err == nil {
206 if isAudioFile(item, filename) {
207 icon = "audio-icon.svg"
208 }
209 }
210 data, err := vidarchive.StaticFS.ReadFile("web/static/icons/" + icon)
211 if err != nil {
212 http.Error(w, "icon not found", http.StatusInternalServerError)
213 return
214 }
215 w.Header().Set("Content-Type", "image/svg+xml")
216 w.Write(data)
217}
218
219// isAudioFile checks the named file, or the first media file when unnamed.
220func isAudioFile(item *models.LibraryItem, filename string) bool {
221 if filename != "" {
222 for _, mf := range item.MediaFiles {
223 if mf.Filename == filename {
224 return mf.IsAudio
225 }
226 }
227 return false
228 }
229 return len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio
230}
231
232func (h *Handler) serveSubtitles(relPath, lang string, w http.ResponseWriter, r *http.Request) {
233 if lang == "" {
234 http.Error(w, "Missing language", http.StatusBadRequest)
235 return
236 }
237
238 if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
239 http.Error(w, "Invalid language", http.StatusBadRequest)
240 return
241 }
242
243 subtitlePath, err := h.librarySvc.GetSubtitlePath(relPath, lang)
244 if err != nil {
245 http.Error(w, "Not found", http.StatusNotFound)
246 return
247 }
248
249 w.Header().Set("Content-Type", "text/vtt")
250 http.ServeFile(w, r, subtitlePath)
251}
252
253func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) {
254 if err := h.librarySvc.Delete(relPath); err != nil {
255 redirectWithError(w, r, "/library", "Couldn't delete this item.", err)
256 return
257 }
258
259 redirectWithSuccess(w, r, "/library", "Item deleted.")
260}
261