diff --git a/pkg/connector/handlers/audio.go b/pkg/connector/handlers/audio.go index ef1b2b2..7fe1822 100644 --- a/pkg/connector/handlers/audio.go +++ b/pkg/connector/handlers/audio.go @@ -44,11 +44,12 @@ func (h *Handler) ConvertAudio(ctx context.Context, portal *bridgev2.Portal, int if isPlainMedia { sid = "m" } - audioData, err := client.DownloadOBSWithSID(ctx, oid, data.ID, sid) + downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia) + audioData, err := client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions) if newClient, ok := h.tryRecoverClient(ctx, err); ok { client = newClient - audioData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, sid) + audioData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions) } _ = client diff --git a/pkg/connector/handlers/file.go b/pkg/connector/handlers/file.go index 46309c3..2788c53 100644 --- a/pkg/connector/handlers/file.go +++ b/pkg/connector/handlers/file.go @@ -35,7 +35,8 @@ func (h *Handler) ConvertFile(ctx context.Context, portal *bridgev2.Portal, inte if isPlainMedia { sid = "m" } - fileData, err := client.DownloadOBSWithSID(ctx, oid, data.ID, sid) + downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia) + fileData, err := client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions) if err != nil { h.Log.Warn(). Err(err). diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 2b292e0..173041d 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" @@ -27,23 +28,36 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int return nil, nil } + mediaCategory := lineMediaCategory(data.ContentMetadata) + downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia) + var imgData []byte var err error + dlStart := time.Now() + h.Log.Debug(). + Str("oid", oid). + Str("msg_id", data.ID). + Str("tid", downloadOptions.TID). + Str("media_category", mediaCategory). + Bool("has_obs_pop", downloadOptions.OBSPop != ""). + Bool("plain_media", isPlainMedia). + Msg("Downloading image from LINE OBS") if isPlainMedia { - imgData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, "m") + imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions) } else { - imgData, err = client.DownloadOBS(ctx, oid, data.ID) + imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions) } // Refresh token if we get a 401 if newClient, ok := h.tryRecoverClient(ctx, err); ok { client = newClient if isPlainMedia { - imgData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, "m") + imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions) } else { - imgData, err = client.DownloadOBS(ctx, oid, data.ID) + imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions) } } + downloadDuration := time.Since(dlStart) if err != nil { h.Log.Warn(). @@ -51,6 +65,7 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int Str("oid", oid). Str("msg_id", data.ID). Bool("plain_media", isPlainMedia). + Dur("download_duration", downloadDuration). Msg("Failed to download image from OBS, sending placeholder") return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ @@ -67,15 +82,22 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } // Decrypt image if it has keyMaterial (E2EE) + var decryptDuration time.Duration if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") { var decryptInfo struct { KeyMaterial string `json:"keyMaterial"` FileName string `json:"fileName"` } if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.KeyMaterial != "" { + decryptStart := time.Now() decryptedImg, err := h.DecryptMedia(imgData, decryptInfo.KeyMaterial) + decryptDuration = time.Since(decryptStart) if err != nil { - h.Log.Error().Err(err).Msg("Failed to decrypt image data") + h.Log.Error(). + Err(err). + Dur("download_duration", downloadDuration). + Dur("decrypt_duration", decryptDuration). + Msg("Failed to decrypt image data") return nil, fmt.Errorf("failed to decrypt image data: %w", err) } imgData = decryptedImg @@ -83,12 +105,33 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } // Upload to Matrix + uploadStart := time.Now() mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + uploadDuration := time.Since(uploadStart) if err != nil { - h.Log.Error().Err(err).Int("size_bytes", len(imgData)).Msg("Failed to upload image to Matrix") + h.Log.Error(). + Err(err). + Int("size_bytes", len(imgData)). + Dur("download_duration", downloadDuration). + Dur("decrypt_duration", decryptDuration). + Dur("upload_duration", uploadDuration). + Msg("Failed to upload image to Matrix") return nil, fmt.Errorf("failed to upload image to matrix: %w", err) } + matrixMediaURL := string(mxc) + if file != nil && file.URL != "" { + matrixMediaURL = string(file.URL) + } + + h.Log.Info(). + Str("matrix_media_url", matrixMediaURL). + Int("size", len(imgData)). + Dur("download_duration", downloadDuration). + Dur("decrypt_duration", decryptDuration). + Dur("upload_duration", uploadDuration). + Msg("Successfully uploaded image to Matrix") + return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { @@ -104,3 +147,28 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int }, }, nil } + +func lineMediaCategory(metadata map[string]string) string { + if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" { + return "" + } + + var info struct { + Category string `json:"category"` + } + if err := json.Unmarshal([]byte(metadata["MEDIA_CONTENT_INFO"]), &info); err != nil { + return "" + } + + return info.Category +} + +func lineOBSDownloadOptions(metadata map[string]string, isPlainMedia bool) line.OBSDownloadOptions { + opts := line.OBSDownloadOptions{ + OBSPop: metadata["OBS_POP"], + } + if isPlainMedia && lineMediaCategory(metadata) == "original" { + opts.TID = "original" + } + return opts +} diff --git a/pkg/connector/handlers/video.go b/pkg/connector/handlers/video.go index 7e5d64c..8dbf05c 100644 --- a/pkg/connector/handlers/video.go +++ b/pkg/connector/handlers/video.go @@ -45,12 +45,13 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int if isPlainMedia { sid = "m" } + downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia) dlStart := time.Now() - videoData, err := client.DownloadOBSWithSID(ctx, oid, data.ID, sid) + videoData, err := client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions) if newClient, ok := h.tryRecoverClient(ctx, err); ok { client = newClient - videoData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, sid) + videoData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions) } _ = client diff --git a/pkg/line/client.go b/pkg/line/client.go index 79c92a7..d65f06e 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -11,6 +11,7 @@ import ( "io" "log" "net/http" + "net/url" "strings" "time" @@ -24,6 +25,9 @@ const ( ShopBaseURL = "https://line-chrome-gw.line-apps.com/api/shop/thrift/ShopService" ExtensionVersion = "3.7.2" UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + rpcClientTimeout = 30 * time.Second + obsRetryDelay = 2 * time.Second + obsMaxRetries = 5 ) type Client struct { @@ -32,14 +36,29 @@ type Client struct { AccessToken string } +type OBSDownloadOptions struct { + TID string + OBSPop string +} + func NewClient(token string) *Client { return &Client{ - HTTPClient: &http.Client{Timeout: 30 * time.Second}, + HTTPClient: &http.Client{Timeout: rpcClientTimeout}, OBSClient: &http.Client{}, AccessToken: token, } } +func (c *Client) obsHTTPClient() *http.Client { + if c.OBSClient != nil { + return c.OBSClient + } + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{} +} + func (c *Client) Login(email, pass, certificate string) (*LoginResult, error) { // 1. Get RSA Key Info rsaKey, err := c.GetRSAKeyInfo() @@ -517,7 +536,7 @@ func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { req.Header.Set("X-Obs-Params", obsParamsB64) req.Header.Set("x-line-access", obsToken) - resp, err := c.HTTPClient.Do(req) + resp, err := c.obsHTTPClient().Do(req) if err != nil { return fmt.Errorf("OBS upload request failed: %w", err) } @@ -579,7 +598,7 @@ func (c *Client) UploadOBSWithSID(data []byte, sid string) (string, error) { // Use the OBS-specific access token req.Header.Set("x-line-access", obsToken) - resp, err := c.HTTPClient.Do(req) + resp, err := c.obsHTTPClient().Do(req) if err != nil { return "", fmt.Errorf("OBS upload request failed: %w", err) } @@ -637,7 +656,7 @@ func (c *Client) UploadOBSWithOIDAndSID(data []byte, oid string, sid string) err req.Header.Set("X-Obs-Params", obsParamsB64) req.Header.Set("x-line-access", obsToken) - resp, err := c.HTTPClient.Do(req) + resp, err := c.obsHTTPClient().Do(req) if err != nil { return fmt.Errorf("OBS upload request failed: %w", err) } @@ -657,20 +676,33 @@ func (c *Client) DownloadOBS(ctx context.Context, oid string, messageID string) return c.DownloadOBSWithSID(ctx, oid, messageID, "emi") } +func (c *Client) DownloadOBSWithOptions(ctx context.Context, oid string, messageID string, opts OBSDownloadOptions) ([]byte, error) { + return c.DownloadOBSWithSIDOptions(ctx, oid, messageID, "emi", opts) +} + func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID string, sid string) ([]byte, error) { + return c.DownloadOBSWithSIDOptions(ctx, oid, messageID, sid, OBSDownloadOptions{}) +} + +func (c *Client) DownloadOBSWithSIDOptions(ctx context.Context, oid string, messageID string, sid string, opts OBSDownloadOptions) ([]byte, error) { // URL structure: https://obs.line-apps.com/r/talk/{SID}/{OID} // SID: emi (images), emv (videos), ema (audio), emf (files) - url := fmt.Sprintf("%s/r/talk/%s/%s", OBSBaseURL, sid, oid) + obsURL := fmt.Sprintf("%s/r/talk/%s/%s", OBSBaseURL, sid, oid) + if opts.TID != "" { + obsURL += "/" + url.PathEscape(opts.TID) + } + if opts.OBSPop != "" { + obsURL += "?p=" + url.QueryEscape(opts.OBSPop) + } obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { return nil, fmt.Errorf("failed to acquire encrypted access token: %w", err) } - // Retry loop for 202 (media still processing, e.g. video transcoding) - maxRetries := 5 - for attempt := 0; attempt <= maxRetries; attempt++ { - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + // Retry loop for 202/404 (media still processing, e.g. video transcoding). + for attempt := 0; attempt <= obsMaxRetries; attempt++ { + req, err := http.NewRequestWithContext(ctx, "GET", obsURL, nil) if err != nil { return nil, fmt.Errorf("failed to create OBS download request: %w", err) } @@ -686,14 +718,21 @@ func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID s req.Header.Set("x-talk-meta", talkMeta) } - resp, err := c.OBSClient.Do(req) + resp, err := c.obsHTTPClient().Do(req) if err != nil { return nil, fmt.Errorf("OBS download request failed: %w", err) } - if (resp.StatusCode == 202 || resp.StatusCode == 404) && attempt < maxRetries { + if resp.StatusCode == 202 || resp.StatusCode == 404 { resp.Body.Close() - time.Sleep(2 * time.Second) + if attempt >= obsMaxRetries { + return nil, fmt.Errorf("OBS download failed: media still processing after %d retries", obsMaxRetries) + } + select { + case <-time.After(obsRetryDelay): + case <-ctx.Done(): + return nil, ctx.Err() + } continue } @@ -712,7 +751,7 @@ func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID s return data, nil } - return nil, fmt.Errorf("OBS download failed: media still processing after %d retries", maxRetries) + return nil, fmt.Errorf("OBS download failed: media still processing after %d retries", obsMaxRetries) } // this builds x-talk-meta