diff --git a/pkg/app/app.go b/pkg/app/app.go index 1503ddd..d0477ef 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -415,7 +415,7 @@ func (a *App) processItem(ctx context.Context, p googlephotos.Photo, albumTitle, } a.Logger.Debug("Downloading item", "id", safeId) - data, ext, isVideo, err := googlephotos.DownloadMedia(ctx, a.GPClient, p.URL) + data, ext, isVideo, isLivePhoto, err := googlephotos.DownloadMedia(ctx, a.GPClient, p.URL) if err != nil { return "", false, 0, 0, fmt.Errorf("error downloading item: %w", err) } @@ -441,6 +441,13 @@ func (a *App) processItem(ctx context.Context, p googlephotos.Photo, albumTitle, } filename := baseName + ext + if isVideo { + a.Logger.Debug("Classified item as video", + "id", safeId, + "filename", filename, + "ext", ext, + ) + } description := p.Description sep := "\n" @@ -458,34 +465,11 @@ func (a *App) processItem(ctx context.Context, p googlephotos.Photo, albumTitle, if !isVideo { imageData, videoData, isMotion, hadMotionXMP := googlephotos.ExtractMotionPhoto(data, a.Logger) motionVideoExt := ".mp4" - - // Some Google motion photos expose video as sidecar (=dv) instead of embedded bytes. - if !isMotion && hadMotionXMP { - sidecarData, sidecarExt, sidecarErr := googlephotos.DownloadMotionVideoSidecar(ctx, a.GPClient, p.URL) - if sidecarErr == nil { - videoData = sidecarData - isMotion = true - if sidecarExt != "" { - motionVideoExt = sidecarExt - } - a.Logger.Debug("Motion photo sidecar downloaded", - "id", safeId, - "video_size", len(videoData), - ) - } else { - a.Logger.Debug("Motion XMP found but sidecar unavailable, stripping XMP flags", - "id", safeId, - "error", sidecarErr, - ) - googlephotos.StripMotionPhotoXMP(imageData) - } + if hadMotionXMP && !isMotion { + googlephotos.StripMotionPhotoXMP(imageData) } - // iOS Live Photos: no motion XMP in the JPEG, but Google Photos - // still exposes the paired .MOV via the =dv sidecar endpoint. - // Probe for it so we can upload both components and link them as - // a Live Photo in Immich. - if !isMotion && !hadMotionXMP { + if !isMotion && isLivePhoto { sidecarData, sidecarExt, sidecarErr := googlephotos.DownloadMotionVideoSidecar(ctx, a.GPClient, p.URL) if sidecarErr == nil { videoData = sidecarData @@ -493,12 +477,11 @@ func (a *App) processItem(ctx context.Context, p googlephotos.Photo, albumTitle, if sidecarExt != "" { motionVideoExt = sidecarExt } - a.Logger.Debug("iOS Live Photo sidecar downloaded", + a.Logger.Debug("Live photo sidecar downloaded", "id", safeId, "video_size", len(videoData), ) } - // If no sidecar found, this is just a regular image — no action needed. } if isMotion { @@ -578,4 +561,3 @@ func (a *App) processItem(ctx context.Context, p googlephotos.Photo, albumTitle, a.Logger.Debug("Uploaded item", "filename", filename, "id", uploadedId) return uploadedId, true, bytesDownloaded, bytesUploaded, nil } - diff --git a/pkg/googlephotos/scraper.go b/pkg/googlephotos/scraper.go index 64a38b0..55ed89f 100644 --- a/pkg/googlephotos/scraper.go +++ b/pkg/googlephotos/scraper.go @@ -728,16 +728,16 @@ func isImageMagicBytes(data []byte) bool { // DownloadMedia downloads original media from Google Photos. // It always tries =d first so motion photos are fetched as their JPEG container // (e.g. *.MP.jpg) instead of the short motion-video stream from =dv. -func DownloadMedia(ctx context.Context, client *Client, baseUrl string) ([]byte, string, bool, error) { +func DownloadMedia(ctx context.Context, client *Client, baseUrl string) ([]byte, string, bool, bool, error) { // Always fetch =d first. For motion photos this is the image container file. resp, err := client.Get(ctx, baseUrl+"=d") if err != nil { - return nil, "", false, fmt.Errorf("download failed: %w", err) + return nil, "", false, false, fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, "", false, fmt.Errorf("download returned status %d", resp.StatusCode) + return nil, "", false, false, fmt.Errorf("download returned status %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") @@ -746,23 +746,23 @@ func DownloadMedia(ctx context.Context, client *Client, baseUrl string) ([]byte, if strings.HasPrefix(strings.ToLower(ct), "video/") { resp2, err := client.Get(ctx, baseUrl+"=dv") if err != nil { - return nil, "", false, fmt.Errorf("video re-download failed: %w", err) + return nil, "", false, false, fmt.Errorf("video re-download failed: %w", err) } defer resp2.Body.Close() if resp2.StatusCode == 200 { videoCt := resp2.Header.Get("Content-Type") videoData, err := io.ReadAll(resp2.Body) if err != nil { - return nil, "", false, fmt.Errorf("failed to read video response body: %w", err) + return nil, "", false, false, fmt.Errorf("failed to read video response body: %w", err) } ext := extensionFromContentType(videoCt) - return videoData, ext, true, nil + return videoData, ext, true, false, nil } } data, err := io.ReadAll(resp.Body) if err != nil { - return nil, "", false, fmt.Errorf("failed to read response body: %w", err) + return nil, "", false, false, fmt.Errorf("failed to read response body: %w", err) } // If =d is actually a video item, prefer =dv for the original video stream. @@ -770,31 +770,29 @@ func DownloadMedia(ctx context.Context, client *Client, baseUrl string) ([]byte, if isVideo { resp2, err := client.Get(ctx, baseUrl+"=dv") if err != nil { - return nil, "", false, fmt.Errorf("video re-download failed: %w", err) + return nil, "", false, false, fmt.Errorf("video re-download failed: %w", err) } defer resp2.Body.Close() if resp2.StatusCode == 200 { videoCt := resp2.Header.Get("Content-Type") videoData, err := io.ReadAll(resp2.Body) if err != nil { - return nil, "", false, fmt.Errorf("failed to read video response body: %w", err) + return nil, "", false, false, fmt.Errorf("failed to read video response body: %w", err) } ext := extensionFromContentType(videoCt) - return videoData, ext, true, nil + return videoData, ext, true, false, nil } // =dv failed, fall through and use the =d data as-is } // Some Google Photos shared-album video items may return an image poster on =d. - // If this is not a motion-photo container, probe =dv and treat valid video as the primary asset. - // However, skip this replacement when =d returned genuine image data (confirmed by both - // Content-Type and magic bytes). This prevents iOS Live Photo JPEG components from being - // silently replaced by their paired .MOV sidecar — the caller (processItem) handles - // sidecar discovery and Live Photo pairing separately. - isConfirmedImage := strings.HasPrefix(strings.ToLower(ct), "image/") && isImageMagicBytes(data) - if !hasMotionPhotoXMP(data) && !isConfirmedImage { + // Probe =dv and classify the result as either a live-photo sidecar or a standalone video. + if !hasMotionPhotoXMP(data) { if sidecarData, sidecarExt, sidecarErr := DownloadMotionVideoSidecar(ctx, client, baseUrl); sidecarErr == nil { - return sidecarData, sidecarExt, true, nil + if isLikelyLivePhotoSidecar(sidecarData) { + return data, extensionFromContentType(ct), false, true, nil + } + return sidecarData, sidecarExt, true, false, nil } } @@ -802,7 +800,24 @@ func DownloadMedia(ctx context.Context, client *Client, baseUrl string) ([]byte, // If =dv retry failed but Content-Type indicated video, trust that classification. isVideoFinal := strings.HasPrefix(strings.ToLower(ct), "video/") || isVideoMagicBytes(data) ext := extensionFromContentType(ct) - return data, ext, isVideoFinal, nil + return data, ext, isVideoFinal, false, nil +} + +func isLikelyLivePhotoSidecar(data []byte) bool { + if len(data) > 25*1024*1024 { + return false + } + markers := [][]byte{ + []byte("com.apple.quicktime.still-image-time"), + []byte("com.apple.quicktime.content.identifier"), + []byte("com.apple.quicktime.live-photo"), + } + for _, marker := range markers { + if bytes.Contains(data, marker) { + return true + } + } + return false } // DownloadMotionVideoSidecar fetches the motion sidecar stream (if present) for an image item.