@@ -9,7 +9,7 @@ def get_valid_input(prompt: str, valid_values: list, invalid_msg: str) -> str:
99 input_value = None
1010 input_valid = False
1111 while not input_valid :
12- input_value = input (f" { prompt } " )
12+ input_value = input (f' { prompt } ' )
1313 input_valid = input_value in valid_values
1414 if not input_valid :
1515 print (invalid_msg )
@@ -22,8 +22,8 @@ def str_range(stop: int) -> list:
2222 return [str (v ) for v in values ]
2323
2424
25- parser = argparse .ArgumentParser (" Transfer Plex user viewstate between users" )
26- parser .add_argument ("-p" , " --db-path" , help = " Path to com.plexapp.plugins.library.db file" )
25+ parser = argparse .ArgumentParser (description = ' Transfer Plex user viewstate and play history between users' )
26+ parser .add_argument ('-p' , ' --db-path' , help = ' Path to com.plexapp.plugins.library.db file' , type = str , required = True )
2727args = parser .parse_args ()
2828
2929connection = sqlite3 .connect (args .db_path )
@@ -32,113 +32,145 @@ def str_range(stop: int) -> list:
3232cursor = connection .cursor ()
3333
3434# Get users
35- cursor .execute (" SELECT id, name, created_at FROM accounts WHERE id > 0" )
35+ cursor .execute (' SELECT id, name, created_at FROM accounts WHERE id > 0' )
3636accounts = cursor .fetchall ()
3737
3838# Print users
39- print (tabulate ([{" index" : i , " name" : accounts [i ][" name" ]} for i in range (0 , len (accounts ))], headers = " keys" ))
39+ print (tabulate ([{' index' : i , ' name' : accounts [i ][' name' ]} for i in range (0 , len (accounts ))], headers = ' keys' ))
4040
4141# Ask user to select source user
4242sourceAccountIndex = int (get_valid_input (
43- " First, please enter the _source_ account index" ,
43+ ' First, please enter the _source_ account index' ,
4444 str_range (len (accounts )),
45- " No account with that ID, please enter a valid account index"
45+ ' No account with that ID, please enter a valid account index'
4646))
4747
4848# Ask user to select target user
4949targetAccountIndex = int (get_valid_input (
50- " Now, please enter the _target_ account index" ,
50+ ' Now, please enter the _target_ account index' ,
5151 str_range (len (accounts )),
52- " No account with that ID, please enter a valid account index"
52+ ' No account with that ID, please enter a valid account index'
5353))
5454
5555# Ask user to select copying/moving view state
5656mode = get_valid_input (
57- " Do you want to copy or move the viewstate? ([c]opy/[m]move)" ,
58- ["c" , " copy" , "m" , " move" ],
59- " Please select either [c]opy or [m]ove"
57+ ' Do you want to copy or move the viewstate? ([c]opy/[m]move)' ,
58+ ['c' , ' copy' , 'm' , ' move' ],
59+ ' Please select either [c]opy or [m]ove'
6060)
6161
6262# Get source account viewstate
63- sql = "SELECT * FROM metadata_item_settings WHERE account_id = :account_id"
64- cursor .execute (sql , {"account_id" : accounts [sourceAccountIndex ]["id" ]})
63+ sql = 'SELECT guid, rating, view_offset, view_count, last_viewed_at, created_at, updated_at, skip_count, ' \
64+ 'last_skipped_at, changed_at, extra_data, last_rated_at FROM metadata_item_settings ' \
65+ 'WHERE account_id = :account_id'
66+ cursor .execute (sql , {'account_id' : accounts [sourceAccountIndex ]['id' ]})
6567sourceViewstate = cursor .fetchall ()
6668
69+ # Get source account play history
70+ sql = 'SELECT guid, metadata_type, library_section_id, grandparent_title, parent_index, parent_title, ' \
71+ '"index", title, thumb_url, viewed_at, grandparent_guid, originally_available_at, device_id ' \
72+ 'FROM metadata_item_views WHERE account_id = :account_id'
73+ cursor .execute (sql , {'account_id' : accounts [sourceAccountIndex ]['id' ]})
74+ sourcePlayHistory = cursor .fetchall ()
75+
6776# Make sure source account has any viewstate items
68- if len (sourceViewstate ) == 0 :
69- sys .exit (" Source account has no viewstate items" )
77+ if len (sourceViewstate ) == 0 and len ( sourcePlayHistory ) == 0 :
78+ sys .exit (' Source account has no viewstate/play history items' )
7079
7180# Check for existing viewstate items of target user
72- sql = " SELECT id, guid FROM metadata_item_settings WHERE account_id = :account_id"
73- cursor .execute (sql , {" account_id" : accounts [targetAccountIndex ]['id' ]})
81+ sql = ' SELECT id, guid FROM metadata_item_settings WHERE account_id = :account_id'
82+ cursor .execute (sql , {' account_id' : accounts [targetAccountIndex ]['id' ]})
7483targetViewstate = cursor .fetchall ()
7584
85+ # Check for existing play history items of target user
86+ sql = 'SELECT id, guid FROM metadata_item_views WHERE account_id = :account_id'
87+ cursor .execute (sql , {'account_id' : accounts [targetAccountIndex ]['id' ]})
88+ targetPlayHistory = cursor .fetchall ()
89+
7690# Handle existing target account viewstates items if any were found
77- if len (targetViewstate ) > 0 :
78- print (f"Found { len (targetViewstate )} existing viewstate items for target account" )
91+ if len (targetViewstate ) > 0 or len (targetPlayHistory ):
92+ print (f'Found existing viewstate ({ len (targetViewstate )} items)/play history ({ len (targetPlayHistory )} items) '
93+ f'for target account' )
7994 handleExisting = get_valid_input (
80- " Do you want to add to or replace existing viewstate? ([a]dd/[r]eplace)" ,
81- ["a" , " add" , "r" , " replace" ],
82- " Please select either [a]dd/[r]eplace"
95+ ' Do you want to add to or replace existing viewstate/play history ? ([a]dd/[r]eplace)' ,
96+ ['a' , ' add' , 'r' , ' replace' ],
97+ ' Please select either [a]dd/[r]eplace'
8398 )
8499
85- if handleExisting in ["a" , " add" ]:
100+ if handleExisting in ['a' , ' add' ]:
86101 # Remove source account's viewstate for any media items for which target user has an existing viewstate
87- sourceViewstateGuids = [e ["guid" ] for e in sourceViewstate ]
88- targetViewstateGuids = [e ["guid" ] for e in targetViewstate ]
89- for guid in targetViewstateGuids :
90- # Check if guid is also present in source viewstate
91- if guid in sourceViewstateGuids :
92- # Get index of media item
93- # (indexes are the same between guid list and overall source viewstate list,
94- # so we can use the index to remove the media item's source viewstate from the copy/move list)
95- index = sourceViewstateGuids .index (guid )
96- # Remove viewstate for media item from copy/move list
97- del sourceViewstate [index ], sourceViewstateGuids [index ]
98-
99- # If we are moving the viewstate, remove viewstate items we don't need to move from source user
100- sql = "DELETE FROM metadata_item_settings WHERE account_id = :account_id AND guid = :guid"
101- cursor .execute (sql , {"account_id" : accounts [sourceAccountIndex ]["id" ], "guid" : guid })
102- elif handleExisting in ["r" , "replace" ]:
102+ sourceViewstateGuids = [e ['guid' ] for e in sourceViewstate ]
103+ viewstateGuidsToDelete = [e ['guid' ] for e in targetViewstate if e ['guid' ] in sourceViewstateGuids ]
104+ for guid in viewstateGuidsToDelete :
105+ # Get index of media item
106+ # (indexes are the same between guid list and overall source viewstate list,
107+ # so we can use the index to remove the media item's source viewstate from the copy/move list)
108+ index = sourceViewstateGuids .index (guid )
109+ # Remove viewstate for media item from copy/move list
110+ del sourceViewstate [index ], sourceViewstateGuids [index ]
111+
112+ # If we are moving the viewstate, remove viewstate items we don't need to move from source user
113+ if mode in ['m' , 'move' ]:
114+ sql = 'DELETE FROM metadata_item_settings WHERE account_id = :account_id AND guid = :guid'
115+ cursor .execute (sql , {'account_id' : accounts [sourceAccountIndex ]['id' ], 'guid' : guid })
116+
117+ # Remove source account's play history accordingly
118+ sourcePlayHistoryGuids = [e ['guid' ] for e in sourcePlayHistory ]
119+ # Play history may contain multiple entries for one guid (one user playing an item multiple items)
120+ # => make sure to get a list of unique guids
121+ playHistoryGuidsToDelete = list (set ([e ['guid' ] for e in targetPlayHistory
122+ if e ['guid' ] in sourcePlayHistoryGuids ]))
123+ for guid in playHistoryGuidsToDelete :
124+ # Delete add play history items for guid from source user list (and database, if we are moving)
125+ while guid in sourcePlayHistoryGuids :
126+ index = sourcePlayHistoryGuids .index (guid )
127+ del sourcePlayHistory [index ], sourcePlayHistoryGuids [index ]
128+ if mode in ['m' , 'move' ]:
129+ sql = 'DELETE FROM metadata_item_views WHERE account_id = :account_id AND guid = :guid'
130+ cursor .execute (sql , {'account_id' : accounts [sourceAccountIndex ]['id' ], 'guid' : guid })
131+ elif handleExisting in ['r' , 'replace' ]:
103132 # Remove existing viewstate items
104133 for entry in targetViewstate :
105- sql = "DELETE FROM metadata_item_settings WHERE id = :id"
106- cursor .execute (sql , {"id" : entry ["id" ]})
107-
108- if mode in ["c" , "copy" ]:
109- print (f"Copying viewstate ({ len (sourceViewstate )} items) "
110- f"from { accounts [sourceAccountIndex ]['name' ]} to { accounts [targetAccountIndex ]['name' ]} " )
111-
112- for entry in sourceViewstate :
113- sql = "INSERT INTO metadata_item_settings (account_id, guid, rating, view_offset, view_count, last_viewed_at, " \
114- "created_at, updated_at, skip_count, last_skipped_at, changed_at, extra_data, last_rated_at) VALUES (?, " \
115- "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
116- cursor .execute (sql , (
117- accounts [targetAccountIndex ]["id" ],
118- entry ["guid" ],
119- entry ["rating" ],
120- entry ["view_offset" ],
121- entry ["view_count" ],
122- entry ["last_viewed_at" ],
123- entry ["created_at" ],
124- entry ["updated_at" ],
125- entry ["skip_count" ],
126- entry ["last_skipped_at" ],
127- entry ["changed_at" ],
128- entry ["extra_data" ],
129- entry ["last_rated_at" ]
130- ))
134+ sql = 'DELETE FROM metadata_item_settings WHERE id = :id'
135+ cursor .execute (sql , {'id' : entry ['id' ]})
136+
137+ # Remove existing play history items
138+ for entry in targetPlayHistory :
139+ sql = 'DELETE FROM metadata_item_views WHERE id = :id'
140+ cursor .execute (sql , {'id' : entry ['id' ]})
141+
142+ if mode in ['c' , 'copy' ]:
143+ print (f'Copying viewstate ({ len (sourceViewstate )} items) and play history ({ len (sourcePlayHistory )} items) '
144+ f'from { accounts [sourceAccountIndex ]["name" ]} to { accounts [targetAccountIndex ]["name" ]} ' )
145+
146+ # Copy viewstate items
147+ sql = 'INSERT INTO metadata_item_settings (account_id, guid, rating, view_offset, view_count, last_viewed_at, ' \
148+ 'created_at, updated_at, skip_count, last_skipped_at, changed_at, extra_data, last_rated_at) VALUES (?, ' \
149+ '?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
150+ for existingEntry in sourceViewstate :
151+ newEntry = {'account_id' : accounts [targetAccountIndex ]['id' ], ** existingEntry }
152+ cursor .execute (sql , list (newEntry .values ()))
153+
154+ # Copy play history items
155+ sql = 'INSERT INTO metadata_item_views (account_id, guid, metadata_type, library_section_id, grandparent_title, ' \
156+ 'parent_index, parent_title, "index", title, thumb_url, viewed_at, grandparent_guid, ' \
157+ 'originally_available_at, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
158+ for existingEntry in sourcePlayHistory :
159+ newEntry = {'account_id' : accounts [targetAccountIndex ]['id' ], ** existingEntry }
160+ cursor .execute (sql , list (newEntry .values ()))
131161
132162 connection .commit ()
133- elif mode in ["m" , "move" ]:
134- print (f"Moving viewstate ({ len (sourceViewstate )} items) "
135- f"from { accounts [sourceAccountIndex ]['name' ]} to { accounts [targetAccountIndex ]['name' ]} " )
136-
137- sql = "UPDATE metadata_item_settings SET account_id = :target_account_id WHERE account_id = :source_account_id"
138- cursor .execute (sql , {
139- "target_account_id" : accounts [targetAccountIndex ]["id" ],
140- "source_account_id" : accounts [sourceAccountIndex ]["id" ]
141- })
163+ elif mode in ['m' , 'move' ]:
164+ print (f'Moving viewstate ({ len (sourceViewstate )} items) and play history ({ len (sourcePlayHistory )} items) '
165+ f'from { accounts [sourceAccountIndex ]["name" ]} to { accounts [targetAccountIndex ]["name" ]} ' )
166+
167+ tables = ['metadata_item_settings' , 'metadata_item_views' ]
168+ for table in tables :
169+ sql = f'UPDATE { table } SET account_id = :target_account_id WHERE account_id = :source_account_id'
170+ cursor .execute (sql , {
171+ 'target_account_id' : accounts [targetAccountIndex ]['id' ],
172+ 'source_account_id' : accounts [sourceAccountIndex ]['id' ]
173+ })
142174
143175 connection .commit ()
144176
0 commit comments