small code cleanups
Minternal/handler/handler.go
@@ -59,10 +59,8 @@ func loadTemplates(presetSvc *service.PresetService) (*template.Template, error)
},
"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
Minternal/handler/health.go
@@ -36,7 +36,7 @@ func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
// yt-dlp reports the database as unreachable and the container is restarted
// for the wrong reason.
status := http.StatusOK
if _, err := h.downloadSvc.CountByStatus(ctx); err != nil {
if err := h.downloadSvc.Ping(ctx); err != nil {
resp.Status = "unhealthy"
resp.Dependencies["database"] = "unreachable"
status = http.StatusServiceUnavailable
Minternal/repository/download.go
@@ -69,27 +69,9 @@ func (r *DownloadRepository) GetQueued(limit int) ([]*models.Download, error) {
)
}
// CountByStatus returns the number of downloads per status, aggregated in SQL so
// callers that only need totals don't load every row's logs. It honours ctx: the
// pool is limited to one connection, so a caller with a deadline (the health
// probe) must be able to give up while a long write holds it.
func (r *DownloadRepository) CountByStatus(ctx context.Context) (map[string]int, error) {
rows, err := r.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM downloads GROUP BY status`)
if err != nil {
return nil, err
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return nil, err
}
counts[status] = n
}
return counts, rows.Err()
func (r *DownloadRepository) Ping(ctx context.Context) error {
var one int
return r.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one)
}
// IDsByStatus returns the ids of downloads in the given status.
Minternal/repository/download_queue_test.go
@@ -83,26 +83,19 @@ func TestGetAllStatusFilter(t *testing.T) {
}
}
// The health endpoint reads these counts, aggregated in SQL so it never loads
// the log column.
func TestCountByStatus(t *testing.T) {
// The health endpoint only needs to know the database answers at all.
func TestPing(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewDownloadRepository(db)
queueDownload(t, repo, "a", "queued", nil)
queueDownload(t, repo, "b", "queued", nil)
queueDownload(t, repo, "c", "error", nil)
counts, err := repo.CountByStatus(context.Background())
if err != nil {
t.Fatalf("count by status: %v", err)
if err := repo.Ping(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
if counts["queued"] != 2 || counts["error"] != 1 {
t.Errorf("counts = %v, want queued=2 error=1", counts)
}
if _, ok := counts["completed"]; ok {
t.Errorf("counts = %v, want no entry for an unused status", counts)
db.Close()
if err := repo.Ping(context.Background()); err == nil {
t.Error("ping on a closed database should fail")
}
}
Minternal/service/download.go
@@ -176,8 +176,8 @@ func (s *DownloadService) DeleteAll() error {
return s.repo.DeleteAll()
}
func (s *DownloadService) CountByStatus(ctx context.Context) (map[string]int, error) {
return s.repo.CountByStatus(ctx)
func (s *DownloadService) Ping(ctx context.Context) error {
return s.repo.Ping(ctx)
}
// ResetStalledDownloads re-queues downloads left mid-flight by a previous run and
@@ -239,7 +239,7 @@ func (s *DownloadService) ExecuteDownload(parent context.Context, d *models.Down
defer cancel()
defer s.registerActive(d.ID, cancel)()
s.cache.Set(d.ID, &LiveDownload{LastUpdate: time.Now()})
s.cache.Set(d.ID, &LiveDownload{})
defer s.cache.Delete(d.ID)
var preset *models.Preset
Minternal/service/library.go
@@ -22,11 +22,11 @@ const subtitlesDirName = "subtitles"
var mediaExts = map[string]struct{}{
".mp4": {}, ".webm": {}, ".mkv": {}, ".avi": {}, ".mov": {},
".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {},
".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {}, ".m4a": {},
}
var audioExts = map[string]struct{}{
".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {},
".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {}, ".m4a": {},
}
var imageExts = map[string]struct{}{
@@ -50,10 +50,9 @@ type LibraryService struct {
// run, so we trust ffmpeg's verdict and don't re-run it on every request.
thumbFailed sync.Map
// extraction counters, for observability/tests.
extractInFlight int32
extractMaxConcurrent int32
extractAttempts int32
// extractAttempts counts ffmpeg extraction runs, so the negative cache can
// be verified to actually prevent a re-run.
extractAttempts int32
// scanCache memoizes scanned items for a short TTL so the listing page (which
// fans out one thumbnail request per media file) and quick auto-refreshes
Minternal/service/library_test.go
@@ -150,6 +150,7 @@ func TestListItemFilesClassification(t *testing.T) {
itemDir := writeItem(t, dir, "item", "", map[string]string{
"video.mp4": "v",
"audio.mp3": "a",
"audio.m4a": "a",
"cover.jpg": "img",
"thumb.webp": "img",
"notes.txt": "ignored",
@@ -161,10 +162,10 @@ func TestListItemFilesClassification(t *testing.T) {
if err != nil {
t.Fatalf("listItemFiles: %v", err)
}
if len(media) != 2 {
t.Fatalf("expected 2 media files, got %d: %+v", len(media), media)
if len(media) != 3 {
t.Fatalf("expected 3 media files, got %d: %+v", len(media), media)
}
var sawAudio, sawVideo bool
var sawAudio, sawVideo, sawM4A bool
for _, m := range media {
switch m.Filename {
case "audio.mp3":
@@ -172,6 +173,13 @@ func TestListItemFilesClassification(t *testing.T) {
if !m.IsAudio {
t.Error("audio.mp3 should be IsAudio")
}
// yt-dlp writes .m4a for extracted audio, so the scanner must list it —
// otherwise an imported item never appears in the library.
case "audio.m4a":
sawM4A = true
if !m.IsAudio {
t.Error("audio.m4a should be IsAudio")
}
case "video.mp4":
sawVideo = true
if m.IsAudio {
@@ -181,7 +189,7 @@ func TestListItemFilesClassification(t *testing.T) {
t.Errorf("unexpected media file %q", m.Filename)
}
}
if !sawAudio || !sawVideo {
if !sawAudio || !sawVideo || !sawM4A {
t.Error("missing expected media files")
}
if infoPath == "" {
Minternal/service/media_probe.go
@@ -40,6 +40,28 @@ func (s *LibraryService) GetMetadata(relPath, filename string) (*MediaMetadata,
return s.probeMedia(target.Filepath)
}
// probeStreams runs ffprobe once and returns the container format and every
// stream in it. It is the single ffprobe entry point: callers that only care
// about one stream kind filter the result themselves, rather than each
// re-declaring the same command and JSON shape.
func (s *LibraryService) probeStreams(path string) (ffprobeOutput, error) {
var probe ffprobeOutput
output, err := exec.Command(s.ffprobePath,
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
path,
).Output()
if err != nil {
return probe, err
}
if err := json.Unmarshal(output, &probe); err != nil {
return probe, err
}
return probe, nil
}
func (s *LibraryService) probeMedia(path string) (*MediaMetadata, error) {
info, err := os.Stat(path)
if err != nil {
@@ -50,23 +72,13 @@ func (s *LibraryService) probeMedia(path string) (*MediaMetadata, error) {
FileSize: info.Size(),
}
cmd := exec.Command(s.ffprobePath,
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
path,
)
output, err := cmd.Output()
// A file ffprobe can't read still has a size worth showing, so a probe
// failure degrades to the size-only metadata rather than erroring.
probe, err := s.probeStreams(path)
if err != nil {
return meta, nil
}
var probe ffprobeOutput
if err := json.Unmarshal(output, &probe); err != nil {
return meta, nil
}
if probe.Format.FormatName != "" {
parts := strings.Split(probe.Format.FormatName, ",")
meta.Container = parts[0]
@@ -173,20 +185,17 @@ type SubtitleStream struct {
type ffprobeOutput struct {
Format struct {
FormatName string `json:"format_name"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []ffprobeStream `json:"streams"`
}
type ffprobeStream struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Profile string `json:"profile"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
@@ -196,5 +205,7 @@ type ffprobeStream struct {
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
Mimetype string `json:"mimetype"`
Filename string `json:"filename"`
} `json:"tags"`
}
Minternal/service/progress_cache.go
@@ -3,13 +3,11 @@ package service
import (
"strings"
"sync"
"time"
)
type LiveDownload struct {
Logs strings.Builder
LastUpdate time.Time
flushed int // length of Logs already persisted to the DB
Logs strings.Builder
flushed int // length of Logs already persisted to the DB
}
type ProgressCache struct {
@@ -35,7 +33,6 @@ func (c *ProgressCache) AppendLog(id int64, line string) {
if d, ok := c.data[id]; ok {
d.Logs.WriteString(line)
d.Logs.WriteByte('\n')
d.LastUpdate = time.Now()
}
}
Minternal/service/subtitles.go
@@ -1,7 +1,6 @@
package service
import (
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -68,10 +67,8 @@ func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, e
}
var tracks []models.SubtitleTrack
for _, stream := range streams {
// extractSubtitleInfo always fills Lang (falling back to "trackN").
lang := stream.Lang
if lang == "" {
lang = fmt.Sprintf("track%d", stream.Index)
}
outPath := filepath.Join(cacheDir, lang+".vtt")
if err := s.extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil {
continue
@@ -95,52 +92,47 @@ type subtitleStream struct {
}
func (s *LibraryService) extractSubtitleInfo(path string) ([]subtitleStream, error) {
cmd := exec.Command(s.ffprobePath,
"-v", "error",
"-show_streams",
"-select_streams", "s",
"-of", "json",
path,
)
output, err := cmd.Output()
probe, err := s.probeStreams(path)
if err != nil {
return nil, err
}
var probe struct {
Streams []struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return nil, err
}
return selectSubtitleStreams(probe.Streams), nil
}
func selectSubtitleStreams(all []ffprobeStream) []subtitleStream {
var streams []subtitleStream
// Index is an ffmpeg "0:s:N" selector, so it must count every subtitle
// stream. Counting only the convertible ones would shift the selector past a
// bitmap track (PGS, DVD subs) and extract the wrong stream.
subIndex := 0
for _, stream := range probe.Streams {
for _, stream := range all {
if stream.CodecType != "subtitle" {
continue
}
index := subIndex
subIndex++
switch stream.CodecName {
case "subrip", "ass", "ssa", "webvtt", "mov_text":
lang := stream.Tags.Language
if lang == "" {
lang = fmt.Sprintf("track%d", subIndex)
}
label := stream.Tags.Title
if label == "" {
label = strings.ToUpper(lang)
}
streams = append(streams, subtitleStream{
Index: subIndex,
Lang: lang,
Label: label,
})
subIndex++
default:
// Bitmap subtitles need OCR to become WebVTT. Skip them.
continue
}
lang := stream.Tags.Language
if lang == "" {
lang = fmt.Sprintf("track%d", index)
}
label := stream.Tags.Title
if label == "" {
label = strings.ToUpper(lang)
}
streams = append(streams, subtitleStream{
Index: index,
Lang: lang,
Label: label,
})
}
return streams, nil
return streams
}
func (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
Ainternal/service/subtitles_test.go
@@ -0,0 +1,28 @@
package service
import "testing"
// Index is an ffmpeg "0:s:N" selector. A bitmap track is unusable but still
// occupies a slot, so skipping it must not shift the following tracks.
func TestSelectSubtitleStreamsCountsSkippedTracks(t *testing.T) {
streams := []ffprobeStream{
{CodecType: "video", CodecName: "h264"},
{CodecType: "audio", CodecName: "aac"},
{CodecType: "subtitle", CodecName: "hdmv_pgs_subtitle"},
{CodecType: "subtitle", CodecName: "subrip"},
{CodecType: "subtitle", CodecName: "ass"},
}
streams[3].Tags.Language = "eng"
got := selectSubtitleStreams(streams)
if len(got) != 2 {
t.Fatalf("got %d streams, want 2: %+v", len(got), got)
}
if got[0].Index != 1 || got[0].Lang != "eng" || got[0].Label != "ENG" {
t.Errorf("subrip track = %+v, want index 1 lang eng label ENG", got[0])
}
// No language tag, so the name falls back to the selector index.
if got[1].Index != 2 || got[1].Lang != "track2" {
t.Errorf("ass track = %+v, want index 2 lang track2", got[1])
}
}
Minternal/service/thumbnail.go
@@ -1,7 +1,6 @@
package service
import (
"encoding/json"
"fmt"
"log"
"os"
@@ -69,17 +68,7 @@ func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) (string, bo
}
s.thumbSem <- struct{}{}
cur := atomic.AddInt32(&s.extractInFlight, 1)
for {
max := atomic.LoadInt32(&s.extractMaxConcurrent)
if cur <= max || atomic.CompareAndSwapInt32(&s.extractMaxConcurrent, max, cur) {
break
}
}
defer func() {
atomic.AddInt32(&s.extractInFlight, -1)
<-s.thumbSem
}()
defer func() { <-s.thumbSem }()
atomic.AddInt32(&s.extractAttempts, 1)
path, err := s.extractThumbnail(mf)
@@ -108,23 +97,10 @@ func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
// covers are attachment streams, not attached_pic video streams, so they must be
// dumped with -dump_attachment rather than mapped like a normal stream.
func (s *LibraryService) findImageAttachment(path string) int {
cmd := exec.Command(s.ffprobePath, "-v", "error", "-show_streams", "-of", "json", path)
output, err := cmd.Output()
probe, err := s.probeStreams(path)
if err != nil {
return -1
}
var probe struct {
Streams []struct {
CodecType string `json:"codec_type"`
Tags struct {
Mimetype string `json:"mimetype"`
Filename string `json:"filename"`
} `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return -1
}
best, bestScore, attachmentIdx := -1, 0, 0
for _, stream := range probe.Streams {
Minternal/service/thumbnail_test.go
@@ -183,35 +183,6 @@ func TestThumbnailForFileIsPerFile(t *testing.T) {
}
}
func TestThumbnailConcurrencyBounded(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "many", "name = \"M\"\nduration = -1\n", nil)
const n = 8
for i := 0; i < n; i++ {
makeTestVideo(t, filepath.Join(itemDir, fmt.Sprintf("c%d.mp4", i)))
}
var wg sync.WaitGroup
for i := 0; i < n; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
svc.ThumbnailForFile("many", fmt.Sprintf("c%d.mp4", i))
}()
}
wg.Wait()
max := atomic.LoadInt32(&svc.extractMaxConcurrent)
if max > maxConcurrentThumbnails {
t.Errorf("peak concurrent extractions %d exceeded cap %d", max, maxConcurrentThumbnails)
}
if max < 1 {
t.Error("expected at least one extraction to run")
}
}
func TestThumbnailUsesEmbeddedAttachment(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
Mweb/static/style.css
@@ -271,8 +271,7 @@ button:disabled {
.folder-item:hover .folder-name,
.media-item:hover .media-info,
.media-item:hover .media-title,
.media-item:hover .media-path {
.media-item:hover .media-title {
color: var(--pico-primary-inverse);
}
@@ -408,10 +407,6 @@ button:disabled {
white-space: nowrap;
}
.media-path {
font-size: 0.85rem;
}
.media-info .media-duration {
display: none;
}
@@ -544,18 +539,6 @@ button:disabled {
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;