fix breadrcumbs, add duration parsing, better detail information
Minternal/handler/handler.go
@@ -5,6 +5,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
@@ -55,6 +56,15 @@ func loadTemplates() (*template.Template, error) {
"ext": filepath.Ext,
"mimeType": mimeTypeFromFile,
"presetFlags": func(p *models.Preset) string { return presetSvcGlobal.EffectiveFlags(p, "", "") },
"urlEncode": template.URLQueryEscaper,
"urlEncodePath": func(p string) string {
parts := strings.Split(strings.Trim(p, "/"), "/")
for i, part := range parts {
parts[i] = url.QueryEscape(part)
}
return strings.Join(parts, "/")
},
"sub": func(a, b int) int { return a - b },
})
return tmpl.ParseGlob("web/templates/*.html")
Minternal/repository/media.go
@@ -42,6 +42,16 @@ func (r *MediaRepository) GetAll(path string, sortBy, filter string) ([]*models.
FROM media WHERE relative_path LIKE ?`
args := []interface{}{path + "%"}
// For non-root paths, restrict to immediate items (not nested inside subfolders).
// For root path, only show media not inside any folder.
if path != "" {
query += ` AND relative_path NOT LIKE ?`
args = append(args, path+"%/%")
} else {
query += ` AND relative_path NOT LIKE ?`
args = append(args, "%/%")
}
if filter != "" {
query += ` AND (title LIKE ? OR description LIKE ?)`
args = append(args, "%"+filter+"%", "%"+filter+"%")
@@ -110,7 +120,7 @@ func (r *MediaRepository) GetFolders(parentPath string) ([]string, error) {
if idx := strings.Index(remaining, "/"); idx >= 0 {
folder := remaining[:idx]
if folder != "" {
folders[parentPath+"/"+folder] = true
folders[folder] = true
}
}
}
Minternal/service/download.go
@@ -334,6 +334,7 @@ func (s *DownloadService) scanForNewMedia(url string, downloadDir string) {
HasEmbeddedThumbnail: true,
InfoJSONPath: infoJSONPath,
Title: strings.TrimSuffix(filepath.Base(path), ext),
Duration: extractDuration(infoJSONPath, path),
}
s.mediaRepo.Create(media)
Minternal/service/media.go
@@ -3,6 +3,7 @@ package service
import (
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
@@ -92,11 +93,40 @@ func (s *MediaService) ScanLibrary() error {
// Try to extract title from filename
media.Title = strings.TrimSuffix(filepath.Base(path), ext)
media.Duration = extractDuration(infoJSONPath, path)
return s.repo.Create(media)
})
}
func extractDuration(infoJSONPath, mediaPath string) int {
if infoJSONPath != "" {
if data, err := os.ReadFile(infoJSONPath); err == nil {
var info struct {
Duration float64 `json:"duration"`
}
if err := json.Unmarshal(data, &info); err == nil && info.Duration > 0 {
return int(info.Duration + 0.5)
}
}
}
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
)
output, err := cmd.Output()
if err != nil {
return 0
}
if sec, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64); err == nil && sec > 0 {
return int(sec + 0.5)
}
return 0
}
func (s *MediaService) Delete(id int64) error {
media, err := s.repo.GetByID(id)
if err != nil {
@@ -306,12 +336,59 @@ func (s *MediaService) HasThumbnail(id int64) bool {
}
type MediaMetadata struct {
Container string
VideoCodec string
AudioCodec string
Resolution string
Container string
Resolution string
FileSize int64
VideoStreams []VideoStream
AudioStreams []AudioStream
SubtitleStreams []SubtitleStream
}
type VideoStream struct {
Codec string
Profile string
Width int
Height int
FPS string
PixelFormat string
Bitrate string
FileSize int64
}
type AudioStream struct {
Codec string
SampleRate string
Channels int
ChannelLayout string
SampleFormat string
Bitrate string
Language string
}
type SubtitleStream struct {
Language string
Title string
Codec string
}
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"`
ChannelLayout string `json:"channel_layout"`
SampleFmt string `json:"sample_fmt"`
BitRate string `json:"bit_rate"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
}
type ffprobeOutput struct {
@@ -319,12 +396,7 @@ type ffprobeOutput struct {
FormatName string `json:"format_name"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
CodecType string `json:"codec_type"`
} `json:"streams"`
Streams []ffprobeStream `json:"streams"`
}
func (s *MediaService) GetMetadata(id int64) (*MediaMetadata, error) {
@@ -367,29 +439,78 @@ func (s *MediaService) GetMetadata(id int64) (*MediaMetadata, error) {
meta.Container = parts[0]
}
// Overall bitrate
if probe.Format.BitRate != "" {
if br, err := strconv.ParseInt(probe.Format.BitRate, 10, 64); err == nil {
meta.Bitrate = fmt.Sprintf("%d", br/1000)
}
}
// Stream info
for _, stream := range probe.Streams {
switch stream.CodecType {
case "video":
meta.VideoCodec = stream.CodecName
vs := VideoStream{
Codec: stream.CodecName,
Profile: stream.Profile,
Width: stream.Width,
Height: stream.Height,
FPS: parseFPS(stream.RFrameRate),
PixelFormat: stream.PixFmt,
Bitrate: formatBitrate(stream.BitRate),
}
meta.VideoStreams = append(meta.VideoStreams, vs)
if stream.Width > 0 && stream.Height > 0 {
meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
meta.AudioCodec = stream.CodecName
as := AudioStream{
Codec: stream.CodecName,
SampleRate: stream.SampleRate,
Channels: stream.Channels,
ChannelLayout: stream.ChannelLayout,
SampleFormat: stream.SampleFmt,
Bitrate: formatBitrate(stream.BitRate),
Language: stream.Tags.Language,
}
meta.AudioStreams = append(meta.AudioStreams, as)
case "subtitle":
ss := SubtitleStream{
Codec: stream.CodecName,
Language: stream.Tags.Language,
Title: stream.Tags.Title,
}
meta.SubtitleStreams = append(meta.SubtitleStreams, ss)
}
}
return meta, nil
}
func formatBitrate(bitRate string) string {
if bitRate == "" {
return ""
}
br, err := strconv.ParseInt(bitRate, 10, 64)
if err != nil {
return ""
}
return fmt.Sprintf("%d", br/1000)
}
func parseFPS(rate string) string {
if rate == "" || rate == "0/0" {
return ""
}
parts := strings.Split(rate, "/")
if len(parts) != 2 {
return ""
}
num, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
den, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err1 != nil || err2 != nil || den == 0 {
return ""
}
fps := num / den
if fps == math.Trunc(fps) {
return fmt.Sprintf("%.0f", fps)
}
return fmt.Sprintf("%.2f", fps)
}
func (s *MediaService) subtitleCachePath(id int64) string {
return filepath.Join(s.subtitleCacheDir, fmt.Sprintf("%d", id))
}
Ainternal/service/progress_cache.go
@@ -0,0 +1,71 @@
package service
import (
"strings"
"sync"
"time"
)
type LiveDownload struct {
Logs strings.Builder
LastUpdate time.Time
}
type ProgressCache struct {
mu sync.RWMutex
data map[int64]*LiveDownload
}
func NewProgressCache() *ProgressCache {
return &ProgressCache{
data: make(map[int64]*LiveDownload),
}
}
func (c *ProgressCache) Set(id int64, d *LiveDownload) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[id] = d
}
func (c *ProgressCache) Get(id int64) (*LiveDownload, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
d, ok := c.data[id]
return d, ok
}
func (c *ProgressCache) AppendLog(id int64, line string) {
c.mu.Lock()
defer c.mu.Unlock()
if d, ok := c.data[id]; ok {
d.Logs.WriteString(line)
d.Logs.WriteByte('\n')
d.LastUpdate = time.Now()
}
}
func (c *ProgressCache) GetLogs(id int64) string {
c.mu.RLock()
defer c.mu.RUnlock()
if d, ok := c.data[id]; ok {
return d.Logs.String()
}
return ""
}
func (c *ProgressCache) Delete(id int64) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, id)
}
func (c *ProgressCache) FlushLogs(id int64) string {
c.mu.Lock()
defer c.mu.Unlock()
if d, ok := c.data[id]; ok {
logs := d.Logs.String()
return logs
}
return ""
}
Aweb/static/icons/audio-icon.svg
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M21 3v12.5a3.5 3.5 0 0 1-3.5 3.5a3.5 3.5 0 0 1-3.5-3.5a3.5 3.5 0 0 1 3.5-3.5c.54 0 1.05.12 1.5.34V6.47L9 8.6v8.9A3.5 3.5 0 0 1 5.5 21A3.5 3.5 0 0 1 2 17.5A3.5 3.5 0 0 1 5.5 14c.54 0 1.05.12 1.5.34V6z"/></svg>
Aweb/static/icons/video-icon.svg
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M18 9h-2V7h2m0 6h-2v-2h2m0 6h-2v-2h2M8 9H6V7h2m0 6H6v-2h2m0 6H6v-2h2M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3z"/></svg>
Mweb/static/style.css
@@ -169,6 +169,23 @@ button:disabled {
/* Library */
.breadcrumbs {
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
flex-wrap: wrap;
text-align: left;
width: 100%;
}
.breadcrumbs a,
.breadcrumbs span {
flex-shrink: 0;
}
.breadcrumbs .current {
cursor: default;
pointer-events: none;
}
.library-grid {
@@ -185,17 +202,27 @@ button:disabled {
overflow: hidden;
text-decoration: none;
color: var(--pico-color);
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: var(--pico-card-box-shadow);
}
.folder-item:hover,
.media-item:hover {
transform: translateY(-2px);
background-color: var(--pico-primary-background);
border-color: var(--pico-primary-border);
box-shadow: var(--pico-card-box-shadow), 0 4px 12px rgba(0, 0, 0, 0.15);
}
.folder-icon,
.folder-item:hover .folder-name,
.media-item:hover .media-info,
.media-item:hover .media-title,
.media-item:hover .media-path {
color: var(--pico-primary-inverse);
}
.media-info {
transition: color 0.2s;
}
.media-thumbnail {
aspect-ratio: 16/9;
background: var(--pico-card-sectioning-background-color);
@@ -203,6 +230,29 @@ button:disabled {
align-items: center;
justify-content: center;
font-size: 3rem;
position: relative;
}
.media-thumbnail .media-duration {
position: absolute;
bottom: 0.4rem;
right: 0.4rem;
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 0.15rem 0.4rem;
border-radius: var(--pico-border-radius);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
}
.folder-icon {
aspect-ratio: 16/9;
background: var(--pico-card-sectioning-background-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
}
.media-thumbnail img {
@@ -231,11 +281,14 @@ button:disabled {
white-space: nowrap;
}
.media-duration,
.media-path {
font-size: 0.85rem;
}
.media-info .media-duration {
display: none;
}
/* Detail view */
.detail-view {
max-width: 900px;
Mweb/templates/library.html
@@ -21,20 +21,25 @@
{{if .Data.Path}}
<nav class="breadcrumbs">
<a href="/library">Home</a>
{{$path := .Data.Path}}
{{$parts := splitPath $path}}
{{$parts := splitPath .Data.Path}}
{{$last := sub (len $parts) 1}}
{{$currentPath := ""}}
{{range $i, $part := $parts}}
{{$currentPath = printf "%s/%s" $currentPath $part}}
<span>/</span>
<a href="/library?path={{$currentPath}}">{{$part}}</a>
{{if eq $i $last}}
<a href="/library?path={{urlEncodePath $currentPath}}" aria-current="page" class="current">{{$part}}</a>
{{else}}
<a href="/library?path={{urlEncodePath $currentPath}}">{{$part}}</a>
{{end}}
{{end}}
</nav>
{{end}}
{{$parentPath := .Data.Path}}
<div class="library-grid">
{{range .Data.Folders}}
<a href="/library?path={{.}}" class="folder-item">
<a href="/library?path={{urlEncodePath (printf "%s/%s" $parentPath .)}}" class="folder-item">
<div class="folder-icon">📁</div>
<div class="folder-name">{{baseName .}}</div>
</a>
@@ -48,12 +53,12 @@
{{else}}
<img src="/media/{{.ID}}/thumbnail" alt="" loading="lazy">
{{end}}
{{if .Duration}}
<span class="media-duration">{{formatDuration .Duration}}</span>
{{end}}
</div>
<div class="media-info">
<div class="media-title">{{.Title}}</div>
{{if .Duration}}
<div class="media-duration">{{formatDuration .Duration}}</div>
{{end}}
<div class="media-path">{{.RelativePath}}</div>
</div>
</a>
Mweb/templates/library_detail.html
@@ -1,7 +1,6 @@
{{define "library_detail"}}
<div class="detail-view">
<div class="detail-header">
<a href="/library" class="back-link">← Back to Library</a>
<h2>{{.Data.Media.Title}}</h2>
<details class="delete-overlay">
<summary role="button" class="small danger">Delete</summary>
@@ -33,59 +32,82 @@
<div class="detail-meta">
<dl>
<dt>Path</dt>
<dd>{{.Data.Media.RelativePath}}</dd>
{{if .Data.Media.Duration}}
<dt>Duration</dt>
<dd>{{formatDuration .Data.Media.Duration}}</dd>
{{end}}
<dt>Filename</dt>
<dd>{{baseName .Data.Media.Filepath}}</dd>
{{if .Data.Media.URL}}
<dt>Source URL</dt>
<dd><a href="{{.Data.Media.URL}}" target="_blank">{{.Data.Media.URL}}</a></dd>
{{end}}
</dl>
{{if .Data.Media.Description}}
<dt>Description</dt>
<dd>{{.Data.Media.Description}}</dd>
{{end}}
<details>
<summary role="button" class="small secondary">More Details</summary>
<dl>
{{if .Data.Media.Duration}}
<dt>Duration</dt>
<dd>{{formatDuration .Data.Media.Duration}}</dd>
{{end}}
{{if .Data.Metadata}}
{{if .Data.Metadata.Container}}
<dt>Container</dt>
<dd>{{.Data.Metadata.Container}}</dd>
{{end}}
{{if .Data.Media.Description}}
<dt>Description</dt>
<dd>{{.Data.Media.Description}}</dd>
{{end}}
{{if .Data.Metadata.Resolution}}
<dt>Resolution</dt>
<dd>{{.Data.Metadata.Resolution}}</dd>
{{end}}
{{if .Data.Metadata}}
{{if .Data.Metadata.Container}}
<dt>Container</dt>
<dd>{{.Data.Metadata.Container}}</dd>
{{end}}
{{if .Data.Metadata.VideoCodec}}
<dt>Video Codec</dt>
<dd>{{.Data.Metadata.VideoCodec}}</dd>
{{end}}
{{if .Data.Metadata.Resolution}}
<dt>Resolution</dt>
<dd>{{.Data.Metadata.Resolution}}</dd>
{{end}}
{{if .Data.Metadata.AudioCodec}}
<dt>Audio Codec</dt>
<dd>{{.Data.Metadata.AudioCodec}}</dd>
{{end}}
{{range .Data.Metadata.VideoStreams}}
<dt>Video</dt>
<dd>
{{.Codec}}
{{if .Profile}}({{.Profile}}){{end}}
{{if .Width}}[{{.Width}}x{{.Height}}]{{end}}
{{if .FPS}}@ {{.FPS}} fps{{end}}
{{if .PixelFormat}}, {{.PixelFormat}}{{end}}
{{if .Bitrate}}, {{.Bitrate}} kbps{{end}}
</dd>
{{end}}
{{if .Data.Metadata.Bitrate}}
<dt>Bitrate</dt>
<dd>{{.Data.Metadata.Bitrate}} kbps</dd>
{{end}}
{{range .Data.Metadata.AudioStreams}}
<dt>Audio</dt>
<dd>
{{.Codec}}
{{if .Language}}[{{.Language}}]{{end}}
{{if .SampleRate}}, {{.SampleRate}} Hz{{end}}
{{if .ChannelLayout}}, {{.ChannelLayout}}{{else if .Channels}}, {{.Channels}} channels{{end}}
{{if .SampleFormat}}, {{.SampleFormat}}{{end}}
{{if .Bitrate}}, {{.Bitrate}} kbps{{end}}
</dd>
{{end}}
{{if .Data.Metadata.FileSize}}
<dt>File Size</dt>
<dd>{{formatFileSize .Data.Metadata.FileSize}}</dd>
{{end}}
{{end}}
{{range .Data.Metadata.SubtitleStreams}}
<dt>Subtitle</dt>
<dd>
{{.Codec}}
{{if .Language}}[{{.Language}}]{{end}}
{{if .Title}}- {{.Title}}{{end}}
</dd>
{{end}}
<dt>File</dt>
<dd>{{.Data.Media.Filepath}}</dd>
</dl>
{{if .Data.Metadata.FileSize}}
<dt>File Size</dt>
<dd>{{formatFileSize .Data.Metadata.FileSize}}</dd>
{{end}}
{{end}}
<dt>File</dt>
<dd>{{.Data.Media.Filepath}}</dd>
</dl>
</details>
</div>
</div>
{{end}}