handler.go
⎇
Raw
1package handler
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "net/http"
8 "net/url"
9 "path/filepath"
10 "strconv"
11 "strings"
12
13 "github.com/gabriel-vasile/mimetype"
14 "github.com/go-chi/chi/v5"
15
16 "vidarchive"
17 "vidarchive/internal/config"
18 "vidarchive/internal/models"
19 "vidarchive/internal/service"
20 "vidarchive/internal/worker"
21)
22
23type Handler struct {
24 cfg *config.Config
25 templates *template.Template
26 presetSvc *service.PresetService
27 downloadSvc *service.DownloadService
28 librarySvc *service.LibraryService
29 settingsSvc *service.SettingsService
30 subscriptionSvc *service.SubscriptionService
31 workerPool *worker.Pool
32}
33
34func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, librarySvc *service.LibraryService, settingsSvc *service.SettingsService, subscriptionSvc *service.SubscriptionService, workerPool *worker.Pool) (*Handler, error) {
35 presetSvcGlobal = presetSvc
36 tmpl, err := loadTemplates()
37 if err != nil {
38 return nil, fmt.Errorf("load templates: %w", err)
39 }
40
41 return &Handler{
42 cfg: cfg,
43 templates: tmpl,
44 presetSvc: presetSvc,
45 downloadSvc: downloadSvc,
46 librarySvc: librarySvc,
47 settingsSvc: settingsSvc,
48 subscriptionSvc: subscriptionSvc,
49 workerPool: workerPool,
50 }, nil
51}
52
53func loadTemplates() (*template.Template, error) {
54 tmpl := template.New("").Funcs(template.FuncMap{
55 "formatDuration": formatDuration,
56 "formatFileSize": formatFileSize,
57 "splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
58 "baseName": filepath.Base,
59 "ext": filepath.Ext,
60 "mimeType": mimeTypeFromFile,
61 "presetFlags": func(p *models.Preset) string { return presetSvcGlobal.EffectiveFlags(p, "", "") },
62 "urlEncode": template.URLQueryEscaper,
63 "urlEncodePath": func(p string) string {
64 parts := strings.Split(strings.Trim(p, "/"), "/")
65 for i, part := range parts {
66 parts[i] = url.PathEscape(part)
67 }
68 return strings.Join(parts, "/")
69 },
70 "sub": func(a, b int) int { return a - b },
71 // isLongText reports whether text spans more than ~2 lines, so the detail
72 // view can make long descriptions collapsible. Uses rune count (not bytes)
73 // so CJK text isn't flagged early.
74 "isLongText": func(s string) bool {
75 return strings.Count(s, "\n") >= 2 || len([]rune(s)) > 180
76 },
77 })
78
79 return tmpl.ParseFS(vidarchive.TemplatesFS, "web/templates/*.html")
80}
81
82var presetSvcGlobal *service.PresetService
83
84func formatDuration(seconds int) string {
85 if seconds <= 0 {
86 return "--:--"
87 }
88 m := seconds / 60
89 s := seconds % 60
90 h := m / 60
91 m = m % 60
92 if h > 0 {
93 return fmt.Sprintf("%d:%02d:%02d", h, m, s)
94 }
95 return fmt.Sprintf("%d:%02d", m, s)
96}
97
98func formatFileSize(bytes int64) string {
99 const unit = 1024
100 if bytes < unit {
101 return fmt.Sprintf("%d B", bytes)
102 }
103 div, exp := int64(unit), 0
104 for n := bytes / unit; n >= unit; n /= unit {
105 div *= unit
106 exp++
107 }
108 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
109}
110
111func mimeTypeFromFile(path string) string {
112 mtype, err := mimetype.DetectFile(path)
113 if err != nil || mtype == nil {
114 return "application/octet-stream"
115 }
116 return mtype.String()
117}
118
119type PageData struct {
120 Title string
121 ActiveTab string
122 Data interface{}
123 BaseURL string
124 AutoRefresh bool
125 RefreshSec int
126 Theme string
127}
128
129const cookieMaxAge = 365 * 24 * 60 * 60
130
131func getCookie(r *http.Request, name, defaultVal string) string {
132 cookie, err := r.Cookie(name)
133 if err != nil {
134 return defaultVal
135 }
136 return cookie.Value
137}
138
139func setCookie(w http.ResponseWriter, name, value string) {
140 http.SetCookie(w, &http.Cookie{
141 Name: name,
142 Value: value,
143 Path: "/",
144 MaxAge: cookieMaxAge,
145 HttpOnly: true,
146 SameSite: http.SameSiteLaxMode,
147 })
148}
149
150func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, contentTmpl string, data PageData) {
151 data.BaseURL = h.cfg.BaseURL
152 if data.Theme == "" {
153 data.Theme = getCookie(r, "theme", "auto")
154 }
155 w.Header().Set("Content-Type", "text/html; charset=utf-8")
156
157 var contentBuf bytes.Buffer
158 if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil {
159 http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError)
160 return
161 }
162
163 layoutData := struct {
164 PageData
165 Content template.HTML
166 }{
167 PageData: data,
168 Content: template.HTML(contentBuf.String()),
169 }
170
171 if err := h.templates.ExecuteTemplate(w, "layout", layoutData); err != nil {
172 http.Error(w, fmt.Sprintf("render layout: %v", err), http.StatusInternalServerError)
173 }
174}
175
176func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
177 path := r.URL.Query().Get("path")
178 sortBy := r.URL.Query().Get("sort")
179 filter := r.URL.Query().Get("filter")
180
181 if sortBy == "" {
182 sortBy = getCookie(r, "library_sort", "date")
183 } else {
184 setCookie(w, "library_sort", sortBy)
185 }
186
187 items, folders, err := h.librarySvc.GetAll(path, sortBy, filter)
188 if err != nil {
189 http.Error(w, err.Error(), http.StatusInternalServerError)
190 return
191 }
192
193 settings, _ := h.settingsSvc.GetAll()
194
195 h.renderWithRequest(w, r, "library", PageData{
196 Title: "Library",
197 ActiveTab: "library",
198 AutoRefresh: settings.AutoRefreshLibrary,
199 RefreshSec: settings.RefreshInterval,
200 Data: struct {
201 Items []*models.LibraryItem
202 Folders []string
203 Path string
204 SortBy string
205 Filter string
206 }{
207 Items: items,
208 Folders: folders,
209 Path: path,
210 SortBy: sortBy,
211 Filter: filter,
212 },
213 })
214}
215
216func normalizeRelPath(r *http.Request) string {
217 // chi gives the raw, still-encoded wildcard. Decode it as a URL path, where
218 // '+' is a literal plus (only query strings treat '+' as space) — so an item
219 // directory named "a+b" round-trips correctly.
220 relPath := chi.URLParam(r, "*")
221 relPath = strings.Trim(relPath, "/")
222 relPath, _ = url.PathUnescape(relPath)
223 return relPath
224}
225
226func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
227 relPath := normalizeRelPath(r)
228 if r.Method == "POST" && strings.HasSuffix(relPath, "/delete") {
229 relPath = strings.TrimSuffix(relPath, "/delete")
230 h.deleteMedia(relPath, w, r)
231 return
232 }
233 h.libraryDetail(relPath, w, r)
234}
235
236func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
237
238 ctx := r.Context()
239 item, err := h.librarySvc.GetByRelPath(ctx, relPath)
240 if err != nil {
241 http.Error(w, "Not found", http.StatusNotFound)
242 return
243 }
244
245 selectedFilename := r.URL.Query().Get("file")
246 if selectedFilename == "" && len(item.MediaFiles) > 0 {
247 selectedFilename = item.MediaFiles[0].Filename
248 }
249
250 meta, _ := h.librarySvc.GetMetadata(ctx, relPath, selectedFilename)
251 subtitles, _ := h.librarySvc.GetSubtitles(ctx, relPath)
252
253 h.renderWithRequest(w, r, "library_detail", PageData{
254 Title: item.Name,
255 ActiveTab: "library",
256 Data: struct {
257 Item *models.LibraryItem
258 SelectedFilename string
259 Metadata *service.MediaMetadata
260 Subtitles []models.SubtitleTrack
261 }{
262 Item: item,
263 SelectedFilename: selectedFilename,
264 Metadata: meta,
265 Subtitles: subtitles,
266 },
267 })
268}
269
270func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) {
271 relPath := normalizeRelPath(r)
272 if strings.HasSuffix(relPath, "/thumbnail") {
273 h.serveThumbnail(strings.TrimSuffix(relPath, "/thumbnail"), w, r)
274 return
275 }
276 // Subtitle tracks are addressed as <item>/subtitles/<lang>, with the language
277 // as a trailing path segment (see LibraryService.GetSubtitles).
278 if i := strings.LastIndex(relPath, "/subtitles/"); i >= 0 {
279 item := relPath[:i]
280 lang := relPath[i+len("/subtitles/"):]
281 h.serveSubtitles(item, lang, w, r)
282 return
283 }
284 h.serveMedia(relPath, w, r)
285}
286
287func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Request) {
288
289 filename := r.URL.Query().Get("file")
290 if filename == "" {
291 http.Error(w, "Missing file", http.StatusBadRequest)
292 return
293 }
294
295 mediaPath, err := h.librarySvc.GetMediaFile(r.Context(), relPath, filename)
296 if err != nil {
297 http.Error(w, "Not found", http.StatusNotFound)
298 return
299 }
300
301 http.ServeFile(w, r, mediaPath)
302}
303
304func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) {
305 ctx := r.Context()
306 filename := r.URL.Query().Get("file")
307
308 if thumb, ok := h.librarySvc.ThumbnailForFile(ctx, relPath, filename); ok {
309 http.ServeFile(w, r, thumb)
310 return
311 }
312
313 // Fall back to an icon, matched to the requested file's type (or the item's
314 // primary file when no specific file was requested).
315 icon := "video-icon.svg"
316 if item, err := h.librarySvc.GetByRelPath(ctx, relPath); err == nil {
317 if isAudioFile(item, filename) {
318 icon = "audio-icon.svg"
319 }
320 }
321 data, err := vidarchive.StaticFS.ReadFile("web/static/icons/" + icon)
322 if err != nil {
323 http.Error(w, "icon not found", http.StatusInternalServerError)
324 return
325 }
326 w.Header().Set("Content-Type", "image/svg+xml")
327 w.Write(data)
328}
329
330// isAudioFile reports whether the named file (or, if unnamed, the first media
331// file) of an item is audio.
332func isAudioFile(item *models.LibraryItem, filename string) bool {
333 if filename != "" {
334 for _, mf := range item.MediaFiles {
335 if mf.Filename == filename {
336 return mf.IsAudio
337 }
338 }
339 return false
340 }
341 return len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio
342}
343
344func (h *Handler) serveSubtitles(relPath, lang string, w http.ResponseWriter, r *http.Request) {
345
346 if lang == "" {
347 http.Error(w, "Missing language", http.StatusBadRequest)
348 return
349 }
350
351 if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
352 http.Error(w, "Invalid language", http.StatusBadRequest)
353 return
354 }
355
356 subtitlePath := h.librarySvc.GetSubtitlePath(relPath, lang)
357
358 w.Header().Set("Content-Type", "text/vtt")
359 http.ServeFile(w, r, subtitlePath)
360}
361
362func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) {
363
364 if err := h.librarySvc.Delete(relPath); err != nil {
365 http.Error(w, err.Error(), http.StatusInternalServerError)
366 return
367 }
368
369 http.Redirect(w, r, "/library", http.StatusSeeOther)
370}
371
372func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
373 status := r.URL.Query().Get("status")
374 sortBy := r.URL.Query().Get("sort")
375
376 if sortBy == "" {
377 sortBy = getCookie(r, "queue_sort", "date")
378 } else {
379 setCookie(w, "queue_sort", sortBy)
380 }
381
382 downloads, err := h.downloadSvc.GetAll(status, sortBy)
383 if err != nil {
384 http.Error(w, err.Error(), http.StatusInternalServerError)
385 return
386 }
387
388 settings, _ := h.settingsSvc.GetAll()
389
390 h.renderWithRequest(w, r, "queue", PageData{
391 Title: "Queue",
392 ActiveTab: "queue",
393 AutoRefresh: settings.AutoRefreshDownloads,
394 RefreshSec: settings.RefreshInterval,
395 Data: struct {
396 Items []*models.Download
397 Status string
398 SortBy string
399 }{
400 Items: downloads,
401 Status: status,
402 SortBy: sortBy,
403 },
404 })
405}
406
407func (h *Handler) DownloadDetail(w http.ResponseWriter, r *http.Request) {
408 idStr := chi.URLParam(r, "id")
409 id, err := strconv.ParseInt(idStr, 10, 64)
410 if err != nil {
411 http.Error(w, "Invalid ID", http.StatusBadRequest)
412 return
413 }
414
415 download, err := h.downloadSvc.GetByID(id)
416 if err != nil {
417 http.Error(w, "Not found", http.StatusNotFound)
418 return
419 }
420
421 h.renderWithRequest(w, r, "queue_detail", PageData{
422 Title: "Queue Details",
423 ActiveTab: "queue",
424 Data: download,
425 })
426}
427
428func (h *Handler) CreateDownload(w http.ResponseWriter, r *http.Request) {
429 if err := r.ParseForm(); err != nil {
430 http.Error(w, err.Error(), http.StatusBadRequest)
431 return
432 }
433
434 url := r.FormValue("url")
435 if url == "" {
436 http.Error(w, "URL is required", http.StatusBadRequest)
437 return
438 }
439
440 var presetID *int64
441 if pid := r.FormValue("preset_id"); pid != "" {
442 id, err := strconv.ParseInt(pid, 10, 64)
443 if err == nil {
444 presetID = &id
445 }
446 }
447
448 formatOverride := r.FormValue("format_override")
449 customFlags := r.FormValue("custom_flags")
450 outputDir := r.FormValue("output_dir")
451
452 download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags, outputDir)
453 if err != nil {
454 http.Error(w, err.Error(), http.StatusInternalServerError)
455 return
456 }
457
458 h.workerPool.Submit(download)
459
460 http.Redirect(w, r, "/queue", http.StatusSeeOther)
461}
462
463func (h *Handler) ListFormats(w http.ResponseWriter, r *http.Request) {
464 url := r.URL.Query().Get("url")
465 if url == "" {
466 http.Error(w, "URL is required", http.StatusBadRequest)
467 return
468 }
469
470 formats, err := h.downloadSvc.ListFormats(url)
471 if err != nil {
472 http.Error(w, err.Error(), http.StatusInternalServerError)
473 return
474 }
475
476 h.renderWithRequest(w, r, "formats", PageData{
477 Title: "Available Formats",
478 ActiveTab: "download",
479 Data: struct {
480 URL string
481 Formats []*models.FormatInfo
482 }{
483 URL: url,
484 Formats: formats,
485 },
486 })
487}
488
489func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) {
490 idStr := chi.URLParam(r, "id")
491 id, err := strconv.ParseInt(idStr, 10, 64)
492 if err != nil {
493 http.Error(w, "Invalid ID", http.StatusBadRequest)
494 return
495 }
496
497 if err := h.downloadSvc.Delete(id); err != nil {
498 http.Error(w, err.Error(), http.StatusInternalServerError)
499 return
500 }
501
502 http.Redirect(w, r, "/queue", http.StatusSeeOther)
503}
504
505func (h *Handler) ClearAllDownloads(w http.ResponseWriter, r *http.Request) {
506 if err := h.downloadSvc.DeleteAll(); err != nil {
507 http.Error(w, err.Error(), http.StatusInternalServerError)
508 return
509 }
510
511 http.Redirect(w, r, "/queue", http.StatusSeeOther)
512}
513
514func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
515 presets, err := h.presetSvc.GetAll()
516 if err != nil {
517 http.Error(w, err.Error(), http.StatusInternalServerError)
518 return
519 }
520
521 settings, err := h.settingsSvc.GetAll()
522 if err != nil {
523 http.Error(w, err.Error(), http.StatusInternalServerError)
524 return
525 }
526
527 h.renderWithRequest(w, r, "settings", PageData{
528 Title: "Settings",
529 ActiveTab: "settings",
530 Data: struct {
531 Presets []*models.Preset
532 Settings *models.Settings
533 }{
534 Presets: presets,
535 Settings: settings,
536 },
537 })
538}
539
540func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
541 if err := r.ParseForm(); err != nil {
542 http.Error(w, err.Error(), http.StatusBadRequest)
543 return
544 }
545
546 preset := &models.Preset{
547 Name: r.FormValue("name"),
548 Description: r.FormValue("description"),
549 FormatMode: r.FormValue("format_mode"),
550 Format: r.FormValue("format"),
551 Quality: r.FormValue("quality"),
552 CustomFormat: r.FormValue("custom_format"),
553 AudioFormat: r.FormValue("audio_format"),
554 SubLangs: r.FormValue("sub_langs"),
555 CustomFlags: r.FormValue("custom_flags"),
556 }
557
558 preset.IsDefault = r.FormValue("is_default") == "1"
559 preset.ExtractAudio = r.FormValue("extract_audio") == "1"
560 preset.EmbedSubs = r.FormValue("embed_subs") == "1"
561 preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
562 preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
563 preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
564
565 if err := h.presetSvc.Create(preset); err != nil {
566 http.Error(w, err.Error(), http.StatusInternalServerError)
567 return
568 }
569
570 http.Redirect(w, r, "/settings", http.StatusSeeOther)
571}
572
573func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
574 idStr := chi.URLParam(r, "id")
575 id, err := strconv.ParseInt(idStr, 10, 64)
576 if err != nil {
577 http.Error(w, "Invalid ID", http.StatusBadRequest)
578 return
579 }
580
581 if err := r.ParseForm(); err != nil {
582 http.Error(w, err.Error(), http.StatusBadRequest)
583 return
584 }
585
586 preset, err := h.presetSvc.GetByID(id)
587 if err != nil {
588 http.Error(w, "Not found", http.StatusNotFound)
589 return
590 }
591
592 preset.Name = r.FormValue("name")
593 preset.Description = r.FormValue("description")
594 preset.FormatMode = r.FormValue("format_mode")
595 preset.Format = r.FormValue("format")
596 preset.Quality = r.FormValue("quality")
597 preset.CustomFormat = r.FormValue("custom_format")
598 preset.AudioFormat = r.FormValue("audio_format")
599 preset.SubLangs = r.FormValue("sub_langs")
600 preset.CustomFlags = r.FormValue("custom_flags")
601 preset.IsDefault = r.FormValue("is_default") == "1"
602 preset.ExtractAudio = r.FormValue("extract_audio") == "1"
603 preset.EmbedSubs = r.FormValue("embed_subs") == "1"
604 preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
605 preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
606 preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
607
608 if err := h.presetSvc.Update(preset); err != nil {
609 http.Error(w, err.Error(), http.StatusInternalServerError)
610 return
611 }
612
613 http.Redirect(w, r, "/settings", http.StatusSeeOther)
614}
615
616func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) {
617 idStr := chi.URLParam(r, "id")
618 id, err := strconv.ParseInt(idStr, 10, 64)
619 if err != nil {
620 http.Error(w, "Invalid ID", http.StatusBadRequest)
621 return
622 }
623
624 if err := h.presetSvc.Delete(id); err != nil {
625 http.Error(w, err.Error(), http.StatusInternalServerError)
626 return
627 }
628
629 http.Redirect(w, r, "/settings", http.StatusSeeOther)
630}
631
632func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
633 if err := r.ParseForm(); err != nil {
634 http.Error(w, err.Error(), http.StatusBadRequest)
635 return
636 }
637
638 if interval := r.FormValue("refresh_interval"); interval != "" {
639 h.settingsSvc.SetRefreshInterval(interval)
640 }
641
642 h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1")
643 h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1")
644 h.settingsSvc.SetCookies(r.FormValue("cookies"))
645
646 http.Redirect(w, r, "/settings", http.StatusSeeOther)
647}
648
649func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
650 presets, err := h.presetSvc.GetAll()
651 if err != nil {
652 http.Error(w, err.Error(), http.StatusInternalServerError)
653 return
654 }
655
656 defaultPreset, _ := h.presetSvc.GetDefault()
657
658 url := r.URL.Query().Get("url")
659 var formats []*models.FormatInfo
660 if r.URL.Query().Get("list_formats") == "1" && url != "" {
661 formats, _ = h.downloadSvc.ListFormats(url)
662 }
663
664 h.renderWithRequest(w, r, "download_form", PageData{
665 Title: "Download",
666 ActiveTab: "download",
667 Data: struct {
668 Presets []*models.Preset
669 DefaultPreset *models.Preset
670 URL string
671 FormatOverride string
672 CustomFlags string
673 OutputDir string
674 Formats []*models.FormatInfo
675 ShowFormats bool
676 }{
677 Presets: presets,
678 DefaultPreset: defaultPreset,
679 URL: url,
680 FormatOverride: r.URL.Query().Get("format_override"),
681 CustomFlags: r.URL.Query().Get("custom_flags"),
682 OutputDir: r.URL.Query().Get("output_dir"),
683 Formats: formats,
684 ShowFormats: r.URL.Query().Get("list_formats") == "1",
685 },
686 })
687}
688
689func (h *Handler) GetPresetFlags(w http.ResponseWriter, r *http.Request) {
690 idStr := r.URL.Query().Get("id")
691 id, err := strconv.ParseInt(idStr, 10, 64)
692 if err != nil {
693 http.Error(w, "Invalid ID", http.StatusBadRequest)
694 return
695 }
696
697 preset, err := h.presetSvc.GetByID(id)
698 if err != nil {
699 http.Error(w, "Not found", http.StatusNotFound)
700 return
701 }
702
703 flags := h.presetSvc.EffectiveFlags(preset, "", "")
704 w.Header().Set("Content-Type", "text/plain")
705 w.Write([]byte(flags))
706}
707
708func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
709 if err := r.ParseForm(); err != nil {
710 http.Error(w, err.Error(), http.StatusBadRequest)
711 return
712 }
713
714 theme := r.FormValue("theme")
715 if theme == "" {
716 theme = "auto"
717 }
718
719 setCookie(w, "theme", theme)
720
721 referer := r.Header.Get("Referer")
722 if referer == "" {
723 referer = "/"
724 }
725 http.Redirect(w, r, referer, http.StatusSeeOther)
726}
727