diff --git a/Dockerfile b/Dockerfile index bda0ca7..08f8914 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ FROM alpine:3.19 -RUN apk add --no-cache tzdata ca-certificates rsync +RUN apk add --no-cache tzdata ca-certificates rsync ffmpeg WORKDIR /app COPY --from=builder /out/jukebox . diff --git a/cmd/jukebox/main.go b/cmd/jukebox/main.go index 701ba52..2ac5ddb 100644 --- a/cmd/jukebox/main.go +++ b/cmd/jukebox/main.go @@ -126,7 +126,7 @@ func main() { switch ev.Info.State { case disk.DiskKnown: openDiskDB(ev.Info) - if watcherReady && ev.Prev.State != disk.DiskKnown && cfg.AutoCopy { + if watcherReady && ev.Prev.State != disk.DiskKnown { triggerAutoCopy(cp, cfg, ev.Info) } case disk.DiskForeign: @@ -197,6 +197,15 @@ func main() { } func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) { + // Используем AutoCopy из профиля диска, если он есть; иначе — из глобального config + autoCopy := cfg.AutoCopy + if info.Profile != nil { + autoCopy = info.Profile.AutoCopy + } + if !autoCopy { + return + } + hasEnabledSources := false for _, s := range cfg.Sources { if s.Enabled { @@ -207,18 +216,31 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) if !hasEnabledSources { return } + + opts := copier.Options{ + DiskID: info.DiskID, + MountPath: info.MountPath, + MediaPath: cfg.MediaPath, + SourceRules: cfg.Sources, + AllowedExtensions: cfg.EffectiveAllowedExtensions(), + OverwriteMode: cfg.OverwriteMode, + } + if p := info.Profile; p != nil { + opts.DestFolder = p.DestFolder + opts.ReserveFreeGB = p.ReserveFreeGB + opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode) + opts.Transcode = p.Transcode + if p.OverwriteMode != "" { + opts.OverwriteMode = config.OverwriteMode(p.OverwriteMode) + } + } else { + opts.DestFolder = cfg.DestFolder + opts.ReserveFreeGB = cfg.ReserveFreeGB + opts.FileSelectMode = cfg.FileSelectMode + } + go func() { - _, err := cp.Start(context.Background(), copier.Options{ - DiskID: info.DiskID, - MountPath: info.MountPath, - MediaPath: cfg.MediaPath, - DestFolder: cfg.DestFolder, - SourceRules: cfg.Sources, - AllowedExtensions: cfg.EffectiveAllowedExtensions(), - ReserveFreeGB: cfg.ReserveFreeGB, - OverwriteMode: cfg.OverwriteMode, - FileSelectMode: cfg.FileSelectMode, - }) + _, err := cp.Start(context.Background(), opts) if err != nil { log.Printf("auto-copy: %v", err) } diff --git a/internal/api/handlers_copy.go b/internal/api/handlers_copy.go index 3e72791..c52db4b 100644 --- a/internal/api/handlers_copy.go +++ b/internal/api/handlers_copy.go @@ -13,17 +13,25 @@ import ( ) func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options { - return copier.Options{ + opts := copier.Options{ DiskID: diskInfo.DiskID, MountPath: diskInfo.MountPath, MediaPath: cfg.MediaPath, - DestFolder: cfg.DestFolder, SourceRules: cfg.Sources, AllowedExtensions: cfg.EffectiveAllowedExtensions(), - ReserveFreeGB: cfg.ReserveFreeGB, OverwriteMode: overwriteMode, - FileSelectMode: cfg.FileSelectMode, } + if p := diskInfo.Profile; p != nil { + opts.DestFolder = p.DestFolder + opts.ReserveFreeGB = p.ReserveFreeGB + opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode) + opts.Transcode = p.Transcode + } else { + opts.DestFolder = cfg.DestFolder + opts.ReserveFreeGB = cfg.ReserveFreeGB + opts.FileSelectMode = cfg.FileSelectMode + } + return opts } func hasEnabledSources(cfg *config.Config) bool { @@ -78,8 +86,11 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) { return } - reserveBytes := int64(cfg.ReserveFreeGB * 1e9) - if diskInfo.FreeBytes <= reserveBytes { + reserveGB := cfg.ReserveFreeGB + if diskInfo.Profile != nil { + reserveGB = diskInfo.Profile.ReserveFreeGB + } + if diskInfo.FreeBytes <= int64(reserveGB*1e9) { jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold") return } @@ -137,8 +148,11 @@ func (s *Server) handleCopyStartSelected(w http.ResponseWriter, r *http.Request) return } - reserveBytes := int64(cfg.ReserveFreeGB * 1e9) - if diskInfo.FreeBytes <= reserveBytes { + reserveGB := cfg.ReserveFreeGB + if diskInfo.Profile != nil { + reserveGB = diskInfo.Profile.ReserveFreeGB + } + if diskInfo.FreeBytes <= int64(reserveGB*1e9) { jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold") return } diff --git a/internal/api/handlers_disk.go b/internal/api/handlers_disk.go index f75a111..c14fbc7 100644 --- a/internal/api/handlers_disk.go +++ b/internal/api/handlers_disk.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "jukebox_maker/internal/config" "jukebox_maker/internal/disk" ) @@ -15,6 +16,7 @@ func (s *Server) diskResponse(info disk.DiskInfo) map[string]any { "total_bytes": info.TotalBytes, "free_bytes": info.FreeBytes, "mount_path": info.MountPath, + "profile": info.Profile, } if info.DiskID != "" { if s.deps.OnDiskInit != nil { @@ -113,3 +115,39 @@ func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) { } jsonOK(w, map[string]string{"disk_id": diskID}) } + +func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { + mountPath := config.NormalizeMediaPath(r.URL.Query().Get("mount_path")) + if mountPath == "" { + jsonErr(w, http.StatusBadRequest, "mount_path is required") + return + } + p, err := disk.LoadProfile(mountPath) + if err != nil { + jsonErr(w, http.StatusNotFound, "profile not found") + return + } + jsonOK(w, p) +} + +func (s *Server) handlePutProfile(w http.ResponseWriter, r *http.Request) { + mountPath := config.NormalizeMediaPath(r.URL.Query().Get("mount_path")) + if mountPath == "" { + jsonErr(w, http.StatusBadRequest, "mount_path is required") + return + } + var p disk.DiskProfile + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if err := disk.SaveProfile(mountPath, &p); err != nil { + jsonErr(w, http.StatusInternalServerError, "save profile: "+err.Error()) + return + } + // Обновляем информацию о диске в watcher если он там есть + if s.deps.Watcher != nil { + s.deps.Watcher.ProbeNow() + } + jsonOK(w, &p) +} diff --git a/internal/api/server.go b/internal/api/server.go index 2f3010e..3a1a59f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -70,6 +70,8 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/disks/{diskID}/copy/start", s.handleCopyStart) s.mux.HandleFunc("POST /api/disks/{diskID}/copy/cancel", s.handleCopyCancel) s.mux.HandleFunc("GET /api/tasks/{id}", s.handleTaskGet) + s.mux.HandleFunc("GET /api/disks/profile", s.handleGetProfile) + s.mux.HandleFunc("PUT /api/disks/profile", s.handlePutProfile) } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { diff --git a/internal/copier/copier.go b/internal/copier/copier.go index ee2c180..bff5e3c 100644 --- a/internal/copier/copier.go +++ b/internal/copier/copier.go @@ -19,6 +19,7 @@ import ( "jukebox_maker/internal/db" "jukebox_maker/internal/disk" "jukebox_maker/internal/task" + "jukebox_maker/internal/transcoder" ) type Options struct { @@ -31,6 +32,7 @@ type Options struct { ReserveFreeGB float64 OverwriteMode config.OverwriteMode FileSelectMode config.FileSelectMode + Transcode *disk.TranscodeProfile // nil = не транскодировать } type Copier struct { @@ -324,8 +326,14 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database } dstAbs := filepath.Join(destRoot, f.relPath) - if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil { - if errors.Is(err, context.Canceled) { + var fileErr error + if opts.Transcode != nil && isVideoFile(f.srcAbs) { + fileErr = c.processVideo(ctx, taskID, database, opts.Transcode, f.srcAbs, dstAbs) + } else { + fileErr = copyFile(ctx, f.srcAbs, dstAbs) + } + if fileErr != nil { + if errors.Is(fileErr, context.Canceled) { c.tasks.Update(taskID, func(t *task.Task) { t.Status = task.StatusCanceled t.Message = "Canceled" @@ -353,6 +361,59 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database setStatus(task.StatusSuccess, fmt.Sprintf("Done. Copied %d files.", copied), 100) } +// videoExtensions — расширения видеофайлов из встроенного справочника. +var videoExtensions = func() map[string]struct{} { + exts := config.BuiltInMediaTypeExtensions()[config.MediaTypeVideo] + set := make(map[string]struct{}, len(exts)) + for _, e := range exts { + set[e] = struct{}{} + } + return set +}() + +func isVideoFile(path string) bool { + _, ok := videoExtensions[strings.ToLower(filepath.Ext(path))] + return ok +} + +// processVideo определяет: транскодировать или скопировать файл. +func (c *Copier) processVideo(ctx context.Context, taskID string, database *db.DB, profile *disk.TranscodeProfile, src, dst string) error { + info, err := transcoder.ProbeVideo(src) + if err != nil { + // Не смогли зондировать — просто копируем + return copyFile(ctx, src, dst) + } + + if !transcoder.NeedsTranscode(info, profile) { + return copyFile(ctx, src, dst) + } + + // Меняем расширение выходного файла под формат контейнера + ext := transcoder.OutputExt(profile.OutputFormat) + dstTranscoded := strings.TrimSuffix(dst, filepath.Ext(dst)) + ext + + c.tasks.Update(taskID, func(t *task.Task) { + t.Phase = task.PhaseTranscoding + t.Message = "Transcoding " + filepath.Base(src) + }) + if t, ok := c.tasks.Get(taskID); ok { + _ = database.UpdateTask(*t) + } + + progressFn := func(pct float64) { + c.tasks.Update(taskID, func(t *task.Task) { + t.Progress = int(pct * 100) + }) + } + + return transcoder.Transcode(ctx, transcoder.Options{ + Input: src, + Output: dstTranscoded, + Profile: profile, + SourceInfo: info, + }, progressFn) +} + type fileEntry struct { srcAbs string relPath string // relative to /media diff --git a/internal/disk/disk.go b/internal/disk/disk.go index b433e04..9d4e12e 100644 --- a/internal/disk/disk.go +++ b/internal/disk/disk.go @@ -18,11 +18,12 @@ const ( ) type DiskInfo struct { - State DiskState `json:"state"` - DiskID string `json:"disk_id"` - TotalBytes int64 `json:"total_bytes"` - FreeBytes int64 `json:"free_bytes"` - MountPath string `json:"mount_path"` + State DiskState `json:"state"` + DiskID string `json:"disk_id"` + TotalBytes int64 `json:"total_bytes"` + FreeBytes int64 `json:"free_bytes"` + MountPath string `json:"mount_path"` + Profile *DiskProfile `json:"profile,omitempty"` } const MarkerDir = ".jukebox" @@ -55,6 +56,9 @@ func Probe(mountPath string) (DiskInfo, error) { info.DiskID = strings.TrimSpace(string(data)) info.State = DiskKnown + if p, err := LoadProfile(mountPath); err == nil { + info.Profile = p + } return info, nil } @@ -81,6 +85,7 @@ func InitDisk(mountPath string) (string, error) { if err := os.WriteFile(idPath, []byte(id), 0o644); err != nil { return "", err } + _ = SaveProfile(mountPath, DefaultProfile()) return id, nil } diff --git a/internal/disk/profile.go b/internal/disk/profile.go new file mode 100644 index 0000000..4b0f8bc --- /dev/null +++ b/internal/disk/profile.go @@ -0,0 +1,70 @@ +package disk + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type DiskProfile struct { + DestFolder string `json:"dest_folder"` + OverwriteMode string `json:"overwrite_mode"` + FileSelectMode string `json:"file_select_mode"` + ReserveFreeGB float64 `json:"reserve_free_gb"` + AutoCopy bool `json:"auto_copy"` + + // nil = не транскодировать видео + Transcode *TranscodeProfile `json:"transcode,omitempty"` +} + +type TranscodeProfile struct { + VideoCodec string `json:"video_codec"` // "h264" | "h265" | "mpeg4" + MaxResolution string `json:"max_resolution"` // "480p" | "720p" | "1080p" + MaxVideoBitrate string `json:"max_video_bitrate"` // "1000k" | "2000k" | "" (без лимита) + MaxFPS int `json:"max_fps"` // 0=без лимита | 24 | 25 | 30 + AudioCodec string `json:"audio_codec"` // "aac" | "mp3" + MaxAudioBitrate string `json:"max_audio_bitrate"` // "128k" | "192k" | "" + MaxAudioChannels int `json:"max_audio_channels"` // 2=стерео | 6=5.1 + OutputFormat string `json:"output_format"` // "mp4" | "mkv" | "avi" +} + +const profileFile = "profile.json" + +func profilePath(mountPath string) string { + return filepath.Join(mountPath, MarkerDir, profileFile) +} + +func LoadProfile(mountPath string) (*DiskProfile, error) { + data, err := os.ReadFile(profilePath(mountPath)) + if err != nil { + return nil, err + } + var p DiskProfile + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil +} + +func SaveProfile(mountPath string, p *DiskProfile) error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + path := profilePath(mountPath) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func DefaultProfile() *DiskProfile { + return &DiskProfile{ + DestFolder: "media", + OverwriteMode: "skip", + FileSelectMode: "new", + ReserveFreeGB: 2.0, + AutoCopy: false, + } +} diff --git a/internal/task/task.go b/internal/task/task.go index 9089e87..afb326a 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -23,6 +23,7 @@ const ( PhaseReplacing = "replacing" PhaseLoadingHistory = "loading_history" PhaseScanning = "scanning" + PhaseTranscoding = "transcoding" PhaseCopying = "copying" ) diff --git a/internal/transcoder/detect.go b/internal/transcoder/detect.go new file mode 100644 index 0000000..4264534 --- /dev/null +++ b/internal/transcoder/detect.go @@ -0,0 +1,172 @@ +package transcoder + +import ( + "encoding/json" + "os/exec" + "strconv" + "strings" + + "jukebox_maker/internal/disk" +) + +type VideoInfo struct { + Codec string + Width int + Height int + FPS float64 + DurationSec float64 + VideoBitrate int64 + AudioCodec string + AudioChannels int +} + +func ProbeVideo(path string) (VideoInfo, error) { + out, err := exec.Command("ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_streams", + "-show_format", + path, + ).Output() + if err != nil { + return VideoInfo{}, err + } + + var raw struct { + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Width int `json:"width"` + Height int `json:"height"` + RFrameRate string `json:"r_frame_rate"` + BitRate string `json:"bit_rate"` + Channels int `json:"channels"` + } `json:"streams"` + Format struct { + Duration string `json:"duration"` + BitRate string `json:"bit_rate"` + } `json:"format"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return VideoInfo{}, err + } + + var info VideoInfo + for _, s := range raw.Streams { + switch s.CodecType { + case "video": + info.Codec = s.CodecName + info.Width = s.Width + info.Height = s.Height + info.FPS = parseFraction(s.RFrameRate) + if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil { + info.VideoBitrate = br + } + case "audio": + if info.AudioCodec == "" { + info.AudioCodec = s.CodecName + info.AudioChannels = s.Channels + } + } + } + if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil { + info.DurationSec = d + } + // Если стрим-битрейт не известен — используем битрейт контейнера + if info.VideoBitrate == 0 { + if br, err := strconv.ParseInt(raw.Format.BitRate, 10, 64); err == nil { + info.VideoBitrate = br + } + } + return info, nil +} + +func NeedsTranscode(info VideoInfo, p *disk.TranscodeProfile) bool { + // Несовместимый видеокодек + if normalizeCodec(info.Codec) != p.VideoCodec { + return true + } + + // Разрешение превышает лимит + if maxH := maxHeight(p.MaxResolution); maxH > 0 && info.Height > maxH { + return true + } + + // Битрейт видео превышает лимит + if p.MaxVideoBitrate != "" { + if maxBR := parseBitrate(p.MaxVideoBitrate); maxBR > 0 && info.VideoBitrate > maxBR { + return true + } + } + + // FPS превышает лимит + if p.MaxFPS > 0 && info.FPS > float64(p.MaxFPS)+0.01 { + return true + } + + // Несовместимый аудиокодек + if p.AudioCodec != "" && normalizeCodec(info.AudioCodec) != p.AudioCodec { + return true + } + + // Каналов больше лимита + if p.MaxAudioChannels > 0 && info.AudioChannels > p.MaxAudioChannels { + return true + } + + return false +} + +func normalizeCodec(codec string) string { + switch strings.ToLower(codec) { + case "hevc", "h265": + return "h265" + case "h264", "avc", "avc1": + return "h264" + case "mpeg4", "mp4v": + return "mpeg4" + case "aac": + return "aac" + case "mp3": + return "mp3" + default: + return strings.ToLower(codec) + } +} + +func maxHeight(res string) int { + switch res { + case "480p": + return 480 + case "720p": + return 720 + case "1080p": + return 1080 + default: + return 0 + } +} + +func parseBitrate(s string) int64 { + s = strings.ToLower(strings.TrimSpace(s)) + if strings.HasSuffix(s, "k") { + v, _ := strconv.ParseInt(s[:len(s)-1], 10, 64) + return v * 1000 + } + v, _ := strconv.ParseInt(s, 10, 64) + return v +} + +func parseFraction(s string) float64 { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 { + v, _ := strconv.ParseFloat(s, 64) + return v + } + num, _ := strconv.ParseFloat(parts[0], 64) + den, _ := strconv.ParseFloat(parts[1], 64) + if den == 0 { + return 0 + } + return num / den +} diff --git a/internal/transcoder/transcoder.go b/internal/transcoder/transcoder.go new file mode 100644 index 0000000..d623b3a --- /dev/null +++ b/internal/transcoder/transcoder.go @@ -0,0 +1,167 @@ +package transcoder + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "jukebox_maker/internal/disk" +) + +type Options struct { + Input string + Output string + Profile *disk.TranscodeProfile + SourceInfo VideoInfo +} + +// Transcode запускает ffmpeg. progress вызывается с 0..1 по мере работы. +func Transcode(ctx context.Context, opts Options, progress func(float64)) error { + if err := os.MkdirAll(filepath.Dir(opts.Output), 0o755); err != nil { + return err + } + + args := buildArgs(opts) + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("ffmpeg start: %w", err) + } + + // Парсим прогресс из stdout (-progress pipe:1) + if opts.SourceInfo.DurationSec > 0 && progress != nil { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "out_time_us=") { + val := strings.TrimPrefix(line, "out_time_us=") + if us, err := strconv.ParseInt(val, 10, 64); err == nil && us > 0 { + sec := float64(us) / 1e6 + pct := sec / opts.SourceInfo.DurationSec + if pct > 1 { + pct = 1 + } + progress(pct) + } + } + } + } + + if err := cmd.Wait(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("ffmpeg: %w: %s", err, msg) + } + return fmt.Errorf("ffmpeg: %w", err) + } + return nil +} + +func buildArgs(opts Options) []string { + p := opts.Profile + src := opts.SourceInfo + + args := []string{ + "-i", opts.Input, + "-threads", "0", + "-progress", "pipe:1", + "-v", "error", + } + + // Видеокодек + args = append(args, "-c:v", ffmpegVideoCodec(p.VideoCodec)) + + // Битрейт — только если источник выше лимита + if p.MaxVideoBitrate != "" { + if targetBR := parseBitrate(p.MaxVideoBitrate); targetBR > 0 && + (src.VideoBitrate == 0 || src.VideoBitrate > targetBR) { + args = append(args, "-b:v", p.MaxVideoBitrate) + } + } + + // Масштаб + FPS — строим vf + var filters []string + if maxH := maxHeight(p.MaxResolution); maxH > 0 { + filters = append(filters, fmt.Sprintf("scale=-2:min(%d\\,ih)", maxH)) + } + if p.MaxFPS > 0 && src.FPS > float64(p.MaxFPS)+0.01 { + filters = append(filters, fmt.Sprintf("fps=%d", p.MaxFPS)) + } + if len(filters) > 0 { + args = append(args, "-vf", strings.Join(filters, ",")) + } + + // Аудиокодек + if p.AudioCodec != "" { + args = append(args, "-c:a", ffmpegAudioCodec(p.AudioCodec)) + } else { + args = append(args, "-c:a", "copy") + } + + // Аудио-битрейт + if p.MaxAudioBitrate != "" { + args = append(args, "-b:a", p.MaxAudioBitrate) + } + + // Каналы — только даунмикс + if p.MaxAudioChannels > 0 && src.AudioChannels > p.MaxAudioChannels { + args = append(args, "-ac", strconv.Itoa(p.MaxAudioChannels)) + } + + // Для mp4 — оптимизация для стриминга + if p.OutputFormat == "mp4" { + args = append(args, "-movflags", "+faststart") + } + + args = append(args, "-y", opts.Output) + return args +} + +func ffmpegVideoCodec(codec string) string { + switch codec { + case "h264": + return "libx264" + case "h265": + return "libx265" + case "mpeg4": + return "mpeg4" + default: + return "libx264" + } +} + +func ffmpegAudioCodec(codec string) string { + switch codec { + case "aac": + return "aac" + case "mp3": + return "libmp3lame" + default: + return "aac" + } +} + +// OutputExt возвращает расширение файла для заданного формата контейнера. +func OutputExt(format string) string { + switch format { + case "mkv": + return ".mkv" + case "avi": + return ".avi" + default: + return ".mp4" + } +} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 30e3d6d..15fca0a 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -137,9 +137,147 @@ function renderDisk() { ` : ''} + ${isKnown ? renderProfile(disk) : ''} `; } +function renderProfile(disk) { + const p = disk.profile || {}; + const t = p.transcode || null; + const transcodeEnabled = !!t; + + const sel = (name, value, options) => { + const opts = options.map(([v, label]) => + `` + ).join(''); + return ``; + }; + + const transcodeSection = ` +
+ `; + + return ` +