mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
4b747c74ca
Audit-driven port from the original Python MediaStation. Eight major
subsystems that were absent from the Go rewrite are now in place,
each with its own service, handler, frontend page and smoke-test
assertions.
Backend services
- service/crypto.go: AES-256-GCM encrypt/decrypt for at-rest secrets
keyed off the JWT secret. Legacy plaintext rows pass through
unchanged for smooth upgrades. Unit-tested.
- service/api_config.go: third-party provider config (TMDb, Bangumi,
TheTVDB, Fanart, Douban, OpenAI). Seeds defaults on first run.
Encrypts api_key on write, returns masked 'abc1****wxyz' projection.
- service/duplicate.go: sparse-sample MD5 (head + middle + tail, 1MiB
each, plus file-size suffix) duplicate finder. Picks 'best' primary
(matched > size > id) and marks others is_duplicate=true.
- service/filemanager.go: server-side allow-listed file browser used
by the library-path picker. Strict path-traversal protection.
- service/dlna.go: real SSDP M-SEARCH discovery + AVTransport
SetAVTransportURI/Play SOAP cast. 30 s discovery cache.
- service/scheduler.go: 3 recurring background jobs (library_scan
60min, transcode_cleanup 24h, recycle_purge 24h with 30-day
cutoff). Status + run-now endpoints.
- service/cache_cleanup.go: walkAndPrune helper used by scheduler.
- service/storage.go: DB-only disk-usage breakdown by library and by
container format.
- service/emby_compat.go: read-only Emby/Jellyfin shim
(System/Info, Users, Users/x/Views, Items, PlaybackInfo) so Infuse
/ VidHub / Kodi can browse MediaStationGo libraries.
Model updates
- Media: new strm_url (302 redirect target), file_hash, is_duplicate,
duplicate_of fields.
- APIConfig: new table for encrypted provider secrets.
- AutoMigrate registers APIConfig.
Stream layer
- StreamService.ServeFile now redirects 302 to strm_url when set so
WebDAV / Alist / S3 / HTTP direct links work transparently.
Handlers + routes
- Authed: GET /files, GET /storage, GET /dlna/devices, POST /dlna/cast,
PUT/DELETE /media/:id/strm, POST /strm/import,
POST /duplicates/{scan,unmark}.
- Admin: GET/PUT/DELETE /admin/api-configs/:provider,
GET /admin/scheduler, POST /admin/scheduler/:name/run.
- New /emby/* group: System/Info, Users, Users/:userId/Views,
Users/:userId/Items, Items/:id/PlaybackInfo (auth-required).
Frontend pages (lazy-loaded, 7 new chunks)
- DlnaPage: device list + media picker + cast button.
- FileManagerPage: root selector + breadcrumb + sortable listing.
- APIConfigsPage: per-provider card with masked-key editor.
- StoragePage: usage tiles + per-library bars + per-container grid.
- DuplicatesPage: scan form + grouped report with primary highlight.
- SchedulerPage: live job table with run-now button (5s refresh).
- Sidebar reorganised: 自动化 group adds DLNA, 管理 group adds
存储 / 文件浏览 / 重复文件 / 定时任务 / API 配置.
Smoke test additions (all admin-only)
- api-configs seeded with 6 providers
- api-config encrypted in db (sqlite3 enc:v1: prefix check)
- storage breakdown
- file browser lists library root + rejects /etc (path traversal)
- dlna devices endpoint
- scheduler exposes 3 jobs + run library_scan
- emby /System/Info + /Users/{x}/Views
- strm set + stream 302 + strm clear
- duplicate scan
Verified: go build, go vet, go test (incl. new TestCrypto* suite + the
existing TestParseEpisode/TestCleanQuery/TestSrtToVTT/TestStripASSTags/
TestBuildFFmpegArgs); tsc -b && vite build emits 28 route chunks plus
the deferred hls chunk; main bundle 253 KB / 85 KB gzipped; smoke test
PASS=42 / FAIL=0.
278 lines
7.6 KiB
Go
278 lines
7.6 KiB
Go
// Package service — DLNA / UPnP discovery.
|
|
//
|
|
// DLNAService scans the LAN for "MediaRenderer" UPnP devices via SSDP
|
|
// (multicast UDP 239.255.255.250:1900) and exposes a one-shot "cast"
|
|
// helper that POSTs a SOAP envelope to the renderer's AVTransport
|
|
// service to start playback of an HTTP URL.
|
|
//
|
|
// We do NOT mediate the renderer ↔ client traffic; the renderer pulls
|
|
// the bytes directly from MediaStationGo's /api/stream endpoint, so
|
|
// the cast call only ever transports a URL string.
|
|
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// DLNAService discovers UPnP MediaRenderer devices and casts media to them.
|
|
type DLNAService struct {
|
|
log *zap.Logger
|
|
|
|
mu sync.Mutex
|
|
cache []DLNADevice
|
|
cachedAt time.Time
|
|
}
|
|
|
|
// NewDLNAService is the constructor.
|
|
func NewDLNAService(log *zap.Logger) *DLNAService {
|
|
return &DLNAService{log: log}
|
|
}
|
|
|
|
// DLNADevice is the public projection of a discovered renderer.
|
|
type DLNADevice struct {
|
|
UDN string `json:"udn"`
|
|
FriendlyName string `json:"friendly_name"`
|
|
Manufacturer string `json:"manufacturer"`
|
|
ModelName string `json:"model_name"`
|
|
Location string `json:"location"` // device description URL
|
|
ControlURL string `json:"control_url"` // AVTransport SOAP endpoint
|
|
IPAddress string `json:"ip_address"`
|
|
}
|
|
|
|
// ssdpDiscover sends an M-SEARCH and returns the LOCATION URLs of every
|
|
// device that replied within timeout.
|
|
func (d *DLNAService) ssdpDiscover(ctx context.Context, timeout time.Duration) ([]string, error) {
|
|
addr, err := net.ResolveUDPAddr("udp4", "239.255.255.250:1900")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
msg := strings.Join([]string{
|
|
"M-SEARCH * HTTP/1.1",
|
|
"HOST: 239.255.255.250:1900",
|
|
`MAN: "ssdp:discover"`,
|
|
"MX: 2",
|
|
"ST: urn:schemas-upnp-org:device:MediaRenderer:1",
|
|
"", "",
|
|
}, "\r\n")
|
|
if _, err := conn.WriteTo([]byte(msg), addr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
_ = conn.SetReadDeadline(deadline)
|
|
|
|
seen := map[string]struct{}{}
|
|
var locations []string
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return locations, nil
|
|
default:
|
|
}
|
|
n, _, err := conn.ReadFrom(buf)
|
|
if err != nil {
|
|
break
|
|
}
|
|
body := string(buf[:n])
|
|
for _, line := range strings.Split(body, "\r\n") {
|
|
if strings.HasPrefix(strings.ToUpper(line), "LOCATION:") {
|
|
loc := strings.TrimSpace(line[len("LOCATION:"):])
|
|
if _, ok := seen[loc]; ok {
|
|
continue
|
|
}
|
|
seen[loc] = struct{}{}
|
|
locations = append(locations, loc)
|
|
}
|
|
}
|
|
}
|
|
return locations, nil
|
|
}
|
|
|
|
// Discover returns every reachable MediaRenderer on the LAN. Results are
|
|
// cached for 30 seconds so the React UI's polling does not spam the
|
|
// network.
|
|
func (d *DLNAService) Discover(ctx context.Context, force bool) ([]DLNADevice, error) {
|
|
d.mu.Lock()
|
|
if !force && time.Since(d.cachedAt) < 30*time.Second && d.cache != nil {
|
|
out := append([]DLNADevice(nil), d.cache...)
|
|
d.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
locations, err := d.ssdpDiscover(ctx, 3*time.Second)
|
|
if err != nil {
|
|
// SSDP often fails on container networks; treat as "no devices"
|
|
// rather than 500 the API.
|
|
d.log.Debug("ssdp discover failed", zap.Error(err))
|
|
return nil, nil
|
|
}
|
|
devices := make([]DLNADevice, 0, len(locations))
|
|
for _, loc := range locations {
|
|
dev, err := d.fetchDescription(ctx, loc)
|
|
if err != nil {
|
|
d.log.Debug("desc fetch", zap.String("loc", loc), zap.Error(err))
|
|
continue
|
|
}
|
|
devices = append(devices, *dev)
|
|
}
|
|
|
|
d.mu.Lock()
|
|
d.cache = devices
|
|
d.cachedAt = time.Now()
|
|
d.mu.Unlock()
|
|
return devices, nil
|
|
}
|
|
|
|
// fetchDescription parses the device's UPnP XML descriptor and pulls out
|
|
// the AVTransport control URL.
|
|
func (d *DLNAService) fetchDescription(ctx context.Context, location string) (*DLNADevice, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type service struct {
|
|
ServiceType string `xml:"serviceType"`
|
|
ControlURL string `xml:"controlURL"`
|
|
}
|
|
type device struct {
|
|
FriendlyName string `xml:"friendlyName"`
|
|
Manufacturer string `xml:"manufacturer"`
|
|
ModelName string `xml:"modelName"`
|
|
UDN string `xml:"UDN"`
|
|
ServiceList struct {
|
|
Services []service `xml:"service"`
|
|
} `xml:"serviceList"`
|
|
}
|
|
type root struct {
|
|
Device device `xml:"device"`
|
|
}
|
|
var r root
|
|
if err := xml.Unmarshal(body, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &DLNADevice{
|
|
UDN: r.Device.UDN,
|
|
FriendlyName: r.Device.FriendlyName,
|
|
Manufacturer: r.Device.Manufacturer,
|
|
ModelName: r.Device.ModelName,
|
|
Location: location,
|
|
}
|
|
if u, err := url.Parse(location); err == nil {
|
|
out.IPAddress = u.Hostname()
|
|
}
|
|
for _, svc := range r.Device.ServiceList.Services {
|
|
if strings.Contains(svc.ServiceType, "AVTransport") {
|
|
out.ControlURL = absoluteURL(location, svc.ControlURL)
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func absoluteURL(base, ref string) string {
|
|
bu, err := url.Parse(base)
|
|
if err != nil {
|
|
return ref
|
|
}
|
|
ru, err := url.Parse(ref)
|
|
if err != nil {
|
|
return ref
|
|
}
|
|
return bu.ResolveReference(ru).String()
|
|
}
|
|
|
|
// soapTemplate is the AVTransport SetAVTransportURI envelope.
|
|
const soapTemplate = `<?xml version="1.0"?>
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
<s:Body>
|
|
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
|
<InstanceID>0</InstanceID>
|
|
<CurrentURI>%s</CurrentURI>
|
|
<CurrentURIMetaData></CurrentURIMetaData>
|
|
</u:SetAVTransportURI>
|
|
</s:Body>
|
|
</s:Envelope>`
|
|
|
|
const playTemplate = `<?xml version="1.0"?>
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
<s:Body>
|
|
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
|
<InstanceID>0</InstanceID>
|
|
<Speed>1</Speed>
|
|
</u:Play>
|
|
</s:Body>
|
|
</s:Envelope>`
|
|
|
|
// Cast tells the device at controlURL to start playing mediaURL. Returns
|
|
// the renderer's HTTP status for diagnostic purposes.
|
|
func (d *DLNAService) Cast(ctx context.Context, controlURL, mediaURL string) error {
|
|
if controlURL == "" {
|
|
return errors.New("device has no AVTransport control URL")
|
|
}
|
|
if err := d.soap(ctx, controlURL, "SetAVTransportURI",
|
|
fmt.Sprintf(soapTemplate, escapeXML(mediaURL))); err != nil {
|
|
return err
|
|
}
|
|
return d.soap(ctx, controlURL, "Play", playTemplate)
|
|
}
|
|
|
|
// soap POSTs an envelope and returns the parsed faultstring (if any).
|
|
func (d *DLNAService) soap(ctx context.Context, controlURL, action, envelope string) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, controlURL,
|
|
bytes.NewReader([]byte(envelope)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
|
|
req.Header.Set("SOAPAction",
|
|
fmt.Sprintf(`"urn:schemas-upnp-org:service:AVTransport:1#%s"`, action))
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("dlna %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func escapeXML(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">",
|
|
`"`, """, "'", "'")
|
|
return r.Replace(s)
|
|
}
|