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