Skip to content

Commit

Permalink
Merge pull request #15 from fortysix2ahead/feature/10-add-sync-command
Browse files Browse the repository at this point in the history
Add sync command
  • Loading branch information
fortysix2ahead authored Nov 14, 2023
2 parents 508b34d + b89b43f commit 263929d
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 13 deletions.
53 changes: 53 additions & 0 deletions fsio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from logging import getLogger
from os.path import dirname
from typing import Dict, List, Tuple

from attrs import define, field
from fs.osfs import OSFS

from synophotos.photos import Album, Item

log = getLogger( __name__ )

@define
class SyncResult:

fs: OSFS = field( default=None )
additions: List[Tuple[Item, Album, str]] = field( factory=list )
updates: List[Tuple[Item, Album, str]] = field( factory=list )
skips: List[Tuple[Item, Album, str]] = field( factory=list )
removals: List[str] = field( factory=list )

def prepare_sync_albums( albums: Dict[Album, List[Item]], destination: str ) -> SyncResult:
fs = OSFS( root_path=destination, expand_vars=True, create=True )
result = SyncResult( fs = fs )

for album, item_list in albums.items():
for item in item_list:
path = f'/{album.id} - {album.name}/{item.filename}'
if not fs.exists( path ):
result.additions.append( ( item, album, path ) )
elif False:
result.updates.append( (item, album, path) ) # todo: updates seem impossible as items do not have a last_modified field
else:
result.skips.append( (item, album, path) )

# check for removals
added, skipped = [ r[2] for r in result.additions ], [ r[2] for r in result.skips ]
for f in fs.walk.files( filter=[ '*.jpg', '*.jpeg' ] ):
if f not in added and f not in skipped:
result.removals.append( f )

return result

def write_item( item: Item, contents: bytes, fs: OSFS, path: str ):
fs.makedirs( dirname( path ), recreate=True )
fs.writebytes( path, contents )
log.info( f'saved item {item.id} to {fs.getsyspath( path )}, wrote {item.filesize} bytes' )

def remove_item( fs: OSFS, path: str ):
fs.remove( path )
log.info( f'removed item from {fs.getsyspath( path )}' )

if not fs.listdir( dirname( path ) ):
fs.removedir( dirname( path ) )
40 changes: 36 additions & 4 deletions synophotos/cli.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from logging import getLogger
from sys import exit as sysexit
from typing import cast, Optional
from typing import List, cast, Optional

from click import argument, Context, group, option, pass_context, pass_obj
from fs.osfs import OSFS
from more_itertools import flatten
from rich.prompt import Confirm
from yaml import safe_dump

from fsio import prepare_sync_albums, remove_item, write_item
from synophotos import __version__
from synophotos import ApplicationContext
from synophotos.photos import Item, SynoPhotos, ThumbnailSize
from synophotos.ui import obj_table, pprint as pp, print_error, print_obj, print_obj_table, table_for
from synophotos.photos import Album, Item, SynoPhotos, ThumbnailSize
from synophotos.ui import obj_table, pprint as pp, pprint, print_error, print_obj, print_obj_table, table_for

log = getLogger( __name__ )

Expand Down Expand Up @@ -224,7 +227,7 @@ def search( ctx: ApplicationContext, name: str ):
def download( ctx: ApplicationContext, destination: str, id: int, size: ThumbnailSize ):
if size == 'original':
size = 'xl'
log.warning( 'download original size of images is currently broken, falling back to XL thumbnails' )
log.warning( 'download original images is currently not supported, falling back to XL thumbnails' )

fs = OSFS( root_path=destination, expand_vars=True, create=True )

Expand Down Expand Up @@ -252,6 +255,35 @@ def show( ctx: ApplicationContext, album_id: bool, folder_id, item_id: bool, id:
elif folder_id:
print_obj_table( synophotos.folder( id ) )


# noinspection PyShadowingNames
@cli.command( help='sync' )
# @option( '-a', '--album', required=False, is_flag=True, help='treat arguments as albums (the default)' ) # for now only sync albums
@option( '-d', '--destination', required=True, is_flag=False, help='destination folder to sync to' )
@argument( 'albums', nargs=-1, required=True )
@pass_obj
def sync( ctx: ApplicationContext, albums: List[str], destination: str ):
# get all existing items in all albums to be synced
albums = flatten( [ synophotos.albums( a, include_shared=True ) for a in albums ] )
albums = { a: [] for a in albums }
for a in albums.keys():
albums[a] = synophotos.list_album_items( a.id )

result = prepare_sync_albums( albums, destination )

if len( result.additions ) == 0 and len( result.removals ) == 0:
pprint( f'Skipping {len( result.skips )} files, nothing to do ...' )
return

msg = f'Sync will [green]add {len( result.additions )} files[/green], [red]remove {len( result.removals )} files[/red] and [yellow]skip {len( result.skips )} files[/yellow], continue?'
if not Confirm.ask( msg ):
return

for i, a, p in result.additions:
write_item( *synophotos.download( item_id=i.id, passphrase=a.passphrase, thumbnail='xl' ), result.fs, p )
for p in result.removals:
remove_item( result.fs, p )

@cli.command( hidden=True, help='displays a selected payload (this is for development only)' )
@argument( 'name', nargs=1, required=False )
@pass_obj
Expand Down
10 changes: 9 additions & 1 deletion synophotos/parameters/photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class api( Enum ):
download_shared='SYNO.FotoTeam.Download'
sharing_passphrase='SYNO.Foto.Sharing.Passphrase'
thumbnail='SYNO.Foto.Thumbnail'
thumbnail_shared='SYNO.FotoTeam.Thumbnail'

class category( Enum ):
normal='normal'
Expand All @@ -41,6 +42,7 @@ class sort_direction( Enum ):
API_DOWNLOAD_SHARED = { api.__name__: api.download_shared.value }
API_SHARING_PASSPHRASE = { api.__name__: api.sharing_passphrase.value }
API_THUMBNAIL = { api.__name__: api.thumbnail.value }
API_THUMBNAIL_SHARED = { api.__name__: api.thumbnail_shared.value }

SORT_CREATE_TIME = { sort_by.__name__: sort_by.create_time.value }
SORT_TAKENTIME = { sort_by.__name__: sort_by.takentime.value }
Expand Down Expand Up @@ -80,7 +82,12 @@ class sort_direction( Enum ):
GET_SHARED_ALBUM = API_BROWSE_ALBUM | GET4 | { 'passphrase': ..., 'additional': '["sharing_info","flex_section","provider_count","thumbnail"]' } # id is missing in favour of the passphrase!
GET_ITEM = API_BROWSE_ITEM | GET5 | {
'id': '[0]',
'additional': '["description","tag","exif","resolution","orientation","gps","video_meta","video_convert","thumbnail","address","geocoding_id","rating","motion_photo","person"]'
'additional': '["description","tag","exif","resolution","orientation","gps","video_meta","video_convert","thumbnail","address","geocoding_id","rating","motion_photo","provider_user_id","person"]'
}
GET_SHARED_ITEM = API_BROWSE_ITEM | GET5 | {
'id': '[...]',
'passphrase': ...,
'additional': '["description","tag","exif","resolution","orientation","gps","video_meta","video_convert","thumbnail","address","geocoding_id","rating","motion_photo","provider_user_id","person"]'
}

# download item
Expand All @@ -89,6 +96,7 @@ class sort_direction( Enum ):
DOWNLOAD_ITEM = API_DOWNLOAD | GET1 | { 'mode': 'download', 'id': ..., 'type': 'source' }
DOWNLOAD_SHARED_ITEM = API_DOWNLOAD_SHARED | DOWNLOAD1 | { 'unit_id': '"[...]"', 'cache_key': ... } # + cache_key="40808_1633659236" ???
DOWNLOAD_THUMBNAIL = API_THUMBNAIL | GET1 | { 'mode': 'download', 'id': ..., 'type': 'unit', 'size': 'xl', 'cache_key': ... }
DOWNLOAD_SHARED_THUMBNAIL = API_THUMBNAIL | GET2 | { 'id': ..., 'type': 'unit', 'size': 'xl', 'cache_key': ..., 'passphrase': ... }

# browse/list elements

Expand Down
40 changes: 32 additions & 8 deletions synophotos/photos.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from datetime import datetime
from logging import getLogger
from typing import List, Literal, Optional, Tuple

Expand Down Expand Up @@ -31,6 +32,7 @@ class Additional:
orientation_original: int = field( default=None )
person: List = field( default=list ) # that's of type class???
provider_count: int = field( default=None )
provider_user_id: int = field( default=None )
rating: int = field( default=None )
resolution: Dict[str, int] = field( factory=dict )
sharing_info: Dict = field( factory=dict ) # album only?
Expand All @@ -54,11 +56,21 @@ class Item:
# folder: Folder = field( init=False, default=None )
# albums: List[Album] = field( init=False, default_factory=list )

def __hash__( self ):
return self.id

@property
def modified( self ) -> datetime:
return datetime.utcfromtimestamp( self.time )

@property
def indexed( self ) -> datetime:
return datetime.utcfromtimestamp( self.indexed_time / 1000 )

@classmethod
def table_fields( cls ) -> List[str]:
return ['id', 'filename', 'filesize', 'folder_id', 'owner_user_id']


@define
class Folder:

Expand All @@ -80,6 +92,9 @@ class Folder:
# metadata for table printing -> we're doing this via classmethod
# table_fields: ClassVar[List[str]] = field( default=[ 'id', 'name' ] )

def __hash__( self ):
return self.id

@classmethod
def table_fields( cls ) -> List[str]:
return ['id', 'folder_name', 'path', 'parent', 'owner_user_id', 'shared']
Expand Down Expand Up @@ -122,6 +137,9 @@ class Album:
# for album this can be ["sharing_info","flex_section","provider_count","thumbnail"]
additional: Additional = field( factory=Additional )

def __hash__( self ):
return self.id

# additional fields
# items: [] = field( init=False, default_factory=list )

Expand Down Expand Up @@ -274,10 +292,13 @@ def create_album( self, name: str ) -> Album:
def create_folder( self, name: str, parent_id: int = 0 ) -> int:
return self.get( ENTRY_URL, {**CREATE_FOLDER, 'name': f'\"{name}\"', 'target_id': parent_id} )

def download( self, item_id: int, thumbnail: Optional[ThumbnailSize] = None ) -> Tuple[Item, bytes]:
_item, binary = self.item( item_id ), b''
def download( self, item_id: int, passphrase: str = None, thumbnail: Optional[ThumbnailSize] = None ) -> Tuple[Item, bytes]:
_item, binary = self.item( item_id, passphrase ), b''
if thumbnail:
response = self.entry( DOWNLOAD_THUMBNAIL, id=item_id, cache_key=_item.additional.thumbnail.get( 'cache_key' ) )
if passphrase:
response = self.entry( DOWNLOAD_SHARED_THUMBNAIL, id=item_id, cache_key=_item.additional.thumbnail.get( 'cache_key' ), passphrase=passphrase )
else:
response = self.entry( DOWNLOAD_THUMBNAIL, id=item_id, cache_key=_item.additional.thumbnail.get( 'cache_key' ) )
binary = response.response.content
else:
raise NotImplementedError
Expand All @@ -294,17 +315,20 @@ def album( self, id: int ) -> Album:
album = first( self.entry( GET_SHARED_ALBUM, passphrase=album.passphrase ).as_obj( List[Album] ) )
return album

def albums( self, name: str ) -> List[Album]:
return self.list_albums( name, include_shared=False )
def albums( self, name: str, include_shared=False ) -> List[Album]:
return self.list_albums( name, include_shared=include_shared )

def folder( self, id: int ) -> Folder:
return self.entry( GET_FOLDER, id=id ).as_obj( Folder )

def folders( self, name: str ) -> List[Folder]:
return self.list_folders( 0, name, True )

def item( self, id: int ) -> Optional[Item]:
return first( self.entry( GET_ITEM, id=f'[{id}]' ).as_obj( List[Item] ), None )
def item( self, id: int, passphrase: str = None ) -> Optional[Item]:
if passphrase:
return first( self.entry( GET_SHARED_ITEM, id=f'[{id}]', passphrase=passphrase ).as_obj( List[Item] ), None )
else:
return first( self.entry( GET_ITEM, id=f'[{id}]' ).as_obj( List[Item] ), None )

def root_folder( self ) -> Folder:
return self.folder( 0 )
Expand Down

0 comments on commit 263929d

Please sign in to comment.