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