summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/handlers_more_test.go36
-rw-r--r--internal/api/handlers_share.go60
-rw-r--r--internal/api/handlers_test.go7
-rw-r--r--internal/api/server.go1
-rw-r--r--internal/service/media.go4
-rw-r--r--internal/service/media_share.go23
-rw-r--r--internal/service/mock.go7
-rw-r--r--internal/service/service.go23
8 files changed, 136 insertions, 25 deletions
diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go
index 8a8303d..23e94c7 100644
--- a/internal/api/handlers_more_test.go
+++ b/internal/api/handlers_more_test.go
@@ -951,7 +951,7 @@ func TestServer_SharePage(t *testing.T) {
t.Run("service error", func(t *testing.T) {
ms := &service.MockMediaService{
- ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
+ GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
return nil, errors.New("boom")
},
}
@@ -966,7 +966,7 @@ func TestServer_SharePage(t *testing.T) {
t.Run("not found", func(t *testing.T) {
ms := &service.MockMediaService{
- ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
+ GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
return nil, nil
},
}
@@ -981,7 +981,7 @@ func TestServer_SharePage(t *testing.T) {
t.Run("expired", func(t *testing.T) {
ms := &service.MockMediaService{
- ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
+ GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
return nil, service.ErrShareExpired
},
}
@@ -996,8 +996,12 @@ func TestServer_SharePage(t *testing.T) {
t.Run("html default accept", func(t *testing.T) {
ms := &service.MockMediaService{
- ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
- return &model.Share{Token: "abc", MediaID: 1}, nil
+ GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
+ return &service.GetSharedMediaResult{
+ Media: &model.Media{ID: 1, FileName: "share.mp4", Type: model.MediaTypeVideo, Duration: 120},
+ StreamURL: "/s/abc/stream",
+ ThumbURL: "/s/abc/thumbnail",
+ }, nil
},
}
srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs)
@@ -1019,8 +1023,12 @@ func TestServer_SharePage(t *testing.T) {
t.Run("html explicit accept", func(t *testing.T) {
ms := &service.MockMediaService{
- ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
- return &model.Share{Token: "abc", MediaID: 1}, nil
+ GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
+ return &service.GetSharedMediaResult{
+ Media: &model.Media{ID: 1, FileName: "share.mp4", Type: model.MediaTypeVideo, Duration: 120},
+ StreamURL: "/s/abc/stream",
+ ThumbURL: "/s/abc/thumbnail",
+ }, nil
},
}
srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs)
@@ -1039,8 +1047,12 @@ func TestServer_SharePage(t *testing.T) {
t.Run("json accept", func(t *testing.T) {
ms := &service.MockMediaService{
- ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
- return &model.Share{Token: "abc", MediaID: 1}, nil
+ GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
+ return &service.GetSharedMediaResult{
+ Media: &model.Media{ID: 1, FileName: "share.mp4", Type: model.MediaTypeVideo, Duration: 120},
+ StreamURL: "/s/abc/stream",
+ ThumbURL: "/s/abc/thumbnail",
+ }, nil
},
}
srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs)
@@ -1055,12 +1067,12 @@ func TestServer_SharePage(t *testing.T) {
if !strings.Contains(ct, "application/json") {
t.Fatalf("expected application/json content type, got %q", ct)
}
- var body model.Share
+ var body service.GetSharedMediaResult
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
- if body.Token != "abc" {
- t.Fatalf("unexpected token %q", body.Token)
+ if body.StreamURL != "/s/abc/stream" {
+ t.Fatalf("unexpected stream_url %q", body.StreamURL)
}
})
}
diff --git a/internal/api/handlers_share.go b/internal/api/handlers_share.go
index 9fc2974..3eac87b 100644
--- a/internal/api/handlers_share.go
+++ b/internal/api/handlers_share.go
@@ -1,8 +1,11 @@
package api
import (
+ "encoding/json"
"errors"
+ "io"
"net/http"
+ "path/filepath"
"strings"
"time"
@@ -69,8 +72,8 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) {
return
}
token := r.PathValue("token")
- share, err := s.mediaSvc.ValidateShareToken(r.Context(), token)
- if err != nil || share == nil {
+ res, err := s.mediaSvc.GetSharedMedia(r.Context(), token)
+ if err != nil || res == nil {
if err != nil && errors.Is(err, service.ErrShareExpired) {
http.Error(w, "gone", http.StatusGone)
return
@@ -83,11 +86,58 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Vary", "Accept")
accept := r.Header.Get("Accept")
- if strings.Contains(accept, "text/html") || accept == "" {
- s.serveFile(w, r, "share.html")
+ if strings.Contains(accept, "application/json") {
+ writeJSON(w, http.StatusOK, res)
return
}
- writeJSON(w, http.StatusOK, share)
+
+ // Serve HTML page with media metadata injected.
+ f, err := s.staticFS.Open("share.html")
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ defer f.Close()
+ stat, err := f.Stat()
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ var buf strings.Builder
+ if _, err := io.Copy(&buf, f); err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ html := buf.String()
+ data, _ := json.Marshal(res)
+ html = strings.Replace(html, "<!--SHARE_MEDIA-->", string(data), 1)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ http.ServeContent(w, r, "share.html", stat.ModTime(), strings.NewReader(html))
+}
+
+func (s *Server) handleShareThumbnail(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.mediaSvc) {
+ return
+ }
+ token := r.PathValue("token")
+ res, err := s.mediaSvc.GetSharedMedia(r.Context(), token)
+ if err != nil || res == nil {
+ if err != nil && errors.Is(err, service.ErrShareExpired) {
+ http.Error(w, "gone", http.StatusGone)
+ return
+ }
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if !res.HasThumb || res.Media == nil || res.Media.ThumbnailPath == "" {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ fr := &service.FileResult{
+ Path: res.Media.ThumbnailPath,
+ FileName: filepath.Base(res.Media.ThumbnailPath),
+ }
+ s.serveFileResult(w, r, fr, false)
}
func (s *Server) handleShareStream(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
index 41215c9..b574e61 100644
--- a/internal/api/handlers_test.go
+++ b/internal/api/handlers_test.go
@@ -1081,6 +1081,13 @@ func TestServer_Shares(t *testing.T) {
RevokeShareFunc: func(ctx context.Context, token string, userID int64) error {
return nil
},
+ GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
+ return &service.GetSharedMediaResult{
+ Media: &model.Media{ID: 1, FileName: "x.mp4", Type: model.MediaTypeVideo, Duration: 120},
+ StreamURL: "/s/abc/stream",
+ ThumbURL: "/s/abc/thumbnail",
+ }, nil
+ },
ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
return &model.Share{Token: token, MediaID: 1}, nil
},
diff --git a/internal/api/server.go b/internal/api/server.go
index 0ea96af..6c15c78 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -94,6 +94,7 @@ func (s *Server) routes() {
// Public share routes
s.mux.HandleFunc("GET /s/{token}", s.handleSharePage)
s.mux.HandleFunc("GET /s/{token}/stream", s.handleShareStream)
+ s.mux.HandleFunc("GET /s/{token}/thumbnail", s.handleShareThumbnail)
// Static assets (public)
staticHandler := http.FileServer(s.staticFS)
diff --git a/internal/service/media.go b/internal/service/media.go
index 1856592..a617a29 100644
--- a/internal/service/media.go
+++ b/internal/service/media.go
@@ -73,6 +73,8 @@ var supportedExtensions = map[string]struct{}{
".ogg": {},
".m4a": {},
".wma": {},
+ ".m4b": {},
+ ".opus": {},
}
func isSupportedExtension(name string) bool {
@@ -86,7 +88,7 @@ func guessMediaType(name string) model.MediaType {
switch ext {
case ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm":
return model.MediaTypeVideo
- case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".wma":
+ case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".wma", ".m4b", ".opus":
return model.MediaTypeAudio
default:
return model.MediaTypeVideo
diff --git a/internal/service/media_share.go b/internal/service/media_share.go
index 0a96f85..3decf96 100644
--- a/internal/service/media_share.go
+++ b/internal/service/media_share.go
@@ -112,3 +112,26 @@ func (s *mediaService) StreamSharedMedia(ctx context.Context, token string) (*Fi
FileSize: media.FileSizeBytes,
}, nil
}
+
+func (s *mediaService) GetSharedMedia(ctx context.Context, token string) (*GetSharedMediaResult, error) {
+ share, err := s.ValidateShareToken(ctx, token)
+ if err != nil {
+ return nil, err
+ }
+
+ media, err := s.store.GetMediaByID(ctx, share.MediaID)
+ if err != nil {
+ return nil, fmt.Errorf("get media: %w", err)
+ }
+ if media == nil {
+ return nil, ErrMediaNotFound
+ }
+
+ res := &GetSharedMediaResult{
+ Media: media,
+ StreamURL: fmt.Sprintf("/s/%s/stream", token),
+ HasThumb: media.ThumbnailPath != "",
+ ThumbURL: fmt.Sprintf("/s/%s/thumbnail", token),
+ }
+ return res, nil
+}
diff --git a/internal/service/mock.go b/internal/service/mock.go
index c6c2301..82a25de 100644
--- a/internal/service/mock.go
+++ b/internal/service/mock.go
@@ -42,6 +42,7 @@ type MockMediaService struct {
RevokeShareFunc func(ctx context.Context, token string, userID int64) error
ValidateShareTokenFunc func(ctx context.Context, token string) (*model.Share, error)
StreamSharedMediaFunc func(ctx context.Context, token string) (*FileResult, error)
+ GetSharedMediaFunc func(ctx context.Context, token string) (*GetSharedMediaResult, error)
GetNoteFunc func(ctx context.Context, mediaID, userID int64) (*model.Note, error)
UpsertNoteFunc func(ctx context.Context, note *model.Note) error
DeleteNoteFunc func(ctx context.Context, mediaID, userID int64) error
@@ -161,6 +162,12 @@ func (m *MockMediaService) StreamSharedMedia(ctx context.Context, token string)
}
return nil, errors.New("not implemented")
}
+func (m *MockMediaService) GetSharedMedia(ctx context.Context, token string) (*GetSharedMediaResult, error) {
+ if m.GetSharedMediaFunc != nil {
+ return m.GetSharedMediaFunc(ctx, token)
+ }
+ return nil, errors.New("not implemented")
+}
func (m *MockMediaService) GetNote(ctx context.Context, mediaID, userID int64) (*model.Note, error) {
if m.GetNoteFunc != nil {
return m.GetNoteFunc(ctx, mediaID, userID)
diff --git a/internal/service/service.go b/internal/service/service.go
index 1ed1443..fd96b25 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -29,14 +29,23 @@ type MediaWriteService interface {
UploadMedia(ctx context.Context, setID, userID int64, filename string, data io.Reader, size int64) (*model.Media, error)
}
+// GetSharedMediaResult wraps media metadata needed to render a share page.
+ type GetSharedMediaResult struct {
+ Media *model.Media `json:"media"`
+ HasThumb bool `json:"has_thumb"`
+ StreamURL string `json:"stream_url"`
+ ThumbURL string `json:"thumb_url"`
+ }
+
// MediaShareService handles creation, validation and revocation of share links.
-type MediaShareService interface {
- CreateShare(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error)
- ListShares(ctx context.Context, mediaID, userID int64) ([]model.Share, error)
- RevokeShare(ctx context.Context, token string, userID int64) error
- ValidateShareToken(ctx context.Context, token string) (*model.Share, error)
- StreamSharedMedia(ctx context.Context, token string) (*FileResult, error)
-}
+ type MediaShareService interface {
+ CreateShare(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error)
+ ListShares(ctx context.Context, mediaID, userID int64) ([]model.Share, error)
+ RevokeShare(ctx context.Context, token string, userID int64) error
+ ValidateShareToken(ctx context.Context, token string) (*model.Share, error)
+ StreamSharedMedia(ctx context.Context, token string) (*FileResult, error)
+ GetSharedMedia(ctx context.Context, token string) (*GetSharedMediaResult, error)
+ }
// MediaTagService handles tagging of media items.
type MediaTagService interface {