add youtube heatmap and (generic) comment support
Minternal/database/database.go
@@ -193,6 +193,10 @@ func migrate(db *sql.DB) error {
CREATE INDEX IF NOT EXISTS idx_downloads_subscription_id ON downloads(subscription_id);
CREATE INDEX IF NOT EXISTS idx_downloads_created_at ON downloads(created_at);
CREATE INDEX IF NOT EXISTS idx_subscriptions_due ON subscriptions(enabled, next_run_at);`},
{18, `ALTER TABLE presets ADD COLUMN write_comments INTEGER DEFAULT 0;
ALTER TABLE presets ADD COLUMN comment_sort TEXT;
ALTER TABLE presets ADD COLUMN max_comments INTEGER DEFAULT 0;
ALTER TABLE presets ADD COLUMN comment_extractor_args TEXT;`},
}
for _, m := range migrations {
Minternal/handler/handler.go
@@ -56,14 +56,17 @@ func loadTemplates(presetSvc *service.PresetService) (*template.Template, error)
tmpl := template.New("").Funcs(template.FuncMap{
"formatDuration": formatDuration,
"formatFileSize": util.FormatBytes,
"splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
"baseName": filepath.Base,
"ext": filepath.Ext,
"mimeType": mimeTypeFromFile,
"presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") },
"urlEncode": template.URLQueryEscaper,
"urlEncodePath": util.URLEncodePath,
"sub": func(a, b int) int { return a - b },
"heatmapRange": func(hm models.HeatmapSegment) string {
return fmt.Sprintf("%s to %s, activity %.2f", util.FormatClock(int(hm.StartTime+0.5)), util.FormatClock(int(hm.EndTime+0.5)), hm.Value)
},
"splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
"baseName": filepath.Base,
"ext": filepath.Ext,
"mimeType": mimeTypeFromFile,
"presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") },
"urlEncode": template.URLQueryEscaper,
"urlEncodePath": util.URLEncodePath,
"sub": func(a, b int) int { return a - b },
// emptyPreset / newSubscription supply a zero value so the shared create and
// edit form partials can be rendered from the create page too. newSubscription
// carries the create-time defaults (overwrite mode, daily schedule).
@@ -301,6 +304,35 @@ func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
h.libraryDetail(relPath, w, r)
}
const commentPreviewLimit = 50
func (h *Handler) LibraryComments(w http.ResponseWriter, r *http.Request) {
relPath := normalizeRelPath(r)
h.libraryComments(relPath, w, r)
}
func (h *Handler) libraryComments(relPath string, w http.ResponseWriter, r *http.Request) {
item, err := h.librarySvc.GetByRelPath(relPath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
comments, _, err := h.librarySvc.GetEngagement(relPath)
if err != nil {
h.serverError(w, r, "load comments", err)
return
}
h.renderWithRequest(w, r, "library_comments", PageData{
Title: "Comments - " + item.Name,
ActiveTab: "library",
Data: struct {
Item *models.LibraryItem
Comments []models.Comment
}{Item: item, Comments: comments},
})
}
func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
item, err := h.librarySvc.GetByRelPath(relPath)
if err != nil {
@@ -321,6 +353,14 @@ func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.R
if err != nil {
log.Printf("libraryDetail %q: subtitles unavailable: %v", relPath, err)
}
comments, heatmap, err := h.librarySvc.GetEngagement(relPath)
if err != nil {
log.Printf("libraryDetail %q: engagement unavailable: %v", relPath, err)
}
previewComments := comments
if len(previewComments) > commentPreviewLimit {
previewComments = previewComments[:commentPreviewLimit]
}
h.renderWithRequest(w, r, "library_detail", PageData{
Title: item.Name,
@@ -330,11 +370,17 @@ func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.R
SelectedFilename string
Metadata *service.MediaMetadata
Subtitles []models.SubtitleTrack
Comments []models.Comment
CommentTotal int
Heatmap []models.HeatmapSegment
}{
Item: item,
SelectedFilename: selectedFilename,
Metadata: meta,
Subtitles: subtitles,
Comments: previewComments,
CommentTotal: len(comments),
Heatmap: heatmap,
},
})
}
@@ -626,6 +672,25 @@ func applyPresetForm(p *models.Preset, r *http.Request) error {
p.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
p.EmbedMetadata = r.FormValue("embed_metadata") == "1"
p.WriteInfoJSON = r.FormValue("write_info_json") == "1"
p.WriteComments = r.FormValue("write_comments") == "1"
p.CommentSort = strings.TrimSpace(r.FormValue("comment_sort"))
p.CommentExtractorArgs = strings.TrimSpace(r.FormValue("comment_extractor_args"))
p.MaxComments = 0
if raw := strings.TrimSpace(r.FormValue("max_comments")); raw != "" {
maxComments, err := strconv.Atoi(raw)
if err != nil || maxComments < 0 {
return fmt.Errorf("Max comments must be a non-negative number.")
}
p.MaxComments = maxComments
}
// Comments are stored in the info JSON sidecar. Keep the dependent options
// consistent even when a client submits the form without JavaScript.
if !p.WriteInfoJSON || !p.WriteComments {
p.WriteComments = false
p.CommentSort = ""
p.MaxComments = 0
p.CommentExtractorArgs = ""
}
return nil
}
Ainternal/handler/handler_test.go
@@ -0,0 +1,52 @@
package handler
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
"vidarchive/internal/models"
)
func TestApplyPresetFormClearsDependentCommentSettings(t *testing.T) {
tests := []struct {
name string
writeInfo string
writeComments string
wantCleared bool
}{
{"info JSON disabled", "", "1", true},
{"comments disabled", "1", "", true},
{"both enabled", "1", "1", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
form := url.Values{
"name": {"Preset"},
"format_mode": {"default"},
"write_info_json": {tc.writeInfo},
"write_comments": {tc.writeComments},
"comment_sort": {"top"},
"max_comments": {"25"},
"comment_extractor_args": {"youtube:player_client=web"},
}
r := httptest.NewRequest("POST", "/settings/presets", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
preset := &models.Preset{}
if err := applyPresetForm(preset, r); err != nil {
t.Fatalf("applyPresetForm: %v", err)
}
if tc.wantCleared {
if preset.WriteComments || preset.CommentSort != "" || preset.MaxComments != 0 || preset.CommentExtractorArgs != "" {
t.Fatalf("dependent settings were not cleared: %+v", preset)
}
return
}
if !preset.WriteComments || preset.CommentSort != "top" || preset.MaxComments != 25 || preset.CommentExtractorArgs == "" {
t.Fatalf("comment settings not retained: %+v", preset)
}
})
}
}
Minternal/models/models.go
@@ -6,23 +6,27 @@ import (
)
type Preset struct {
ID int64
Name string
Description string
IsDefault bool
FormatMode string
Format string
Quality string
CustomFormat string
ExtractAudio bool
AudioFormat string
EmbedSubs bool
SubLangs string
EmbedThumbnail bool
EmbedMetadata bool
WriteInfoJSON bool
CustomFlags string
CreatedAt time.Time
ID int64
Name string
Description string
IsDefault bool
FormatMode string
Format string
Quality string
CustomFormat string
ExtractAudio bool
AudioFormat string
EmbedSubs bool
SubLangs string
EmbedThumbnail bool
EmbedMetadata bool
WriteInfoJSON bool
WriteComments bool
CommentSort string
MaxComments int
CommentExtractorArgs string
CustomFlags string
CreatedAt time.Time
}
type LibraryItem struct {
@@ -105,6 +109,27 @@ type SubtitleTrack struct {
Src string
}
type Comment struct {
ID string
Parent string
Author string
Text string
Timestamp int64
TimeText string
LikeCount int
AuthorIsUploader bool
ReplyTo string
Depth int
}
type HeatmapSegment struct {
StartTime float64
EndTime float64
Value float64
Width float64
Height float64
}
type ItemMetadata struct {
Name string `toml:"name"`
SourceURL string `toml:"source_url"`
Minternal/repository/preset.go
@@ -23,11 +23,12 @@ func NewPresetRepository(db *sql.DB) *PresetRepository {
func insertPreset(e execer, p *models.Preset) error {
result, err := e.Exec(
`INSERT INTO presets (name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO presets (name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, write_comments, comment_sort, max_comments, comment_extractor_args, custom_flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
boolToInt(p.WriteComments), p.CommentSort, p.MaxComments, p.CommentExtractorArgs,
p.CustomFlags,
)
if err != nil {
@@ -43,11 +44,12 @@ func insertPreset(e execer, p *models.Preset) error {
func updatePreset(e execer, p *models.Preset) error {
_, err := e.Exec(
`UPDATE presets SET name=?, description=?, is_default=?, format_mode=?, format=?, quality=?, custom_format=?, extract_audio=?, audio_format=?, embed_subs=?, sub_langs=?, embed_thumbnail=?, embed_metadata=?, write_info_json=?, custom_flags=?
`UPDATE presets SET name=?, description=?, is_default=?, format_mode=?, format=?, quality=?, custom_format=?, extract_audio=?, audio_format=?, embed_subs=?, sub_langs=?, embed_thumbnail=?, embed_metadata=?, write_info_json=?, write_comments=?, comment_sort=?, max_comments=?, comment_extractor_args=?, custom_flags=?
WHERE id=?`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
boolToInt(p.WriteComments), p.CommentSort, p.MaxComments, p.CommentExtractorArgs,
p.CustomFlags, p.ID,
)
return err
@@ -88,7 +90,7 @@ func (r *PresetRepository) Save(p *models.Preset) error {
func (r *PresetRepository) GetByID(id int64) (*models.Preset, error) {
row := r.db.QueryRow(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags, created_at
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, write_comments, comment_sort, max_comments, comment_extractor_args, custom_flags, created_at
FROM presets WHERE id = ?`, id,
)
return scanPreset(row)
@@ -96,7 +98,7 @@ func (r *PresetRepository) GetByID(id int64) (*models.Preset, error) {
func (r *PresetRepository) GetAll() ([]*models.Preset, error) {
rows, err := r.db.Query(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags, created_at
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, write_comments, comment_sort, max_comments, comment_extractor_args, custom_flags, created_at
FROM presets ORDER BY name`,
)
if err != nil {
@@ -117,7 +119,7 @@ func (r *PresetRepository) GetAll() ([]*models.Preset, error) {
func (r *PresetRepository) GetDefault() (*models.Preset, error) {
row := r.db.QueryRow(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags, created_at
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, write_comments, comment_sort, max_comments, comment_extractor_args, custom_flags, created_at
FROM presets WHERE is_default = 1 LIMIT 1`,
)
return scanPreset(row)
@@ -130,12 +132,13 @@ func (r *PresetRepository) Delete(id int64) error {
func scanPreset(row interface{ Scan(...interface{}) error }) (*models.Preset, error) {
var p models.Preset
var isDefault, extractAudio, embedSubs, embedThumbnail, embedMetadata, writeInfoJSON int
var description, formatMode, format, quality, customFormat, audioFormat, subLangs, customFlags sql.NullString
var isDefault, extractAudio, embedSubs, embedThumbnail, embedMetadata, writeInfoJSON, writeComments, maxComments int
var description, formatMode, format, quality, customFormat, audioFormat, subLangs, commentSort, commentExtractorArgs, customFlags sql.NullString
err := row.Scan(
&p.ID, &p.Name, &description, &isDefault, &formatMode, &format, &quality, &customFormat,
&extractAudio, &audioFormat, &embedSubs, &subLangs,
&embedThumbnail, &embedMetadata, &writeInfoJSON,
&writeComments, &commentSort, &maxComments, &commentExtractorArgs,
&customFlags, &p.CreatedAt,
)
if err != nil {
@@ -149,12 +152,16 @@ func scanPreset(row interface{ Scan(...interface{}) error }) (*models.Preset, er
p.AudioFormat = audioFormat.String
p.SubLangs = subLangs.String
p.CustomFlags = customFlags.String
p.CommentSort = commentSort.String
p.CommentExtractorArgs = commentExtractorArgs.String
p.MaxComments = maxComments
p.IsDefault = isDefault == 1
p.ExtractAudio = extractAudio == 1
p.EmbedSubs = embedSubs == 1
p.EmbedThumbnail = embedThumbnail == 1
p.EmbedMetadata = embedMetadata == 1
p.WriteInfoJSON = writeInfoJSON == 1
p.WriteComments = writeComments == 1
return &p, nil
}
Minternal/server/server.go
@@ -54,6 +54,7 @@ func (s *Server) setupRoutes() {
http.Redirect(w, r, "/library", http.StatusSeeOther)
})
s.router.Get("/library", s.handler.Library)
s.router.Get("/library/comments/*", s.handler.LibraryComments)
s.router.Get("/library/item/*", s.handler.LibraryItem)
s.router.Post("/library/item/*", s.handler.LibraryItem)
Minternal/server/server_test.go
@@ -90,6 +90,76 @@ func createItem(t *testing.T, libraryDir, relPath, name string, files map[string
}
}
func TestLibraryEngagementViews(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "engagement", "Engagement", map[string]string{
"video.mp4": "dummy video",
"info.json": `{"comments":[{"id":"parent","author":"<parent>","text":"safe <comment>","time_text":"today"},{"id":"reply","parent":"parent","author":"<child>","text":"reply","time_text":"today"}],"heatmap":[{"start_time":0,"end_time":10,"value":1}]}`,
})
detail := getWith(srv.Router(), "/library/item/engagement", nil)
if detail.Code != http.StatusOK {
t.Fatalf("detail status = %d", detail.Code)
}
body := detail.Body.String()
for _, want := range []string{"Playback heatmap", "<parent>", "<child>", "safe <comment>", "replying to <strong><parent></strong>", "View all comments"} {
if !strings.Contains(body, want) {
t.Errorf("detail missing %q", want)
}
}
all := getWith(srv.Router(), "/library/comments/engagement", nil)
if all.Code != http.StatusOK || !strings.Contains(all.Body.String(), "Engagement comments") {
t.Fatalf("comments view status/body unexpected: %d %s", all.Code, all.Body.String())
}
// A real item whose path ends in "comments" must remain reachable through
// the item route now that the full comments view has its own namespace.
createItem(t, cfg.LibraryDir, "playlist/comments", "Nested Comments Item", map[string]string{
"video.mp4": "dummy video",
})
item := getWith(srv.Router(), "/library/item/playlist/comments", nil)
if item.Code != http.StatusOK || !strings.Contains(item.Body.String(), "Nested Comments Item") {
t.Fatalf("item path ending in comments was shadowed: %d %s", item.Code, item.Body.String())
}
}
func TestCommentsViewMalformedSidecarIsError(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "bad-comments", "Bad comments", map[string]string{
"video.mp4": "dummy video",
"info.json": `{"comments":[`,
})
w := getWith(srv.Router(), "/library/comments/bad-comments", nil)
if w.Code != http.StatusInternalServerError {
t.Fatalf("malformed comments status = %d, want 500", w.Code)
}
if !strings.Contains(w.Body.String(), "Something went wrong") {
t.Fatalf("malformed comments response = %q", w.Body.String())
}
}
func TestCommentsViewEncodedPath(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
const item = "nested/An Item +"
createItem(t, cfg.LibraryDir, item, "Encoded comments", map[string]string{
"video.mp4": "dummy video",
"info.json": `{"comments":[{"author":"author","text":"hello"}]}`,
})
reqURL := "/library/comments/" + (&url.URL{Path: item}).EscapedPath()
w := getWith(srv.Router(), reqURL, nil)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Encoded comments") {
t.Fatalf("encoded comments route %s: status=%d body=%s", reqURL, w.Code, w.Body.String())
}
}
func TestNestedLibraryItem(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
Minternal/service/download.go
@@ -787,7 +787,7 @@ func (s *DownloadService) refreshAndAddNew(ctx context.Context, d *models.Downlo
continue
}
if existing, ok := s.librarySvc.FindByVideoID(baseLibraryDir, info.ID); ok {
if err := s.applyMetadata(existing, info); err != nil {
if err := s.applyMetadata(existing, info, infoJSONPath); err != nil {
log.Printf("warning: failed to refresh metadata for %s: %v", itemDir, err)
}
continue
@@ -833,9 +833,76 @@ func (s *DownloadService) downloadFresh(ctx context.Context, d *models.Download,
return runErr
}
// applyMetadata rewrites an existing item's marker (name/description/identity)
// from a fresh info.json without touching its media.
func (s *DownloadService) applyMetadata(existing string, info infoJSON) error {
// mergeInfoJSON keeps fields from the existing sidecar that are absent from a
// metadata-only refresh. In particular, comments and heatmap data are expensive
// to reacquire and must not disappear just because the refresh preset does not
// request them.
func mergeInfoJSON(oldData, newData []byte) ([]byte, error) {
var oldObject, newObject map[string]json.RawMessage
if err := json.Unmarshal(newData, &newObject); err != nil {
return nil, err
}
if err := json.Unmarshal(oldData, &oldObject); err != nil {
return newData, nil
}
if newObject == nil {
return newData, nil
}
merged := make(map[string]json.RawMessage, len(oldObject)+len(newObject))
for key, value := range oldObject {
merged[key] = value
}
for key, value := range newObject {
merged[key] = value
}
for _, key := range []string{"comments", "heatmap"} {
oldValue, hadOldValue := oldObject[key]
newValue, hasNewValue := newObject[key]
if hadOldValue && (!hasNewValue || isEmptyJSONArray(newValue)) {
merged[key] = oldValue
}
}
return json.Marshal(merged)
}
func isEmptyJSONArray(value json.RawMessage) bool {
var values []json.RawMessage
if err := json.Unmarshal(value, &values); err != nil {
return false
}
return len(values) == 0
}
// restoreFileAtomically puts data back at path without exposing a partial file.
// It is used to roll back the marker if installing the staged info sidecar fails.
func restoreFileAtomically(path string, data []byte) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".vidarchive-restore-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(0644); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
return nil
}
// applyMetadata refreshes an existing item's marker and info sidecar from a
// fresh info.json without touching its media.
func (s *DownloadService) applyMetadata(existing string, info infoJSON, sourceInfoJSON string) error {
meta, err := s.librarySvc.readMetadata(existing)
if err != nil {
return fmt.Errorf("read metadata for %s: %w", existing, err)
@@ -851,9 +918,66 @@ func (s *DownloadService) applyMetadata(existing string, info infoJSON) error {
}
meta.VideoID = info.ID
markerPath := filepath.Join(existing, itemMarkerName)
oldMarker, err := os.ReadFile(markerPath)
if err != nil {
return fmt.Errorf("read existing marker: %w", err)
}
// Stage the sidecar before changing the marker. The marker is committed first;
// if installing the sidecar then fails, restore the old marker so an ordinary
// I/O error cannot leave the two metadata files out of sync.
stagedInfo := ""
defer func() {
if stagedInfo != "" {
_ = os.Remove(stagedInfo)
}
}()
if sourceInfoJSON != "" {
data, err := os.ReadFile(sourceInfoJSON)
if err != nil {
return fmt.Errorf("read refreshed info JSON: %w", err)
}
if oldInfoJSON := findInfoJSON(existing); oldInfoJSON != "" {
oldData, err := os.ReadFile(oldInfoJSON)
if err != nil {
return fmt.Errorf("read existing info JSON: %w", err)
}
data, err = mergeInfoJSON(oldData, data)
if err != nil {
return fmt.Errorf("merge refreshed info JSON: %w", err)
}
}
tmp, err := os.CreateTemp(existing, ".info-json-*.tmp")
if err != nil {
return fmt.Errorf("create refreshed info JSON: %w", err)
}
stagedInfo = tmp.Name()
if err := tmp.Chmod(0644); err != nil {
tmp.Close()
return fmt.Errorf("set refreshed info JSON permissions: %w", err)
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write refreshed info JSON: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close refreshed info JSON: %w", err)
}
}
if err := s.librarySvc.writeMetadata(existing, meta); err != nil {
return err
}
if stagedInfo != "" {
if err := os.Rename(stagedInfo, filepath.Join(existing, "info.json")); err != nil {
if restoreErr := restoreFileAtomically(markerPath, oldMarker); restoreErr != nil {
return fmt.Errorf("install refreshed info JSON: %v; restore marker: %w", err, restoreErr)
}
return fmt.Errorf("install refreshed info JSON: %w", err)
}
stagedInfo = ""
}
if rel, err := filepath.Rel(s.cfg.LibraryDir, existing); err == nil {
s.librarySvc.evictCachedScan(filepath.ToSlash(rel))
}
Minternal/service/download_test.go
@@ -3,6 +3,7 @@ package service
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
@@ -199,6 +200,87 @@ func TestImportItemDir(t *testing.T) {
}
}
func TestApplyMetadataPreservesEngagementAndPermissions(t *testing.T) {
e := newExecEnv(t)
existing := writeItem(t, e.cfg.LibraryDir, "existing", `name = "Old"
video_id = "old"
`, map[string]string{
"video.mp4": "dummy",
"info.json": `{"id":"old","title":"Old","comments":[{"id":"c1","text":"archived"}],"heatmap":[{"start_time":0,"end_time":1,"value":1}],"filesize":123}`,
})
source := filepath.Join(e.scratch, "refreshed.info.json")
if err := os.WriteFile(source, []byte(`{"id":"new","title":"New","description":"updated","comments":[]}`), 0644); err != nil {
t.Fatal(err)
}
if err := e.svc.applyMetadata(existing, infoJSON{ID: "new", Title: "New", Description: "updated"}, source); err != nil {
t.Fatalf("applyMetadata: %v", err)
}
data, err := os.ReadFile(filepath.Join(existing, "info.json"))
if err != nil {
t.Fatal(err)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("parse installed info JSON: %v", err)
}
if _, ok := got["comments"]; !ok {
t.Fatal("refreshed sidecar lost archived comments")
}
if _, ok := got["heatmap"]; !ok {
t.Fatal("refreshed sidecar lost archived heatmap")
}
if string(got["title"]) != `"New"` {
t.Errorf("title = %s, want New", got["title"])
}
if mode := fileMode(t, filepath.Join(existing, "info.json")); mode != 0644 {
t.Errorf("info.json mode = %o, want 0644", mode)
}
}
func TestApplyMetadataRollsBackMarkerWhenSidecarInstallFails(t *testing.T) {
e := newExecEnv(t)
existing := writeItem(t, e.cfg.LibraryDir, "rollback", `name = "Old"
video_id = "old"
`, map[string]string{"video.mp4": "dummy"})
markerPath := filepath.Join(existing, itemMarkerName)
before, err := os.ReadFile(markerPath)
if err != nil {
t.Fatal(err)
}
// A directory at the destination makes the final rename fail after the
// marker has been written, exercising the rollback path.
infoPath := filepath.Join(existing, "info.json")
if err := os.Mkdir(infoPath, 0755); err != nil {
t.Fatal(err)
}
source := filepath.Join(e.scratch, "rollback.info.json")
if err := os.WriteFile(source, []byte(`{"id":"new","title":"New"}`), 0644); err != nil {
t.Fatal(err)
}
if err := e.svc.applyMetadata(existing, infoJSON{ID: "new", Title: "New"}, source); err == nil {
t.Fatal("expected sidecar installation to fail")
}
after, err := os.ReadFile(markerPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Errorf("marker changed after failed sidecar install:\nbefore: %s\nafter: %s", before, after)
}
}
func fileMode(t *testing.T, path string) os.FileMode {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return info.Mode().Perm()
}
func TestCheckReservedFlags(t *testing.T) {
cases := []struct {
name string
Minternal/service/library.go
@@ -967,6 +967,199 @@ func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, e
return nil, nil
}
// GetEngagement reads optional comments and a playback heatmap from the item's
// yt-dlp info sidecar. These fields are intentionally not copied into the
// marker: the sidecar remains the source of truth and old items simply return
// empty data when the fields are absent.
func (s *LibraryService) GetEngagement(relPath string) ([]models.Comment, []models.HeatmapSegment, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return nil, nil, err
}
_, infoPath, err := s.listItemFiles(item.DirPath)
if err != nil || infoPath == "" {
return nil, nil, err
}
data, err := os.ReadFile(infoPath)
if err != nil {
return nil, nil, err
}
var raw struct {
Comments []struct {
ID string `json:"id"`
Parent string `json:"parent"`
Author string `json:"author"`
Channel string `json:"channel"`
Text string `json:"text"`
Timestamp float64 `json:"timestamp"`
TimeText string `json:"time_text"`
LikeCount int `json:"like_count"`
AuthorIsUploader bool `json:"author_is_uploader"`
} `json:"comments"`
Heatmap []struct {
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
Value float64 `json:"value"`
} `json:"heatmap"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, nil, fmt.Errorf("parse engagement info JSON: %w", err)
}
comments := make([]models.Comment, 0, len(raw.Comments))
for _, c := range raw.Comments {
author := c.Author
if author == "" {
author = c.Channel
}
if c.Text == "" && author == "" {
continue
}
comment := models.Comment{
ID: c.ID,
Parent: c.Parent,
Author: author,
Text: c.Text,
Timestamp: int64(c.Timestamp),
TimeText: c.TimeText,
LikeCount: c.LikeCount,
AuthorIsUploader: c.AuthorIsUploader,
}
if comment.TimeText == "" && comment.Timestamp > 0 {
comment.TimeText = time.Unix(comment.Timestamp, 0).UTC().Format("2006-01-02 15:04")
}
comments = append(comments, comment)
}
comments = orderComments(comments)
maxValue := 0.0
for _, h := range raw.Heatmap {
if h.Value > maxValue && !math.IsNaN(h.Value) && !math.IsInf(h.Value, 0) {
maxValue = h.Value
}
}
endTime := 0.0
for _, h := range raw.Heatmap {
if h.EndTime > endTime {
endTime = h.EndTime
}
}
heatmap := make([]models.HeatmapSegment, 0, len(raw.Heatmap))
for _, h := range raw.Heatmap {
if h.EndTime <= h.StartTime || h.StartTime < 0 || h.Value < 0 ||
math.IsNaN(h.StartTime) || math.IsNaN(h.EndTime) || math.IsNaN(h.Value) ||
math.IsInf(h.StartTime, 0) || math.IsInf(h.EndTime, 0) || math.IsInf(h.Value, 0) {
continue
}
width := 0.0
if endTime > 0 {
width = (h.EndTime - h.StartTime) / endTime * 100
}
height := 8.0
if maxValue > 0 {
height += h.Value / maxValue * 92
}
heatmap = append(heatmap, models.HeatmapSegment{
StartTime: h.StartTime,
EndTime: h.EndTime,
Value: h.Value,
Width: width,
Height: height,
})
}
sort.SliceStable(heatmap, func(i, j int) bool {
return heatmap[i].StartTime < heatmap[j].StartTime
})
return comments, heatmap, nil
}
// orderComments groups replies beneath their parent while preserving the
// source order among siblings. Depth is capped for presentation so malformed
// or unusually deep reply chains cannot make the UI progressively narrower.
func orderComments(comments []models.Comment) []models.Comment {
if len(comments) < 2 {
return comments
}
byID := make(map[string]int, len(comments))
for i, comment := range comments {
if comment.ID != "" {
if _, exists := byID[comment.ID]; !exists {
byID[comment.ID] = i
}
}
}
children := make(map[int][]int)
var roots []int
for i, comment := range comments {
parent := strings.TrimSpace(comment.Parent)
if parent == "" || strings.EqualFold(parent, "root") {
roots = append(roots, i)
continue
}
parentIndex, ok := byID[parent]
if !ok || parentIndex == i {
roots = append(roots, i)
continue
}
children[parentIndex] = append(children[parentIndex], i)
}
ordered := make([]models.Comment, 0, len(comments))
visited := make([]bool, len(comments))
stack := make([]struct {
index int
depth int
}, 0, len(comments))
walk := func(index, depth int) {
stack = append(stack, struct {
index int
depth int
}{index, depth})
for len(stack) > 0 {
last := len(stack) - 1
entry := stack[last]
stack = stack[:last]
if visited[entry.index] {
continue
}
visited[entry.index] = true
comment := comments[entry.index]
if parentIndex, ok := byID[strings.TrimSpace(comment.Parent)]; ok && parentIndex != entry.index {
comment.ReplyTo = comments[parentIndex].Author
}
if entry.depth > 4 {
comment.Depth = 4
} else {
comment.Depth = entry.depth
}
ordered = append(ordered, comment)
childrenForComment := children[entry.index]
for i := len(childrenForComment) - 1; i >= 0; i-- {
stack = append(stack, struct {
index int
depth int
}{childrenForComment[i], entry.depth + 1})
}
}
}
for _, root := range roots {
walk(root, 0)
}
// Cycles or references to invalid parents are rendered as top-level comments
// rather than being dropped.
for i := range comments {
if !visited[i] {
walk(i, 0)
}
}
return ordered
}
type subtitleStream struct {
Index int
Lang string
Minternal/service/library_test.go
@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
@@ -212,6 +213,134 @@ func TestFormatBitrate(t *testing.T) {
}
}
func TestGetEngagement(t *testing.T) {
svc, dir := newLibrary(t)
info := `{"comments":[{"author":"Alice","text":"hello <world>","timestamp":1700000000,"like_count":3,"author_is_uploader":true},{"channel":"Bob","text":"second","time_text":"yesterday"}],"heatmap":[{"start_time":0,"end_time":10,"value":0.5},{"start_time":10,"end_time":20,"value":1.0}]}`
writeItem(t, dir, "engagement", `name = "Engagement"`, map[string]string{
"video.mp4": "dummy",
"info.json": info,
})
comments, heatmap, err := svc.GetEngagement("engagement")
if err != nil {
t.Fatalf("GetEngagement: %v", err)
}
if len(comments) != 2 || comments[0].Author != "Alice" || comments[0].LikeCount != 3 || !comments[0].AuthorIsUploader {
t.Fatalf("unexpected comments: %+v", comments)
}
if comments[0].TimeText != "2023-11-14 22:13" {
t.Errorf("timestamp fallback = %q, want UTC time", comments[0].TimeText)
}
if comments[1].Author != "Bob" || comments[1].TimeText != "yesterday" {
t.Fatalf("fallback comment fields not parsed: %+v", comments[1])
}
if len(heatmap) != 2 || heatmap[0].Width != 50 || heatmap[1].Height != 100 {
t.Fatalf("unexpected heatmap: %+v", heatmap)
}
}
func TestGetEngagementFiltersAndSortsHeatmap(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "heatmap-edge", `name = "Heatmap edge"`, map[string]string{
"video.mp4": "dummy",
"info.json": `{"heatmap":[{"start_time":10,"end_time":20,"value":0.5},{"start_time":2,"end_time":4,"value":1},{"start_time":5,"end_time":4,"value":1},{"start_time":20,"end_time":21,"value":-1}]}`,
})
_, heatmap, err := svc.GetEngagement("heatmap-edge")
if err != nil {
t.Fatalf("GetEngagement: %v", err)
}
if len(heatmap) != 2 {
t.Fatalf("valid heatmap segment count = %d, want 2: %+v", len(heatmap), heatmap)
}
if heatmap[0].StartTime != 2 || heatmap[1].StartTime != 10 {
t.Errorf("heatmap order = %v, want ascending start time", heatmap)
}
for _, segment := range heatmap {
if segment.Width <= 0 || segment.Height < 8 || segment.Height > 100 {
t.Errorf("invalid normalized heatmap segment: %+v", segment)
}
}
}
func TestOrderCommentsNestsRepliesAndCapsDepth(t *testing.T) {
comments := []models.Comment{
{ID: "root", Parent: "root", Author: "root"},
{ID: "reply", Parent: "root", Author: "reply"},
{ID: "deep-1", Parent: "reply", Author: "deep-1"},
{ID: "deep-2", Parent: "deep-1", Author: "deep-2"},
{ID: "deep-3", Parent: "deep-2", Author: "deep-3"},
{ID: "deep-4", Parent: "deep-3", Author: "deep-4"},
{ID: "deep-5", Parent: "deep-4", Author: "deep-5"},
}
ordered := orderComments(comments)
if len(ordered) != len(comments) {
t.Fatalf("orderComments dropped comments: %d of %d", len(ordered), len(comments))
}
for i, want := range []string{"root", "reply", "deep-1", "deep-2", "deep-3", "deep-4", "deep-5"} {
if ordered[i].ID != want {
t.Errorf("comment %d = %q, want %q", i, ordered[i].ID, want)
}
}
if ordered[1].ReplyTo != "root" || ordered[len(ordered)-1].ReplyTo != "deep-4" {
t.Errorf("reply targets = %q, %q; want root and deep-4", ordered[1].ReplyTo, ordered[len(ordered)-1].ReplyTo)
}
if ordered[len(ordered)-1].Depth != 4 {
t.Errorf("deep reply depth = %d, want capped depth 4", ordered[len(ordered)-1].Depth)
}
}
func TestOrderCommentsHandlesMissingParentsCyclesAndSiblings(t *testing.T) {
comments := []models.Comment{
{ID: "root", Parent: "root", Author: "root"},
{ID: "second", Parent: " root ", Author: "second"},
{ID: "first", Parent: "root", Author: "first"},
{ID: "orphan", Parent: "missing", Author: "orphan"},
{ID: "cycle-a", Parent: "cycle-b", Author: "a"},
{ID: "cycle-b", Parent: "cycle-a", Author: "b"},
}
ordered := orderComments(comments)
if len(ordered) != len(comments) {
t.Fatalf("orderComments dropped malformed-tree comments: %d of %d", len(ordered), len(comments))
}
var ids []string
for _, comment := range ordered {
ids = append(ids, comment.ID)
}
if !reflect.DeepEqual(ids[:3], []string{"root", "second", "first"}) {
t.Errorf("sibling/root order = %v, want root then source-order siblings", ids)
}
for _, comment := range ordered {
if comment.ID == "orphan" && comment.Depth != 0 {
t.Errorf("orphan depth = %d, want top-level", comment.Depth)
}
}
}
func TestGetEngagementMalformedSidecarReturnsError(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "bad-engagement", `name = "Bad engagement"`, map[string]string{
"video.mp4": "dummy",
"info.json": `{"comments":[`,
})
if _, _, err := svc.GetEngagement("bad-engagement"); err == nil {
t.Fatal("expected malformed engagement JSON to return an error")
}
}
func TestGetEngagementMissingSidecarIsEmpty(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "no-engagement", `name = "No engagement"`, map[string]string{"video.mp4": "dummy"})
comments, heatmap, err := svc.GetEngagement("no-engagement")
if err != nil {
t.Fatalf("GetEngagement: %v", err)
}
if len(comments) != 0 || len(heatmap) != 0 {
t.Fatalf("expected empty engagement, got comments=%v heatmap=%v", comments, heatmap)
}
}
// --- listItemFiles / GetAll ---
func TestListItemFilesClassification(t *testing.T) {
Minternal/service/preset.go
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"strconv"
"strings"
"vidarchive/internal/models"
"vidarchive/internal/repository"
@@ -82,10 +83,28 @@ func (s *PresetService) BuildArgs(p *models.Preset, formatOverride, customFlags
args = append(args, "--embed-metadata")
}
// Info JSON
if p.WriteInfoJSON {
// Info JSON and comments. Comments are stored in the info JSON sidecar, so
// enabling comment collection implicitly enables sidecar writing.
if p.WriteInfoJSON || p.WriteComments {
args = append(args, "--write-info-json")
}
if p.WriteComments {
args = append(args, "--write-comments")
}
var commentArgs []string
commentSort := strings.TrimSpace(p.CommentSort)
if commentSort != "" {
commentArgs = append(commentArgs, "comment_sort="+commentSort)
}
if p.MaxComments > 0 {
commentArgs = append(commentArgs, "max_comments="+strconv.Itoa(p.MaxComments))
}
if len(commentArgs) > 0 {
args = append(args, "--extractor-args", "youtube:"+strings.Join(commentArgs, ","))
}
if strings.TrimSpace(p.CommentExtractorArgs) != "" {
args = append(args, "--extractor-args", strings.TrimSpace(p.CommentExtractorArgs))
}
// Custom flags
if p.CustomFlags != "" {
Minternal/service/preset_test.go
@@ -95,6 +95,38 @@ func TestPresetServiceBuildArgsWithOverride(t *testing.T) {
}
}
func TestPresetServiceBuildArgsWithComments(t *testing.T) {
preset := &models.Preset{
WriteComments: true,
CommentSort: "top",
MaxComments: 25,
CommentExtractorArgs: "youtube:player_client=web",
}
args := (&PresetService{}).BuildArgs(preset, "", "")
want := []string{"--write-info-json", "--write-comments", "--extractor-args", "youtube:comment_sort=top,max_comments=25", "--extractor-args", "youtube:player_client=web"}
for _, value := range want {
found := false
for _, arg := range args {
if arg == value {
found = true
break
}
}
if !found {
t.Errorf("BuildArgs missing %q: %v", value, args)
}
}
}
func TestPresetServiceIgnoresWhitespaceCommentSort(t *testing.T) {
preset := &models.Preset{WriteComments: true, CommentSort: " "}
flags := (&PresetService{}).EffectiveFlags(preset, "", "")
if contains(flags, "comment_sort=") {
t.Fatalf("whitespace-only comment sort emitted an empty argument: %q", flags)
}
}
func TestPresetServiceEffectiveFlags(t *testing.T) {
preset := &models.Preset{
FormatMode: "preset",
Mweb/static/style.css
@@ -19,7 +19,7 @@ body {
flex-direction: column;
}
header {
body > header {
padding: 1rem 2rem;
position: sticky;
top: 0;
@@ -31,7 +31,7 @@ header {
gap: 2rem;
}
header h1 {
body > header h1 {
margin: 0;
flex-shrink: 0;
font-size: 1.5rem;
@@ -535,6 +535,150 @@ button:disabled {
content: "Show less \25b4";
}
/* Engagement metadata */
.engagement-section {
margin-top: 2rem;
}
.engagement-section > summary .badge {
margin-left: 0.35rem;
}
.section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.section-heading h3 {
margin-bottom: 0.5rem;
}
.comments-actions {
margin: 0.75rem 0;
text-align: right;
}
.comment-settings-group:has(input[name="write_info_json"]:not(:checked)) .comment-options {
display: none;
}
.comment-options:has(input[name="write_comments"]:not(:checked)) .comment-settings-fields {
display: none;
}
.comment-options {
margin-top: 1rem;
padding: 0.75rem 1rem 1rem;
}
.comment-options legend {
padding: 0 0.35rem;
font-weight: 600;
}
.comment-settings-fields {
margin: 0.75rem 0 0 1.5rem;
padding-left: 1rem;
border-left: 2px solid var(--pico-muted-border-color);
}
.comment-settings-fields small {
display: block;
margin: -0.5rem 0 0.75rem;
color: var(--pico-muted-color);
}
.heatmap-figure {
margin: 0;
}
.heatmap {
height: 7rem;
display: flex;
align-items: flex-end;
gap: 0;
padding: 0.5rem 0;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.heatmap-bar {
flex: 0 0 auto;
min-width: 0;
background: var(--pico-primary);
opacity: 0.8;
border-radius: 2px 2px 0 0;
}
.heatmap-figure figcaption {
display: flex;
justify-content: space-between;
color: var(--pico-muted-color);
font-size: 0.8rem;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.comment {
margin: 0;
padding: 0.85rem 1rem;
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color);
}
.comment-depth-1 {
margin-left: 1.25rem;
}
.comment-depth-2 {
margin-left: 2.5rem;
}
.comment-depth-3 {
margin-left: 3.75rem;
}
.comment-depth-4 {
margin-left: 5rem;
}
.comment header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment time {
color: var(--pico-muted-color);
font-size: 0.85rem;
margin-left: auto;
}
.comment-reply-to {
display: block;
margin-top: 0.35rem;
color: var(--pico-muted-color);
}
.comment p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0.5rem 0 0.25rem;
}
.comment small,
.muted {
color: var(--pico-muted-color);
}
/* Queue */
.queue-list {
display: flex;
@@ -703,6 +847,11 @@ pre {
margin-top: 1rem;
}
.preset-actions > details {
flex: 1 1 100%;
min-width: 0;
}
.preset-description {
margin-bottom: 0.5rem;
}
@@ -802,6 +951,8 @@ details[open] .cancel-text {
/* Inline edit form */
.edit-form {
width: 100%;
box-sizing: border-box;
padding: 1rem;
}
@@ -1040,7 +1191,7 @@ input[name="cron_expr"] {
flex-direction: column;
}
header {
body > header {
flex-direction: column;
gap: 1rem;
padding: 1rem;
Aweb/templates/comment.html
@@ -0,0 +1,12 @@
{{define "comment_card"}}
<article class="comment comment-depth-{{.Depth}}">
<header>
<strong>{{.Author}}</strong>
{{if .AuthorIsUploader}}<span class="badge">uploader</span>{{end}}
{{if .TimeText}}<time>{{.TimeText}}</time>{{end}}
</header>
{{if .ReplyTo}}<small class="comment-reply-to">replying to <strong>{{.ReplyTo}}</strong></small>{{end}}
<p>{{.Text}}</p>
{{if gt .LikeCount 0}}<small>{{.LikeCount}} likes</small>{{end}}
</article>
{{end}}
Aweb/templates/library_comments.html
@@ -0,0 +1,21 @@
{{define "library_comments"}}
<div class="detail-view comments-page">
<div class="detail-header">
<div>
<a href="/library/item/{{urlEncodePath .Data.Item.RelPath}}">← Back to item</a>
<h2>{{.Data.Item.Name}} comments</h2>
</div>
</div>
{{if .Data.Comments}}
<p class="muted">{{len .Data.Comments}} comments</p>
<div class="comment-list">
{{range .Data.Comments}}
{{template "comment_card" .}}
{{end}}
</div>
{{else}}
<div class="empty-state"><p>No comments are available for this item.</p></div>
{{end}}
</div>
{{end}}
Mweb/templates/library_detail.html
@@ -129,5 +129,33 @@
</dl>
</details>
</div>
{{if .Data.Heatmap}}
<details class="engagement-section">
<summary role="button" class="small secondary">Playback heatmap</summary>
<figure class="heatmap-figure">
<div class="heatmap" role="list" aria-label="Playback activity heatmap">
{{range .Data.Heatmap}}
<div class="heatmap-bar" role="listitem" style="width: {{printf "%.4f" .Width}}%; height: {{printf "%.2f" .Height}}%;" aria-label="{{heatmapRange .}}" title="{{heatmapRange .}}"></div>
{{end}}
</div>
<figcaption><span>Start</span><span>End</span></figcaption>
</figure>
</details>
{{end}}
{{if gt .Data.CommentTotal 0}}
<details class="engagement-section comments-preview">
<summary role="button" class="small secondary">Comments <span class="badge">{{.Data.CommentTotal}}</span></summary>
<div class="comments-actions">
<a href="/library/comments/{{urlEncodePath .Data.Item.RelPath}}">View all comments</a>
</div>
<div class="comment-list">
{{range .Data.Comments}}
{{template "comment_card" .}}
{{end}}
</div>
</details>
{{end}}
</div>
{{end}}
Mweb/templates/settings.html
@@ -244,12 +244,40 @@
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="write_info_json" value="1"
{{if .}}{{if .WriteInfoJSON}}checked{{end}}{{else}}checked{{end}}>
Write info JSON
<div class="comment-settings-group">
<div class="form-group">
<label>
<input type="checkbox" name="write_info_json" value="1"
{{if .}}{{if .WriteInfoJSON}}checked{{end}}{{else}}checked{{end}}>
Write info JSON
</label>
</div>
<fieldset class="comment-options">
<legend>Comments</legend>
<label class="comment-toggle">
<input type="checkbox" name="write_comments" value="1"
{{if .}}{{if .WriteComments}}checked{{end}}{{end}}>
Write comments to info JSON
</label>
<div class="comment-settings-fields">
<label>Comment sort</label>
<input type="text" name="comment_sort" placeholder="e.g., top or newest"
value="{{if .}}{{.CommentSort}}{{end}}">
<small>Extractor-specific value; leave blank for yt-dlp's default.</small>
<label>Maximum comments</label>
<input type="number" name="max_comments" min="0"
value="{{if .}}{{.MaxComments}}{{end}}">
<small>0 leaves the extractor default.</small>
<label>Additional extractor arguments</label>
<input type="text" name="comment_extractor_args"
placeholder="e.g., youtube:player_client=web"
value="{{if .}}{{.CommentExtractorArgs}}{{end}}">
<small>Raw value passed to <code>--extractor-args</code>.</small>
</div>
</fieldset>
</div>
<div class="form-group">