diff --git a/Download-ODLivePhotos.ps1 b/Download-ODLivePhotos.ps1 new file mode 100644 index 0000000..2081b23 --- /dev/null +++ b/Download-ODLivePhotos.ps1 @@ -0,0 +1,281 @@ +<# +.DESCRIPTION +This script attempts to authenticate to OneDrive as client https://photos.onedrive.com +(which has permissions to download LivePhotos) and downloads all LivePhotos at a given +location within your personal OneDrive. +.PARAMETER SaveTo +Target path where to save live photos. +.PARAMETER PathToScan +DOS-Style path on your OneDrive that should be scanned. Most likely '\Pictures\Camera Roll' or any other shared Camera Roll folder. + +.EXAMPLE +.\Download-ODLivePhotos.ps1 'C:\Live Photos' + +.NOTES +Author: Petr Vyskocil + +There is no error checking, so it is recommended to re-run the command on bigger libraries. +If there are any errors during the download (OneDrive sometimes fails randomly with error "Our +services aren't available right now. We're working to restore all services as soon as possible. +Please check back soon."), next run will download the missing files and skip already downloaded +ones. +#> +param ( + [Parameter(Mandatory)] + [string] $SaveTo, + [string] $PathToScan = '/Photos/Auto-saved' +) + + +function Get-ODPhotosToken +{ + <# + .DESCRIPTION + Connect to OneDrive for authentication with a OneDrive web Photos client id. Adapted from https://github.com/MarcelMeurer/PowerShellGallery-OneDrive to mimic OneDrive Photos web OIDC login. + Unfortunately using custom ClientId seems impossible - generic OD client IDs are missing the ability to download Live Photos. + .PARAMETER ClientId + ClientId of OneDrive Photos web app (073204aa-c1e0-4e66-a200-e5815a0aa93d) + .PARAMETER Scope + Comma-separated string defining the authentication scope (https://dev.onedrive.com/auth/msa_oauth.htm). Default: "OneDrive.ReadWrite,offline_access,openid,profile". + .PARAMETER RedirectURI + Code authentication requires a correct URI. Must be https://photos.onedrive.com/auth/login. + + .EXAMPLE + $access_token=Get-ODPhotosToken + Connect to OneDrive for authentication and save the token to $access_token + .NOTES + Author: Petr Vyskocil + #> + PARAM( + [string]$ClientId = "073204aa-c1e0-4e66-a200-e5815a0aa93d", + [string]$Scope = "OneDrive.ReadWrite,offline_access,openid,profile", + [string]$RedirectURI ="https://photos.onedrive.com/auth/login", + [switch]$DontShowLoginScreen=$false, + [switch]$LogOut + ) + $Authentication="" + + [Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | out-null + [Reflection.Assembly]::LoadWithPartialName("System.Drawing") | out-null + [Reflection.Assembly]::LoadWithPartialName("System.Web") | out-null + if ($Logout) + { + $URIGetAccessToken="https://login.live.com/logout.srf" + } + else + { + $URIGetAccessToken="https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id="+$ClientId+"&nonce=uv."+(New-Guid).Guid+"&response_mode=form_post&scope="+$Scope+"&response_type=code&redirect_URI="+$RedirectURI + } + $form = New-Object Windows.Forms.Form + if ($DontShowLoginScreen) + { + write-debug("Logon screen suppressed by flag -DontShowLoginScreen") + $form.Opacity = 0.0; + } + $form.text = "Authenticate to OneDrive" + $form.size = New-Object Drawing.size @(700,600) + $form.Width = 660 + $form.Height = 775 + $web=New-object System.Windows.Forms.WebBrowser + $web.IsWebBrowserContextMenuEnabled = $true + $web.Width = 600 + $web.Height = 700 + $web.Location = "25, 25" + $web.ScriptErrorsSuppressed = $true + $DocComplete = { + if ($web.Url.AbsoluteUri -match "access_token=|error|code=|logout|/auth/login") {$form.Close() } # + } + $web.Add_DocumentCompleted($DocComplete) + $form.Controls.Add($web) + $web.navigate($URIGetAccessToken) + $form.showdialog() | out-null + $Authentication = New-Object PSObject + # The returned code=XXXX is irrelevant, the actual secrets are sent as cookies: + $web.Document.Cookie -split ';' | % { + $cookie = $_ -split '=' + $cookieValue = [uri]::UnescapeDataString($cookie[1]) + $Authentication | Add-Member NoteProperty $cookie[0].Trim() $cookieValue + } + + if (-Not $Authentication.'AccessToken-OneDrive.ReadWrite') { + write-error("Cannot get authentication token. This program does not suport token refresh at the moment. Try again, if you fail a few times, restart and try again.") + # TODO: Refresh token should be handled here : GET https://photos.onedrive.com/auth/refresh?scope=OneDrive.ReadWrite&refresh_token= + # Unfortunately it seems that this page hangs up the WebBrowser control, and we need all the cookies transferred to use... + # Deemed not worth debugging for utility that runs for a short period of time + return $web + + } + + return $Authentication +} + +function Download-LivePhotosAuth +{ + <# + .DESCRIPTION + Download all Live Photos from a given OneDrive path + .PARAMETER AccessToken + Access token for OneDrive API that has ability to download live photos. + .PARAMETER SaveTo + Target path where to save live photos. + .PARAMETER PathToScan + DOS-Style path on your OneDrive that should be scanned. Most likely '\Pictures\Camera Roll' or any other shared Camera Roll filder you see. + .PARAMETER CurrentPath + Internal, used for recursion + .PARAMETER ElementId + Start at folder id (probably only useful internally for recursion too) + .PARAMETER Uri + Start processing on this URI (again for recursion) + + .EXAMPLE + Download-LivePhotosAuth -AccessToken $token -SaveTo 'C:\LivePhotos' -PathToScan '\Pictures\Camera Roll' + .NOTES + Author: Petr Vyskocil + #> + PARAM( + [Parameter(Mandatory=$True)] + [string]$AccessToken, + [Parameter(Mandatory=$True)] + [string]$SaveTo, + [string]$PathToScan='\', + [string]$CurrentPath='', + [string]$ElementId='', + [string]$Uri='' + ) + if (!$PathToScan.EndsWith('\')) { $PathToScan = $PathToScan + '\' } + if (!$PathToScan.StartsWith('\')) { $PathToScan = '\' + $PathToScan } + if ($Uri -eq '') { + if ($ElementId -eq '') { + $CurrentPath='\' + $Location='root' + } else { + $Location='items/' + $ElementId + } + $Uri = 'https://api.onedrive.com/v1.0/drive/' + $Location + '/children?%24filter=photo%2FlivePhoto+ne+null+or+folder+ne+null+or+remoteItem+ne+null&select=fileSystemInfo%2Cphoto%2Cid%2Cname%2Csize%2Cfolder%2CremoteItem' + } + Write-Output("Calling OneDrive API") + Write-Output($Uri) + $WebRequest=Invoke-WebRequest -Method 'GET' -Header @{ Authorization = "BEARER "+$AccessToken} -ErrorAction SilentlyContinue -Uri $Uri + $Response = ConvertFrom-Json $WebRequest.Content + $Response.value | % { + $FolderPath = $CurrentPath + $_.name + '\' + if ([bool]$_.PSObject.Properties['folder']) { + if ($FolderPath.StartsWith($PathToScan) -or $PathToScan.StartsWith($FolderPath)) { # We're traversing the target folder or we're getting into it + Write-Output("Checking folder $($_.id) - $($FolderPath)") + Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $FolderPath -ElementId $_.id + } + } + if ([bool]$_.PSObject.Properties['remoteItem']) { + if ($FolderPath.StartsWith($PathToScan) -or $PathToScan.StartsWith($FolderPath)) { # We're traversing the target folder or we're getting into it + Write-Output("Checking shared folder $($_.remoteItem.id) - $($FolderPath)") + Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $FolderPath -ElementId $_.remoteItem.id + } + } + if ([bool]$_.PSObject.Properties['photo']) { + if ([bool]$_.photo.PSObject.Properties['livePhoto']) { + if ($CurrentPath.StartsWith($PathToScan)) { + $TargetPath = $SaveTo + '\' + $CurrentPath.Substring($PathToScan.Length) + if ( (Test-Path($TargetPath+$_.name)) -and # Target image exists + (Test-Path($TargetPath+([io.fileinfo]$_.name).basename+'.mov')) -and # Target video exists + (((Get-Item($TargetPath+$_.name)).Length + (Get-Item($TargetPath+([io.fileinfo]$_.name).basename+'.mov')).Length) -eq $_.size) # size of image and video together is onedrive's size + ) { + Write-Output "Live photo $($_.id) - $($CurrentPath + $_.name) already exists at $($TargetPath) - skipping." + } else { + Write-Output("Detected live photo $($_.id) - $($CurrentPath + $_.name). Saving image/video pair to $($TargetPath)") + Download-SingleLivePhoto -AccessToken $AccessToken -ElementId $_.id -SaveTo $TargetPath -ExpectedSize $_.size -LastModified $_.fileSystemInfo.lastModifiedDateTime + } + } + } + } + } + if ([bool]$Response.PSobject.Properties["@odata.nextLink"]) + { + write-debug("Getting more elements form service (@odata.nextLink is present)") + Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $CurrentPath -Uri $Response.'@odata.nextLink' + } +} + +function Download-SingleLivePhoto +{ + <# + .DESCRIPTION + Download single LivePhoto given it's ElementId and static data. + .PARAMETER AccessToken + Access token for OneDrive API that has ability to download live photos. + .PARAMETER ElementId + OneDrive ElementId of a LivePhoto + .PARAMETER SaveTo + Target path where to save live photos. + .PARAMETER ExpectedSize + Sum of photo and video file sizes, as reported in the containing folder + .PARAMETER LastModified + Date to set on a created file. + + .NOTES + Author: Petr Vyskocil + #> + PARAM( + [Parameter(Mandatory=$True)] + [string]$AccessToken, + [Parameter(Mandatory=$True)] + [string]$ElementId, + [Parameter(Mandatory=$True)] + [string]$SaveTo, + [Parameter(Mandatory=$True)] + [int]$ExpectedSize, + [Parameter(Mandatory=$True)] + [datetime]$LastModified + ) + + if (!(Test-Path $SaveTo)) { New-Item -ItemType Directory -Force $SaveTo | Out-Null } + + # video part + $Uri = "https://api.onedrive.com/v1.0/drive/items/$($ElementId)/content?format=video" + Write-Output("Calling OneDrive API") + Write-Output($Uri) + $TmpFile = $SaveTo+'tmp-file.mov' + $WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = "BEARER "+$AccessToken } -ErrorAction SilentlyContinue -OutFile $TmpFile -PassThru + $ActualSize = $WebRequest.RawContentLength + $FileName = ($WebRequest.Headers.'Content-Disposition'.Split('=',2)[-1]).Trim('"') + if ($FileName) { + Write-Debug("Renaming $TmpFile to $FileName") + if (Test-Path($SaveTo+$FileName)) { Remove-Item ($SaveTo+$FileName) } + Rename-Item -Path $TmpFile -NewName $FileName + (Get-Item ($SaveTo+$FileName)).LastWriteTime = $LastModified + } + + + # image part + $Uri = "https://api.onedrive.com/v1.0/drive/items/$($ElementId)/content" + Write-Output("Calling OneDrive API") + Write-Output($Uri) + $TmpFile = $SaveTo+'tmp-file.img' + $WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = "BEARER "+$AccessToken } -ErrorAction SilentlyContinue -OutFile $TmpFile -PassThru + $ActualSize = $ActualSize + $WebRequest.RawContentLength + $FileName = ($WebRequest.Headers.'Content-Disposition'.Split('=',2)[-1]).Trim('"') + if ($FileName) { + Write-Debug("Renaming $TmpFile to $FileName") + if (Test-Path($SaveTo+$FileName)) { Remove-Item ($SaveTo+$FileName) } + Rename-Item -Path $TmpFile -NewName $FileName + (Get-Item ($SaveTo+$FileName)).LastWriteTime = $LastModified + } + + if ($ActualSize -ne $ExpectedSize) { Write-Error("Error saving live photo $ElementId. Got $ActualSize bytes, expected $ExpectedSize bytes.") } + +} + + +Write-Output "Live Photo downloader - Downloads Live Photos from OneDrive camera roll as saved by OneDrive iOS app." +Write-Output "(C) 2024 Petr Vyskocil. Licensed under MIT license." +Write-Output "" + + +# This disables powershell progress indicators, speeding up Invoke-WebRequest with big results by a factor of 10 or so +$ProgressPreference = 'SilentlyContinue' + +Write-Output "Getting OneDrive Authentication token..." +$auth=Get-ODPhotosToken + +Write-Output "Downloading Live Photos..." +Write-Output $auth +Download-LivePhotosAuth -AccessToken $auth.'AccessToken-OneDrive.ReadWrite' -PathToScan $PathToScan -SaveTo $SaveTo \ No newline at end of file diff --git a/Download-ODLivePhotosV2.ps1 b/Download-ODLivePhotosV2.ps1 new file mode 100644 index 0000000..6a26a7f --- /dev/null +++ b/Download-ODLivePhotosV2.ps1 @@ -0,0 +1,348 @@ +<# +.DESCRIPTION +This script attempts to authenticate to OneDrive as client https://photos.onedrive.com +(which has permissions to download LivePhotos) and downloads all LivePhotos at a given +location within your personal OneDrive. +.PARAMETER SaveTo +Target path where to save live photos. +.PARAMETER AccessToken +OneDrive application access token. +.PARAMETER PathToScan +DOS-Style path on your OneDrive that should be scanned. Most likely '\Pictures\Camera Roll' or any other shared Camera Roll folder. + +.EXAMPLE +.\Download-ODLivePhotos2.ps1 'C:\Live Photos' + +.NOTES +Author: Petr Vyskocil + +There is no error checking, so it is recommended to re-run the command on bigger libraries. +If there are any errors during the download (OneDrive sometimes fails randomly with error "Our +services aren't available right now. We're working to restore all services as soon as possible. +Please check back soon."), next run will download the missing files and skip already downloaded +ones. +#> +param ( + [Parameter(Mandatory)] + [string] $SaveTo, + [string] $AccessToken, + [string] $PathToScan = '\Pictures\Camera Roll' +) + +function Register-WebView2Type +{ + <# + .DESCRIPTION + Check that WebView2 exists, if not obtain it from nuget. + + .EXAMPLE + Register-WebView2Type + .NOTES + Author: Petr Vyskocil + #> + $Package = "Microsoft.Web.WebView2" + $Version = "1.0.3124.44" + $BasePath = "$env:temp\ODLivePhotos\" + + Write-Host "Checking for WebView2 assemblies..." + if (!("Microsoft.Web.WebView2.WinForms.WebView2" -as [type])) { + Write-Host "WebView2 type not found, attempting to load from local cache..." + if (!(Test-Path "$($BasePath)\Microsoft.Web.WebView2.WinForms.dll")) { + Write-Host " Downloading nuget package $($Package) $($Version)" + Install-Package -Source "https://www.nuget.org/api/v2" -Name $Package -RequiredVersion $Version -Scope CurrentUser -Destination $BasePath -Force + Write-Host " Copying package files to script directory" + foreach ( $Framework in (Get-ChildItem "$BasePath\$($Package).$($Version)\lib" -Directory) ) { + Write-Host " Processing framework: $Framework" + Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\lib\$($Framework)\*.dll" -Destination $BasePath -Force + } + $Arch = (Get-CimInstance Win32_operatingsystem).OSArchitecture + Write-Host " Detected architecture: $Arch" + if ($Arch -eq "32-bit") { + Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\runtimes\win-x86\native\*.dll" -Destination $BasePath -Force + } + if ($Arch -eq "64-bit") { + Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\runtimes\win-x64\native\*.dll" -Destination $BasePath -Force + } + if ($Arch -eq "ARM 64-bit Processor") { + Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\runtimes\win-arm64\native\*.dll" -Destination $BasePath -Force + } + Remove-Item -Recurse -Force "$BasePath\$($Package).$($Version)" + } + Write-Host " Registering WebView2 type from: $BasePath\Microsoft.Web.WebView2.WinForms.dll" + Add-Type -Path "$BasePath\Microsoft.Web.WebView2.WinForms.dll" + } + Write-Host "WebView2 type registration complete" +} + +function Get-ODPhotosToken +{ + Write-Host "Initializing WebView2 component..." + $Hash = [hashtable]::Synchronized(@{}) + $Env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--enable-features=msSingleSignOnOSForPrimaryAccountIsShared" + + Write-Host "Creating WebView2 control..." + $Web = New-Object Microsoft.Web.WebView2.WinForms.WebView2 + $Web.CreationProperties = New-Object Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties + $Web.CreationProperties.UserDataFolder = "$env:temp\ODLivePhotos\" + + Write-Host "Setting up WebView2 event handlers..." + $Web.add_NavigationStarting({ + param($sender, $e) + Write-Host "Navigation starting to: $($e.Uri)" + }) + + $Web.add_NavigationCompleted({ + param($sender, $e) + Write-Host "Navigation completed to: $($e.Uri)" + if ($e.IsSuccess) { + Write-Host "Navigation successful" + } else { + Write-Host "Navigation failed with WebErrorStatus: $($e.WebErrorStatus)" + } + }) + + $Web.add_CoreWebView2InitializationCompleted({ + param($sender, $e) + if ($e.IsSuccess) { + Write-Host "CoreWebView2 initialized successfully" + Write-Host "Setting up user agent and response handlers..." + $Web.CoreWebView2.Settings.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.3124.85' + $Web.CoreWebView2.add_WebResourceResponseReceived({ + param($WebView2, $E) + if ($E.Request.Uri.StartsWith('https://my.microsoftpersonalcontent.com/_api/') -or $E.Request.Uri.StartsWith('https://api.onedrive.com/')) { + Write-Host "Detected API request: $($E.Request.Uri)" + if ($E.Request.Headers.Contains('Authorization')) { + Write-Host "Found authorization token" + $Hash.AuthToken = $E.Request.Headers.GetHeader('Authorization') + $Form.Close() + } + } + }) + } else { + Write-Host "CoreWebView2 initialization failed" + Write-Host "Please ensure Microsoft Edge WebView2 Runtime is installed" + Write-Host "Download from: https://developer.microsoft.com/en-us/microsoft-edge/webview2/" + } + }) + + Write-Host "Creating form..." + $Form = New-Object System.Windows.Forms.Form -Property @{ + Width=800 + Height=800 + Text="OneDrive Live Photo Downloader - Authentication Token capture dialog" + } -ErrorAction Stop + + Write-Host "Setting up form..." + $Web.Dock = "Fill" + $Form.Controls.Add($web) + + Write-Host "Setting initial URL..." + $Web.source = "https://onedrive.live.com/?qt=allmyphotos&photosData=%2F&sw=bypassConfig&v=photos" + + Write-Host "Showing authentication dialog..." + $Form.Add_Shown( { $form.Activate() } ) + $Form.ShowDialog() | Out-Null + + Write-Host "Dialog closed, cleaning up..." + $Web.Dispose() + + if ($Hash.AuthToken) { + Write-Host "Authentication token obtained successfully" + } else { + Write-Host "No authentication token was obtained" + } + + return $Hash.AuthToken +} + +function Download-LivePhotosAuth +{ + <# + .DESCRIPTION + Download all Live Photos from a given OneDrive path + .PARAMETER AccessToken + Access token for OneDrive API that has ability to download live photos. + .PARAMETER SaveTo + Target path where to save live photos. + .PARAMETER PathToScan + DOS-Style path on your OneDrive that should be scanned. Most likely '\Pictures\Camera Roll' or any other shared Camera Roll filder you see. + .PARAMETER CurrentPath + Internal, used for recursion + .PARAMETER ElementId + Start at folder id (probably only useful internally for recursion too) + .PARAMETER Uri + Start processing on this URI (again for recursion) + + .EXAMPLE + Download-LivePhotosAuth -AccessToken $token -SaveTo 'C:\LivePhotos' -PathToScan '\Pictures\Camera Roll' + .NOTES + Author: Petr Vyskocil + #> + PARAM( + [Parameter(Mandatory=$True)] + [string]$AccessToken, + [Parameter(Mandatory=$True)] + [string]$SaveTo, + [string]$PathToScan='\', + [string]$CurrentPath='', + [string]$ElementId='', + [string]$DriveId='', + [string]$Uri='' + ) + if (!$PathToScan.EndsWith('\')) { $PathToScan = $PathToScan + '\' } + if (!$PathToScan.StartsWith('\')) { $PathToScan = '\' + $PathToScan } + if ($Uri -eq '') { + if ($ElementId -eq '') { + $CurrentPath='\' + $Location='root' + } else { + $Location='items/' + $ElementId + } + if ($DriveId -eq '') { + $Uri = 'https://my.microsoftpersonalcontent.com/_api/v2.1/drive/' + $Location + '/children?%24filter=photo%2FlivePhoto+ne+null+or+folder+ne+null+or+remoteItem+ne+null&select=fileSystemInfo%2Cphoto%2Cid%2Cname%2Csize%2Cfolder%2CremoteItem' + } else { + $Uri = 'https://my.microsoftpersonalcontent.com/_api/v2.1/drives/' +$DriveId + '/' + $Location + '/children?%24filter=photo%2FlivePhoto+ne+null+or+folder+ne+null+or+remoteItem+ne+null&select=fileSystemInfo%2Cphoto%2Cid%2Cname%2Csize%2Cfolder%2CremoteItem' + } + } + Write-Debug("Calling OneDrive API") + Write-Debug($Uri) + $WebRequest=Invoke-WebRequest -Method 'GET' -Header @{ Authorization = $AccessToken; Prefer = "Include-Feature=AddToOneDrive"} -ErrorAction SilentlyContinue -Uri $Uri + $Response = ConvertFrom-Json $WebRequest.Content + $Response.value | % { + $FolderPath = $CurrentPath + $_.name + '\' + if ([bool]$_.PSObject.Properties['folder']) { + if ($FolderPath.StartsWith($PathToScan) -or $PathToScan.StartsWith($FolderPath)) { # We're traversing the target folder or we're getting into it + Write-Output("Checking folder $($_.id) - $($FolderPath)") + Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $FolderPath -ElementId $_.id -DriveId $DriveId + } + } + if ([bool]$_.PSObject.Properties['remoteItem']) { + if ($FolderPath.StartsWith($PathToScan) -or $PathToScan.StartsWith($FolderPath)) { # We're traversing the target folder or we're getting into it + Write-Output("Checking shared folder $($_.remoteItem.id) - $($FolderPath)") + Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $FolderPath -ElementId $_.remoteItem.id -DriveId $_.remoteItem.parentReference.driveId + } + } + if ([bool]$_.PSObject.Properties['photo']) { + if ([bool]$_.photo.PSObject.Properties['livePhoto']) { + if ($CurrentPath.StartsWith($PathToScan)) { + $TargetPath = $SaveTo + '\' + $CurrentPath.Substring($PathToScan.Length) + $VideoName = ([io.fileinfo]$_.name).basename+'.mov' + $VideoLen = $_.photo.livePhoto.totalStreamSize - $_.size + if ( (Test-Path($TargetPath+$_.name)) -and # Target image exists + (Test-Path($TargetPath+$VideoName)) -and # Target video exists + ((Get-Item($TargetPath+$_.name)).Length -eq $_.size) -and # size of image is onedrive's size + ((Get-Item($TargetPath+$VideoName)).Length -eq $VideoLen) # size of video is onedrive's size + ) { + Write-Output "Live photo $($_.id) - $($CurrentPath + $_.name) already exists at $($TargetPath) - skipping." + } else { + Write-Output("Detected live photo $($_.id) - $($CurrentPath + $_.name). Saving image/video pair to $($TargetPath)") + Download-SingleLivePhoto -AccessToken $AccessToken -ElementId $_.id -DriveId $DriveId -SaveTo $TargetPath -ImageName $_.name -VideoName $VideoName -ExpImgLen $_.size -ExpVidLen $VideoLen -LastModified $_.fileSystemInfo.lastModifiedDateTime + } + } + } + } + } + if ([bool]$Response.PSobject.Properties["@odata.nextLink"]) + { + write-debug("Getting more elements form service (@odata.nextLink is present)") + Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $CurrentPath -Uri $Response.'@odata.nextLink' + } +} + +function Download-SingleLivePhoto +{ + <# + .DESCRIPTION + Download single LivePhoto given it's ElementId and static data. + .PARAMETER AccessToken + Access token for OneDrive API that has ability to download live photos. + .PARAMETER ElementId + OneDrive ElementId of a LivePhoto + .PARAMETER DriveId + OneDrive DriveId of a LivePhoto + .PARAMETER SaveTo + Target path where to save live photos. + .PARAMETER ExpImgLen + Expected length of image file, as reported in containing folder. + .PARAMETER ExpVidLen + Expected length of video file, as reported in containing folder. + .PARAMETER LastModified + Date to set on a created file. + + .NOTES + Author: Petr Vyskocil + #> + PARAM( + [Parameter(Mandatory=$True)] + [string]$AccessToken, + [Parameter(Mandatory=$True)] + [string]$ElementId, + [string]$DriveId, + [Parameter(Mandatory=$True)] + [string]$SaveTo, + [Parameter(Mandatory=$True)] + [string]$ImageName, + [Parameter(Mandatory=$True)] + [string]$VideoName, + [Parameter(Mandatory=$True)] + [int]$ExpImgLen, + [Parameter(Mandatory=$True)] + [int]$ExpVidLen, + [Parameter(Mandatory=$True)] + [datetime]$LastModified + ) + + if (!(Test-Path $SaveTo)) { New-Item -ItemType Directory -Force $SaveTo | Out-Null } + + # video part + if ($DriveId -eq '') { + $Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drive/items/$($ElementId)/content?format=video" + } else { + $Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drives/$($DriveId)/items/$($ElementId)/content?format=video" + } + Write-Debug("Calling OneDrive API") + Write-Debug($Uri) + $FileName = $SaveTo+$VideoName + If (Test-Path($FileName)) { Remove-Item ($FileName) } + $WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = $AccessToken } -ErrorAction SilentlyContinue -OutFile $FileName -PassThru + $ActualLen = $WebRequest.RawContentLength + (Get-Item ($FileName)).LastWriteTime = $LastModified + + If ($ActualLen -ne $ExpVidLen) { Write-Error("Error saving video part of live photo $ElementId. Got $ActualLen bytes, expected $ExpVidLen bytes.") } + + # image part + if ($DriveId -eq '') { + $Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drive/items/$($ElementId)/content" + } else { + $Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drives/$($DriveId)/items/$($ElementId)/content" + } + Write-Debug("Calling OneDrive API") + Write-Debug($Uri) + $FileName = $SaveTo+$ImageName + If (Test-Path($FileName)) { Remove-Item ($FileName) } + $WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = $AccessToken } -ErrorAction SilentlyContinue -OutFile $FileName -PassThru + $ActualLen = $WebRequest.RawContentLength + (Get-Item ($FileName)).LastWriteTime = $LastModified + + if ($ActualLen -ne $ExpImgLen) { Write-Error("Error saving photo part of live photo $ElementId. Got $ActualLen bytes, expected $ExpImgLen bytes.") } + +} + + +Write-Output "Live Photo downloader - Downloads Live Photos from OneDrive camera roll as saved by OneDrive iOS app." +Write-Output "(C) 2024-2025 Petr Vyskocil. Licensed under MIT license." +Write-Output "" + + +# This disables powershell progress indicators, speeding up Invoke-WebRequest with big results by a factor of 10 or so +$ProgressPreference = 'SilentlyContinue' + +if ($AccessToken -eq '') { + Write-Output "Creating WebView2 component..." + Register-WebView2Type + Write-Output "Getting OneDrive Authentication token..." + $AccessToken = Get-ODPhotosToken +} + +Write-Output "Downloading Live Photos..." +Download-LivePhotosAuth -AccessToken $AccessToken -PathToScan $PathToScan -SaveTo $SaveTo \ No newline at end of file diff --git a/Download.ps1 b/Download.ps1 new file mode 100644 index 0000000..fbb88d0 --- /dev/null +++ b/Download.ps1 @@ -0,0 +1 @@ +.\Download-ODLivePhotosV2.ps1 -SaveTo 'D:\Photos\OneDrive-Photo\2025\04' -PathToScan '\Photos\Auto-saved\2025\04' -AccessToken 'bearer EwAIBK1DBAAUnIIyw3fu//VwydnOZRl0mzVDtSEAAZEJfUS4ycN7p2NsXfOVvL8URvOVZ/IJfhK6zremxPOoWwvBNw94siJNRuMahNWVE0nHlmojzGY5W9U1yZ46wt8LhbuHGp7flzFmctyWNiVjOSv6DFHwqxeR2MNWp/fJw7wAgOoqyvYnX0M/Fi4fn3xCwYQKEGQIPfTAA0bo0BY/s1B2sNjGxJHnoHO%2bpmJPAQkOzBroyzL0XDmacrg8Yq73vwfpoAKrldY2c8U4IRLp56n4IY5Dkm/IT7jvseV5zpWBZ7kGPT7bvQEJny3FLyXhRcn/tJWUcaygTi%2b8Ag86CNx/1HIfmY0PxgDgM/bGMTCACeSwn1IrDqXtuHxuXQ8QZgAAEBGJudb6NkqUwifou2E5lUnQAokpbHEJ4NRBDgYygdm/iue5F8RHd25td4fbhK7rMtqGG2/qDcCG3Zc/l8bq%2bpJO1i/K/r4RCeekeYV8KpFsqH2VHOPX2bkl6wFNh34OtDYlFMyj2aGqDp9Q4kRPjf9MEDxM6Upv2FNZtccjT9HV25nyPOAAPmc4JBY7bUFRYlky60yKH0gg%2br0ybqPEN0/e2voTPAZvZR6LYmb7BtFHW6dfTU7/c56NTEOvvv2ae4WDoxacQ9C4vAaCEiY36NS6i3jT5qhu/2Aj3uxCGJ0CsNNPgAZa384h/nQ4iv3gbtSGy/x5SAkK3yxxvkMOepQpU16HOSVPgE5v0pSFWwny9O/H5d6eG7jDWf%2bbyLdJUcFtUi5ROmkm5uT3LObUsqL0yZSyRRkGwGGcv%2b6QdcoAj%2bxwalHVyfebRa0iDZFDbI1VmplIGkJIoranZDQxafkH7m8f4mMIoh%2bZ7h/MX1DCXnY2COJqc9zWZCcsHk2Sx8UAtiCINKPUCiQp1VIw2CDnVDLIO866TuSdDtIqTfTeS9RkSeuGVK8sx9Ezttsbyo97fq2ocLirCXmYRVuh4JvgmFIDLwLwNv3ZgrSUjxOY/elG/QVV%2bwHHG9TfeF7d4UaYdlNIcRnx/%2bF6S1O%2bVKiWrCAFBVqVxiN8AmEl2BQ79lkKkOIngapKUgfWklEEUuYfyDkaVyqBljyXFCY6K6SsQM7FRCr9mtwy3PmP6NFKkKaZnn5DvcR6xad/wHHXNLFSfm4JzsiEtmO19m/EnUZzzuQMvl3oybqtl0Ojp/SnFH6u48PXKvFDVdXjW4I8Q9eMMqCJqkMxmbI64bmX/rPlCJbsZjro3FcjGQB1dpHIE9d%2b00816sydZ1NSc%2bp78reRAQ8P2xr0/A9tao6a23yzAp8FAWpuXhNVgdwNElBkB7rqs60lLDx/NXUkRes0sTnacz9NpeBst04g/P7As2qvSvEC' \ No newline at end of file diff --git a/download_od_livephotos.py b/download_od_livephotos.py new file mode 100644 index 0000000..7676b15 --- /dev/null +++ b/download_od_livephotos.py @@ -0,0 +1,172 @@ +import os +import requests +import webbrowser +import http.server +import socketserver +import threading +import urllib.parse +from datetime import datetime +from typing import Optional, Dict +import uuid +import tkinter as tk +from tkinter import ttk +from webview import create_window, start + +class OAuthCallbackHandler(http.server.SimpleHTTPRequestHandler): + def do_POST(self): + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length).decode('utf-8') + self.server.oauth_response = post_data + + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b"Authentication complete. You can close this window.") + + def log_message(self, format, *args): + # Suppress logging + pass + +def get_onedrive_token() -> Dict[str, str]: + """ + Authenticate with OneDrive using the Photos web client ID. + Returns authentication tokens in a dictionary. + """ + client_id = "073204aa-c1e0-4e66-a200-e5815a0aa93d" + redirect_uri = "https://photos.onedrive.com/auth/login" + scope = "OneDrive.ReadWrite,offline_access,openid,profile" + + # 创建一个简单的 Web 浏览器窗口 + import tkinter as tk + from tkinter import ttk + from webview import create_window, start + + def on_loaded(): + # 监听 cookies 变化 + cookies = webview.get_cookies() + for cookie in cookies: + if cookie['name'] == 'AccessToken-OneDrive.ReadWrite': + window.destroy() + return cookie['value'] + + auth_url = ( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" + f"?client_id={client_id}" + f"&nonce=uv.{str(uuid.uuid4())}" + "&response_mode=form_post" + f"&scope={scope}" + "&response_type=code" + f"&redirect_uri={urllib.parse.quote(redirect_uri)}" + ) + + # 创建浏览器窗口 + window = create_window('OneDrive Authentication', auth_url, + width=660, height=775) + + # 启动窗口并等待认证完成 + start(func=on_loaded) + + return window.get_cookies() + +def download_live_photos(access_token: str, save_to: str, path_to_scan: str = "/Pictures/Camera Roll"): + """ + Download all Live Photos from the specified OneDrive path. + """ + if not path_to_scan.startswith('/'): + path_to_scan = '/' + path_to_scan + if not path_to_scan.endswith('/'): + path_to_scan += '/' + + def process_folder(folder_id: str = 'root', current_path: str = '/'): + url = f"https://api.onedrive.com/v1.0/drive/items/{folder_id}/children" + params = { + "$filter": "photo/livePhoto ne null or folder ne null or remoteItem ne null", + "select": "fileSystemInfo,photo,id,name,size,folder,remoteItem" + } + headers = {"Authorization": f"Bearer {access_token}"} + + while url: + response = requests.get(url, params=params, headers=headers).json() + + for item in response.get('value', []): + folder_path = current_path + item['name'] + '/' + + # Handle folders + if 'folder' in item: + if folder_path.startswith(path_to_scan) or path_to_scan.startswith(folder_path): + print(f"Checking folder {item['id']} - {folder_path}") + process_folder(item['id'], folder_path) + + # Handle live photos + if 'photo' in item and 'livePhoto' in item['photo']: + if current_path.startswith(path_to_scan): + target_path = os.path.join(save_to, current_path[len(path_to_scan):]) + download_single_live_photo( + access_token, + item['id'], + target_path, + item['size'], + item['fileSystemInfo']['lastModifiedDateTime'] + ) + + url = response.get('@odata.nextLink') + params = {} # Clear params as nextLink includes them + + process_folder() + +def download_single_live_photo(access_token: str, element_id: str, save_to: str, + expected_size: int, last_modified: str): + """ + Download a single Live Photo (both video and image components). + """ + os.makedirs(save_to, exist_ok=True) + headers = {"Authorization": f"Bearer {access_token}"} + actual_size = 0 + + # Download video component + video_url = f"https://api.onedrive.com/v1.0/drive/items/{element_id}/content?format=video" + video_response = requests.get(video_url, headers=headers) + if video_response.ok: + filename = video_response.headers.get('Content-Disposition', '').split('filename=')[-1].strip('"') + if filename: + video_path = os.path.join(save_to, filename) + with open(video_path, 'wb') as f: + f.write(video_response.content) + actual_size += len(video_response.content) + os.utime(video_path, (datetime.fromisoformat(last_modified.replace('Z', '+00:00')).timestamp(),) * 2) + + # Download image component + image_url = f"https://api.onedrive.com/v1.0/drive/items/{element_id}/content" + image_response = requests.get(image_url, headers=headers) + if image_response.ok: + filename = image_response.headers.get('Content-Disposition', '').split('filename=')[-1].strip('"') + if filename: + image_path = os.path.join(save_to, filename) + with open(image_path, 'wb') as f: + f.write(image_response.content) + actual_size += len(image_response.content) + os.utime(image_path, (datetime.fromisoformat(last_modified.replace('Z', '+00:00')).timestamp(),) * 2) + + if actual_size != expected_size: + print(f"Warning: Size mismatch for {element_id}. Expected {expected_size}, got {actual_size}") + +def main(): + print("Live Photo downloader - Downloads Live Photos from OneDrive camera roll") + print("(C) 2024 Petr Vyskocil. Licensed under MIT license.") + print() + + save_to = input("Enter the path to save Live Photos: ") + path_to_scan = input("Enter OneDrive path to scan [/Photos/Auto-saved]: ") or "/Photos/Auto-saved" + + print("Getting OneDrive Authentication token...") + auth = get_onedrive_token() + + if 'access_token' not in auth: + print("Failed to get authentication token") + return + + print("Downloading Live Photos...") + download_live_photos(auth['access_token'], save_to, path_to_scan) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/run_ps_download.py b/run_ps_download.py new file mode 100644 index 0000000..9b3d752 --- /dev/null +++ b/run_ps_download.py @@ -0,0 +1,103 @@ +import subprocess +import sys +import argparse +from datetime import datetime, timedelta +from pathlib import Path + + + +def run_download_script(base_save_to: str, base_path_to_scan: str, target_date: datetime) -> bool: + """ + Execute the Download-ODLivePhotos.ps1 PowerShell script with year/month paths + + Args: + base_save_to: Base destination path + base_path_to_scan: Base source path in OneDrive + target_date: Date to determine year/month + """ + # Format year and month + year = target_date.strftime("%Y") + month = target_date.strftime("%m") + + # Construct full paths + save_to = str(Path(base_save_to) / year / month) + path_to_scan = str(Path(base_path_to_scan) / year / month) + + try: + # Get the script path (assuming it's in the same directory) + script_path = Path(__file__).parent / "Download-ODLivePhotosV2.ps1" + + # Construct the PowerShell command + cmd = [ + 'powershell.exe', + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', str(script_path), + '-SaveTo', save_to, + '-PathToScan', path_to_scan + ] + + print(f"Processing {year}/{month}") + print(f"Executing: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True + ) + + if result.stdout: + print("Output:") + print(result.stdout) + + return True + + except subprocess.CalledProcessError as e: + print(f"Error executing PowerShell script:", file=sys.stderr) + print(f"Exit code: {e.returncode}", file=sys.stderr) + if e.stdout: + print("Output:", file=sys.stderr) + print(e.stdout, file=sys.stderr) + if e.stderr: + print("Error:", file=sys.stderr) + print(e.stderr, file=sys.stderr) + return False + +# Process current month (and last month if today is the 1st) +# python run_ps_download.py --base-save-to "D:\Photos\OneDrive-Photo" --base-path-to-scan "\Photos\Auto-saved" + +# Process specific month (2 for last month, 3 for month before last, etc.) +# python run_ps_download.py --base-save-to "D:\Photos\OneDrive-Photo" --base-path-to-scan "\Photos\Auto-saved" --months 2 + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Execute Download-ODLivePhotos.ps1') + parser.add_argument('--base-save-to', required=True, + help='Base destination path (year/month will be appended)') + parser.add_argument('--base-path-to-scan', required=True, + help='Base source path in OneDrive (year/month will be appended)') + parser.add_argument('--months', type=int, default=0, + help='Optional: specific month to process (1: current month, 2: last month, etc.). ' + 'If not set: processes current month, plus last month if today is first day of month') + + args = parser.parse_args() + + # Get current date + now = datetime.now() + + if args.months > 0: + # Specific month was requested + months_ago = args.months - 1 + target_date = now.replace(day=1) - timedelta(days=months_ago * 28) + success = run_download_script(args.base_save_to, args.base_path_to_scan, target_date) + else: + # Default behavior + # Always process current month + success = run_download_script(args.base_save_to, args.base_path_to_scan, now) + + # If it's the first day of the month, also process last month + if now.day == 1: + last_month = now.replace(day=1) - timedelta(days=1) + success = success and run_download_script(args.base_save_to, args.base_path_to_scan, last_month) + + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/scan_lib.py b/scan_lib.py new file mode 100644 index 0000000..fa0e41c --- /dev/null +++ b/scan_lib.py @@ -0,0 +1,29 @@ +import requests +import sys + +def scan_library(): + """ + Scan library using Jellyfin API + """ + url = 'http://192.168.2.216:2283/api/libraries/8ea04568-fd60-4960-8b54-11e7508af1c4/scan' + headers = { + 'x-api-key': 'vIs1N0FLaPDCQz6CILc3fBzC7IcRgG5Tne4fyMrA' + } + + try: + response = requests.post(url, headers=headers) + response.raise_for_status() # Raise an exception for bad status codes + + print(f"Scan initiated successfully. Status code: {response.status_code}") + if response.text: + print("Response:", response.text) + + return True + + except requests.exceptions.RequestException as e: + print(f"Error scanning library: {e}", file=sys.stderr) + return False + +if __name__ == "__main__": + success = scan_library() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/scan_lib.sh b/scan_lib.sh new file mode 100644 index 0000000..44d186b --- /dev/null +++ b/scan_lib.sh @@ -0,0 +1,2 @@ +curl -L -X POST 'http://192.168.2.216:2283/api/libraries/8ea04568-fd60-4960-8b54-11e7508af1c4/scan' \ +-H 'x-api-key: vIs1N0FLaPDCQz6CILc3fBzC7IcRgG5Tne4fyMrA' \ No newline at end of file diff --git a/sync.py b/sync.py new file mode 100644 index 0000000..0fd8cb6 --- /dev/null +++ b/sync.py @@ -0,0 +1,68 @@ +import os +import shutil +from pathlib import Path +import sys +import argparse + +def sync_files(src_path: str, dest_path: str) -> None: + """ + Synchronize files from source to destination directory, skipping existing files. + + Args: + src_path: Source directory path + dest_path: Destination directory path + """ + # Convert to Path objects for easier handling + src = Path(src_path) + dest = Path(dest_path) + + # Ensure source directory exists + if not src.exists(): + raise FileNotFoundError(f"Source directory {src_path} does not exist") + + # Create destination directory if it doesn't exist + dest.mkdir(parents=True, exist_ok=True) + + # Walk through source directory + for root, _, files in os.walk(src): + # Convert current root to Path object + root_path = Path(root) + + # Calculate relative path from source + rel_path = root_path.relative_to(src) + + # Create corresponding destination directory + dest_dir = dest / rel_path + dest_dir.mkdir(parents=True, exist_ok=True) + + # Copy each file + for file in files: + src_file = root_path / file + dest_file = dest_dir / file + + # Skip if destination file exists + if dest_file.exists(): + print(f"Skipping existing file: {dest_file}") + continue + + # Copy the file + print(f"Copying: {src_file} -> {dest_file}") + shutil.copy2(src_file, dest_file) + +# Usage +# python sync.py "C:\Users\petr\OneDrive\Photos\Auto-saved\2024\11" "D:\Photos\OneDrive-Photo\2024\11" +# python sync.py /path/to/source /path/to/destination +if __name__ == "__main__": + # Set up argument parser + parser = argparse.ArgumentParser(description='Synchronize files from source to destination directory') + parser.add_argument('src', help='Source directory path') + parser.add_argument('dest', help='Destination directory path') + + # Parse arguments + args = parser.parse_args() + + try: + sync_files(args.src, args.dest) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) \ No newline at end of file diff --git a/sync.py.bak b/sync.py.bak new file mode 100644 index 0000000..a10d871 --- /dev/null +++ b/sync.py.bak @@ -0,0 +1,36 @@ +import os +import shutil +from pathlib import Path + +def sync_heic_mov(src_folder, dest_folder): + # Convert to Path objects for easier handling + src_path = Path(src_folder) + dest_path = Path(dest_folder) + + # Walk through the source directory + for root, dirs, files in os.walk(src_path): + for file in files: + if file.lower().endswith('.mov'): + # Get the relative path from the source folder + rel_path = Path(root).relative_to(src_path) + + # Construct the full paths for source and destination + src_file = Path(root) / file + dest_file = dest_path / rel_path / file + + # Create the destination directory if it doesn't exist + dest_file.parent.mkdir(parents=True, exist_ok=True) + + # Check if the destination file already exists + if not dest_file.exists(): + print(f"Copying: {src_file} -> {dest_file}") + shutil.copy2(src_file, dest_file) + else: + print(f"Skipping (already exists): {dest_file}") + +if __name__ == "__main__": + source_folder = input("Enter the source folder path: ") + destination_folder = input("Enter the destination folder path: ") + + sync_heic_mov(source_folder, destination_folder) + print("Sync completed.") \ No newline at end of file diff --git a/sync_task.py b/sync_task.py new file mode 100644 index 0000000..5a05359 --- /dev/null +++ b/sync_task.py @@ -0,0 +1,76 @@ +import os +from datetime import datetime, timedelta +import subprocess +import argparse +from pathlib import Path + +def sync_monthly(base_src: str, base_dest: str, date: datetime) -> None: + """ + Sync files for a specific year/month from base_src to base_dest + + Args: + base_src: Base source directory + base_dest: Base destination directory + date: Date to process + """ + # Format year and month + year = date.strftime("%Y") + month = date.strftime("%m") + + # Construct full paths + src_path = Path(base_src) / year / month + dest_path = Path(base_dest) / year / month + + # Skip if source doesn't exist + if not src_path.exists(): + print(f"Source path does not exist: {src_path}") + return + + print(f"Syncing {src_path} -> {dest_path}") + + # Call sync.py + # Assuming sync.py is in the same directory as this script + sync_script = Path(__file__).parent / "sync.py" + try: + subprocess.run([ + "python", + str(sync_script), + str(src_path), + str(dest_path) + ], check=True) + except subprocess.CalledProcessError as e: + print(f"Sync failed for {year}/{month}: {e}") + +# Sync OneDrive to Local(Backup) +# python sync_task.py d:\OneDrive\Photos\Auto-saved\ D:\Photos\Auto-saved --months 1 +# Sync Photo to NAS +# python sync_task.py d:\OneDrive\Photos\Auto-saved\ \\tiger-nas\Multimedia\Photos\ --months 1 +# Sync Local Motion to NAS +# python sync_task.py D:\Photos\OneDrive-Photo\ \\tiger-nas\Multimedia\Photos\ --months 1 +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Sync files based on year/month structure') + parser.add_argument('base_src', help='Base source directory') + parser.add_argument('base_dest', help='Base destination directory') + parser.add_argument('--months', type=int, default=0, + help='Optional: specific month to sync (1: current month, 2: last month, etc.). ' + 'If not set: syncs current month, plus last month if today is first day of month') + + args = parser.parse_args() + + # Get current date + now = datetime.now() + + if args.months > 0: + # Specific month was requested + months_ago = args.months - 1 + target_date = now.replace(day=1) - timedelta(days=months_ago * 28) + sync_monthly(args.base_src, args.base_dest, target_date) + else: + # Default behavior + # Always sync current month + sync_monthly(args.base_src, args.base_dest, now) + + # If it's the first day of the month, also sync last month + if now.day == 1: + last_month = now.replace(day=1) - timedelta(days=1) + sync_monthly(args.base_src, args.base_dest, last_month) \ No newline at end of file diff --git a/sync_to_nas.py b/sync_to_nas.py new file mode 100644 index 0000000..e69de29