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 // Only the items are paginated. Folders are few and act as navigation, so
29 // 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 gives the raw, still-encoded wildcard. Decode it as a URL path, where
62 // '+' is a literal plus (only query strings treat '+' as space) — so an item
63 // directory named "a+b" round-trips correctly.
64 relPath := chi.URLParam(r, "*")
65 relPath = strings.Trim(relPath, "/")
66 if decoded, err := url.PathUnescape(relPath); err == nil {
67 relPath = decoded
68 } else {
69 slog.Warn("undecodable path", "path", relPath, "err", err)
70 }
71 return relPath
72}
73
74func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
75 relPath := normalizeRelPath(r)
76 if r.Method == "POST" && strings.HasSuffix(relPath, "/delete") {
77 relPath = strings.TrimSuffix(relPath, "/delete")
78 h.deleteMedia(relPath, w, r)
79 return
80 }
81 h.libraryDetail(relPath, w, r)
82}
83
84const commentPreviewLimit = 50
85
86func (h *Handler) LibraryComments(w http.ResponseWriter, r *http.Request) {
87 relPath := normalizeRelPath(r)
88 h.libraryComments(relPath, w, r)
89}
90
91func (h *Handler) libraryComments(relPath string, w http.ResponseWriter, r *http.Request) {
92 item, err := h.librarySvc.GetByRelPath(relPath)
93 if err != nil {
94 http.Error(w, "Not found", http.StatusNotFound)
95 return
96 }
97 comments, _, err := h.librarySvc.GetEngagement(relPath)
98 if err != nil {
99 h.serverError(w, r, "load comments", err)
100 return
101 }
102
103 h.renderWithRequest(w, r, "library_comments", PageData{
104 Title: "Comments - " + item.Name,
105 ActiveTab: "library",
106 Data: struct {
107 Item *models.LibraryItem
108 Comments []models.Comment
109 }{Item: item, Comments: comments},
110 })
111}
112
113func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
114 item, err := h.librarySvc.GetByRelPath(relPath)
115 if err != nil {
116 http.Error(w, "Not found", http.StatusNotFound)
117 return
118 }
119
120 selectedFilename := r.URL.Query().Get("file")
121 if selectedFilename == "" && len(item.MediaFiles) > 0 {
122 selectedFilename = item.MediaFiles[0].Filename
123 }
124
125 meta, err := h.librarySvc.GetMetadata(relPath, selectedFilename)
126 if err != nil {
127 slog.Warn("metadata unavailable", "path", relPath, "err", err)
128 }
129 subtitles, err := h.librarySvc.GetSubtitles(relPath)
130 if err != nil {
131 slog.Warn("subtitles unavailable", "path", relPath, "err", err)
132 }
133 comments, heatmap, err := h.librarySvc.GetEngagement(relPath)
134 if err != nil {
135 slog.Warn("engagement unavailable", "path", relPath, "err", err)
136 }
137 previewComments := comments
138 if len(previewComments) > commentPreviewLimit {
139 previewComments = previewComments[:commentPreviewLimit]
140 }
141
142 h.renderWithRequest(w, r, "library_detail", PageData{
143 Title: item.Name,
144 ActiveTab: "library",
145 Data: struct {
146 Item *models.LibraryItem
147 SelectedFilename string
148 Metadata *service.MediaMetadata
149 Subtitles []models.SubtitleTrack
150 Comments []models.Comment
151 CommentTotal int
152 Heatmap []models.HeatmapSegment
153 }{
154 Item: item,
155 SelectedFilename: selectedFilename,
156 Metadata: meta,
157 Subtitles: subtitles,
158 Comments: previewComments,
159 CommentTotal: len(comments),
160 Heatmap: heatmap,
161 },
162 })
163}
164
165func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) {
166 relPath := normalizeRelPath(r)
167 if strings.HasSuffix(relPath, "/thumbnail") {
168 h.serveThumbnail(strings.TrimSuffix(relPath, "/thumbnail"), w, r)
169 return
170 }
171 // Subtitle tracks are addressed as <item>/subtitles/<lang>, with the language
172 // as a trailing path segment (see LibraryService.GetSubtitles).
173 if i := strings.LastIndex(relPath, "/subtitles/"); i >= 0 {
174 item := relPath[:i]
175 lang := relPath[i+len("/subtitles/"):]
176 h.serveSubtitles(item, lang, w, r)
177 return
178 }
179 h.serveMedia(relPath, w, r)
180}
181
182func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Request) {
183 filename := r.URL.Query().Get("file")
184 if filename == "" {
185 http.Error(w, "Missing file", http.StatusBadRequest)
186 return
187 }
188
189 mediaPath, err := h.librarySvc.GetMediaFile(relPath, filename)
190 if err != nil {
191 http.Error(w, "Not found", http.StatusNotFound)
192 return
193 }
194
195 http.ServeFile(w, r, mediaPath)
196}
197
198func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) {
199 filename := r.URL.Query().Get("file")
200
201 if thumb, ok := h.librarySvc.ThumbnailForFile(relPath, filename); ok {
202 http.ServeFile(w, r, thumb)
203 return
204 }
205
206 // Fall back to an icon, matched to the requested file's type (or the item's
207 // primary file when no specific file was requested).
208 icon := "video-icon.svg"
209 if item, err := h.librarySvc.GetByRelPath(relPath); err == nil {
210 if isAudioFile(item, filename) {
211 icon = "audio-icon.svg"
212 }
213 }
214 data, err := vidarchive.StaticFS.ReadFile("web/static/icons/" + icon)
215 if err != nil {
216 http.Error(w, "icon not found", http.StatusInternalServerError)
217 return
218 }
219 w.Header().Set("Content-Type", "image/svg+xml")
220 w.Write(data)
221}
222
223// isAudioFile reports whether the named file (or, if unnamed, the first media
224// file) of an item is audio.
225func isAudioFile(item *models.LibraryItem, filename string) bool {
226 if filename != "" {
227 for _, mf := range item.MediaFiles {
228 if mf.Filename == filename {
229 return mf.IsAudio
230 }
231 }
232 return false
233 }
234 return len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio
235}
236
237func (h *Handler) serveSubtitles(relPath, lang string, w http.ResponseWriter, r *http.Request) {
238 if lang == "" {
239 http.Error(w, "Missing language", http.StatusBadRequest)
240 return
241 }
242
243 if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
244 http.Error(w, "Invalid language", http.StatusBadRequest)
245 return
246 }
247
248 subtitlePath, err := h.librarySvc.GetSubtitlePath(relPath, lang)
249 if err != nil {
250 http.Error(w, "Not found", http.StatusNotFound)
251 return
252 }
253
254 w.Header().Set("Content-Type", "text/vtt")
255 http.ServeFile(w, r, subtitlePath)
256}
257
258func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) {
259 if err := h.librarySvc.Delete(relPath); err != nil {
260 redirectWithError(w, r, "/library", "Couldn't delete this item.", err)
261 return
262 }
263
264 redirectWithSuccess(w, r, "/library", "Item deleted.")
265}
266