Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Media Resolver #427

Merged
merged 12 commits into from
Feb 11, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- PDF: Generate customized tooltips for PDF files. (#374, #377)
- Twitter: Generate thumbnails with all images of a tweet. (#373)
- YouTube: Added support for 'YouTube shorts' URLs. (#299)
- Media files: Generate tooltips for Video and Audio files. (#427)
- Minor: Add ability to opt out hostnames from the API. (#405)
- Fix: SevenTV emotes now resolve correctly. (#281, #288, #307)
- Fix: YouTube videos are no longer resolved as channels. (#284)
Expand Down
7 changes: 5 additions & 2 deletions internal/resolvers/default/link_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,11 @@ func New(ctx context.Context, cfg config.APIConfig, pool db.Pool, helixClient *h
youtube.Initialize(ctx, cfg, pool, &customResolvers)
seventv.Initialize(ctx, cfg, pool, &customResolvers)

contentTypeResolvers := []ContentTypeResolver{}
contentTypeResolvers = append(contentTypeResolvers, NewPDFResolver(cfg.BaseURL, cfg.MaxContentLength))
// The content type resolvers should match from most to least specific
contentTypeResolvers := []ContentTypeResolver{
NewPDFResolver(cfg.BaseURL, cfg.MaxContentLength),
nuuls marked this conversation as resolved.
Show resolved Hide resolved
NewMediaResolver(cfg.BaseURL),
}

linkLoader := &LinkLoader{
baseURL: cfg.BaseURL,
Expand Down
134 changes: 134 additions & 0 deletions internal/resolvers/default/media_resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package defaultresolver

import (
"bytes"
"context"
"fmt"
"html/template"
"mime"
"net/http"
"net/url"
"strings"

"github.com/Chatterino/api/pkg/humanize"
"github.com/Chatterino/api/pkg/resolver"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

const mediaTooltipTemplateString = `<div style="text-align: left;">
<b>Media File</b>
{{if .MediaType}}<br><b>Type:</b> {{.MediaType}}{{end}}
{{if .Size}}<br><b>Size:</b> {{.Size}}{{end}}
</div>
`

var mediaTooltipTemplate = template.Must(template.New("mediaTooltipTemplate").Parse(mediaTooltipTemplateString))

type mediaTooltipData struct {
MediaType string
Size string
}

type MediaResolver struct {
baseURL string
}

func (r *MediaResolver) Check(ctx context.Context, contentType string) bool {
spl := strings.Split(contentType, "/")
switch spl[0] {
case "video", "audio":
return true
}
return false
}

func (r *MediaResolver) Run(ctx context.Context, req *http.Request, resp *http.Response) (*resolver.Response, error) {
mimeType := resp.Header.Get("Content-Type")
spl := strings.Split(mimeType, "/")

size := ""
reportedSize := resp.ContentLength
if reportedSize > 0 {
size = humanize.Bytes(uint64(reportedSize))
}

ttData := mediaTooltipData{
MediaType: fmt.Sprintf("%s (%s)",
cases.Title(language.English).String(spl[0]),
strings.ToUpper(extensionFromMime(mimeType)),
),
Size: size,
}

var tooltip bytes.Buffer
if err := mediaTooltipTemplate.Execute(&tooltip, ttData); err != nil {
return nil, err
}

targetURL := resp.Request.URL.String()
response := &resolver.Response{
Status: http.StatusOK,
Link: targetURL,
Tooltip: url.PathEscape(tooltip.String()),
}

return response, nil
}

func (r *MediaResolver) Name() string {
return "MediaResolver"
}

func NewMediaResolver(baseURL string) *MediaResolver {
return &MediaResolver{
baseURL: baseURL,
}
}

func extensionFromMime(mimeType string) string {
spl := strings.Split(mimeType, "/")
if len(spl) < 2 {
return ""
}
s1, s2 := spl[0], spl[1]
switch s1 {
case "audio":
switch s2 {
case "wav", "x-wav":
return "wav"
case "mpeg":
return "mp3"
case "mp4":
return "mp4"
case "ogg":
return "ogg"
}
case "video":
switch s2 {
case "avi", "x-msvideo":
return "avi"
case "mp4":
return "mp4"
case "mpeg":
return "mpeg"
case "ogg":
return "ogg"
case "quicktime":
return "mov"
}
}

// this returns weird extensions for some mime types
// so it's only used as a backup.
// video/mp4 returns f4v for example.
types, _ := mime.ExtensionsByType(mimeType)
if len(types) > 0 {
ext := types[0]
if len(ext) > 1 {
ext = ext[1:]
}
return ext
}
return "unknown"
}
68 changes: 68 additions & 0 deletions internal/resolvers/default/media_resolver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package defaultresolver_test

import (
"context"
"net/http"
"net/url"
"strings"
"testing"

defaultresolver "github.com/Chatterino/api/internal/resolvers/default"
"github.com/Chatterino/api/pkg/humanize"
)

func TestMediaResolver(t *testing.T) {
runMRTest(t, "video/mp4", 12345, "Video (MP4)")
runMRTest(t, "video/mpeg", 12345, "Video (MPEG)")
runMRTest(t, "video/ogg", 12345, "Video (OGG)")
runMRTest(t, "video/webm", 12345, "Video (WEBM)")
runMRTest(t, "video/x-msvideo", 12345, "Video (AVI)")
runMRTest(t, "video/nam", 12345, "Video (UNKNOWN)")

runMRTest(t, "audio/mpeg", 12345, "Audio (MP3)")
runMRTest(t, "audio/mp4", 12345, "Audio (MP4)")
runMRTest(t, "audio/ogg", 12345, "Audio (OGG)")
runMRTest(t, "audio/wav", 12345, "Audio (WAV)")

runMRTest(t, "audio/wav", 12345, "Audio (WAV)")
}

func runMRTest(t *testing.T, contentType string, size int64, expectedType string) {
mr := &defaultresolver.MediaResolver{}
httpRes := &http.Response{
Header: http.Header{
"Content-Type": []string{contentType},
},
ContentLength: size,
Request: &http.Request{
URL: &url.URL{},
},
}
if !mr.Check(context.Background(), contentType) {
if expectedType == "" {
return
}
t.Errorf("Expected MediaResolver to handle content type: %s", contentType)
return
}
res, err := mr.Run(context.Background(), nil, httpRes)
if err != nil {
t.Errorf("MediaResolver should never return an error: %v", err)
return
}

resUnescaped, err := url.PathUnescape(res.Tooltip)
if err != nil {
t.Errorf("PathUnescape should never fail: %v", err)
return
}

if !strings.Contains(resUnescaped, expectedType) {
t.Errorf("Expected: %s, Got: %s", expectedType, res.Tooltip)
}

expectedSize := humanize.Bytes(uint64(size))
if size > 0 && !strings.Contains(resUnescaped, expectedSize) {
t.Errorf("Expected: %s, Got: %s", expectedSize, res.Tooltip)
}
}
17 changes: 17 additions & 0 deletions pkg/humanize/bytes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package humanize

import "fmt"

var bytesSteps = []string{"B", "KB", "MB", "GB"}

func Bytes(n uint64) string {
div := float64(1000)
val := float64(n)
for _, step := range bytesSteps {
if val < 1000 {
return fmt.Sprintf("%.1f %s", val, step)
}
val /= div
}
return fmt.Sprintf("%.1f %s", val, "TB")
}
34 changes: 34 additions & 0 deletions pkg/humanize/bytes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package humanize_test

import (
"testing"

"github.com/Chatterino/api/pkg/humanize"
qt "github.com/frankban/quicktest"
)

func TestBytes(t *testing.T) {
c := qt.New(t)
type testCase struct {
input uint64
expected string
}
cases := []testCase{
{0, "0.0 B"},
{1, "1.0 B"},
{1000, "1.0 KB"},
{1001, "1.0 KB"},
{1501, "1.5 KB"},
{1234 * 1000, "1.2 MB"},
{1234 * 1000 * 1000, "1.2 GB"},
{1234 * 1000 * 1000 * 1000, "1.2 TB"},
{1234 * 1000 * 1000 * 1000 * 1000, "1234.0 TB"},
}

for _, tc := range cases {
c.Run("", func(c *qt.C) {
res := humanize.Bytes(tc.input)
c.Assert(res, qt.Equals, tc.expected)
})
}
}