├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENCE ├── README.md ├── bin └── sync-dl ├── icon.png ├── privacy-policy.md ├── setup.py ├── sync_dl ├── __init__.py ├── cli.py ├── commands.py ├── config │ ├── __init__.py │ ├── parsing.py │ ├── tmpdir.py │ └── ytdlParams.py ├── helpers.py ├── plManagement.py ├── tests │ ├── integrationTests.py │ ├── test.mp3 │ └── unitTests.py ├── timestamps │ ├── __init__.py │ └── scraping.py ├── ytapiInterface.py └── ytdlWrappers.py └── test.py /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: Bug 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Before Report 11 | Be sure you are using the current version of sync-dl, update using: 12 | ``` 13 | pip install sync-dl --upgrade 14 | ``` 15 | # Bug Report 16 | ## Version 17 | ``` 18 | Copy paste output of the following command here: 19 | sync-dl --version 20 | ``` 21 | ## Details 22 | Give information about the bug here, including: 23 | - Affected commands 24 | - What system you are using (Windows/specific Linux distro/Mac) 25 | - URL of playlist used (if you are comfortable with it) 26 | 27 | ## Verbose Output 28 | ``` 29 | Copy paste output of the following command here: 30 | sync-dl -v [affected_command] 31 | ``` 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Feature Request 11 | What do you want added or changed? 12 | 13 | State what commands that need to be updated, or what new commands you want added. 14 | ``` 15 | sync-dl [NEW_COMMAND] 16 | ``` 17 | 18 | Give some details on the use case your new feature. 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Before Pull Request 2 | 3 | Make sure your build passes the unit and integration tests: 4 | ``` 5 | python3 test.py [PLAYLIST_URL] 6 | ``` 7 | 8 | # Pull Request 9 | 10 | ## This pull request is for: 11 | - [ ] bug fix 12 | - [ ] new feature 13 | - [ ] improvement 14 | - [ ] other (specify below): 15 | 16 | ## Details: 17 | Be sure to include links relevant issues if applicable. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | sync_dl/tests/testPlaylists 3 | sync_dl/tests/testing.log 4 | notepad* 5 | debug.log 6 | sync_dl/tmp 7 | sync_dl/config.ini 8 | config.ini 9 | sync_dl.egg-info 10 | 11 | /build 12 | /dist 13 | sync_dl/yt_api/credentials 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 3.6 5 | - 3.8 6 | - 3.9 7 | 8 | branches: 9 | - main 10 | 11 | before_script: 12 | - sudo apt-get install ffmpeg 13 | - pip install sync_dl_ytapi 14 | - pip install -e . 15 | 16 | script: 17 | - python3 test.py https://www.youtube.com/playlist\?list=PLbg8uA1JzGJD56Lfl7aYK4iW2vJHDo0DE 18 | 19 | after_failure: 20 | - ls 21 | - ls sync_dl 22 | - ls sync_dl/tests 23 | - echo $(cat sync_dl/tests/testing.log) 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # ISSUES 2 | State your issue, what platform you are on, and, if applicable, the output of running the relevent command with the verbose flag: 3 | ``` 4 | sync-dl -v [REST_OF_COMMAND] 5 | ``` 6 | If your issue involves metadata corruption, the output of the -d and -p flags would be helpful 7 | ``` 8 | sync-dl -d [NAME_OF_PLAYLIST] 9 | ``` 10 | ``` 11 | sync-dl -p [NAME_OF_PLAYLIST] 12 | ``` 13 | # Devlopment 14 | The Hiarchy of the project is as follows (with scripts only depending on scripts beneath them in the list) 15 | 16 | 17 | ### `cli.py` 18 | -> The command line interface 19 | 20 | ### `commands.py` 21 | -> The commands run by cli, commands from here can also imported and envoked by other projects (such as the android app). 22 | 23 | ### `plManagement.py` 24 | -> Large functions used by the commands 25 | 26 | ### `helpers.py` 27 | -> small functions and wrappers 28 | 29 | ### `ytdlWrappers.py` 30 | -> everything which directly interfaces with youtube-dl 31 | 32 | ### `config.py` 33 | -> configuration and global variables 34 | 35 | ### `config.ini` 36 | -> holds user editable configuration, if this does exist a default one will be created by `config.py` 37 | 38 | ## Entry points 39 | The entry point for the code is in `__init__.py` which calls the main function of `cli.py`. on windows `__init__.py` is the console script that is added to path, whereas and on linux bin/sync-dl is used, which simply calls the main() function of `__init__.py`. 40 | 41 | `__init__.py` also holds a singleton called noInturrupt, which is used as a handler for SIGINT, it can also be used to simulate a SIGINT through code (used for canceling in the android app). 42 | 43 | ## encrypted/ 44 | This holds the encrypted oauth api key along with the obfuscated code needed to decrypt it, this is only used for the --push-order command. any modifications made to newCredentials.py will cause the decryption to fail (by design) 45 | 46 | # PULL REQUESTS 47 | Before all else be sure all changes pass the unit and integration tests 48 | ``` 49 | python3 test.py [PLAYLIST_URL] 50 | ``` 51 | 52 | Travis CI will also run these on your pull request, however its best if you also run them on your own machine. 53 | 54 | Say what the pull request is for: 55 | - bug fix 56 | - new feature 57 | - improvement 58 | 59 | And link to any relevent issues that the pull request is addressing. 60 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joshua McPherson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # sync-dl 4 |

5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | 14 | 15 | > A tool for downloading and syncing remote playlists to your computer 16 | - [INSTALLATION](#INSTALLATION) 17 | - [ABOUT](#ABOUT) 18 | - [USAGE](#USAGE) 19 | - [EXAMPLE](#EXAMPLE) 20 | - [DEVLOPMENT](#DEVLOPMENT) 21 | 22 | 23 | # INSTALLATION 24 | Requires ffmpeg or avconv, however one of these comes pre-installed on most machines. 25 | 26 | Install sync-dl via pypi using pip: 27 | ``` 28 | pip install sync-dl 29 | ``` 30 | 31 | # ABOUT 32 | Created to avoid having music deleted but still have the convenience of browsing, adding and reordering new music using youtube. 33 | 34 | The application does not store any of its metadata in songs, metadata is stored next to them in a .metadata file, the music files are managed through numbering, allowing them to be played alphanumerically using any playback service (such as VLC). 35 | 36 | 37 | # Usage 38 | ``` 39 | sync-dl [options] COMMAND [options] PLAYLIST 40 | ``` 41 | 42 | sync-dl has the several subcommands, run `sync-dl -h` to see them all and `sync-dl [COMMAND] -h` to get info on a particular one. 43 | As an example, here is the new command which creates new playlists from a youtube [URL]: 44 | 45 | ``` 46 | sync-dl new [URL] [PLAYLIST] 47 | ``` 48 | 49 | The playlist will be put it in directory [PLAYLIST], which is relative to the current working directory unless you specify your music directory using: 50 | 51 | ``` 52 | sync-dl config -l [PATH] 53 | ``` 54 | 55 | Where [PATH] is where you wish to store all your playlists in, ie) `~/Music`. 56 | 57 | 58 | ## Smart Sync: 59 | The main feature of sync-dl: 60 | ``` 61 | sync-dl sync -s PLAYLIST 62 | ``` 63 | 64 | Adds new music from remote playlist to local playlist, also takes ordering of remote playlist 65 | without deleting songs no longer available in remote playlist. 66 | 67 | Songs that are no longer available in remote, will remain after the song they are currently after 68 | in the local playlist to maintain playlist flow. 69 | 70 | 71 | ## Push Order: 72 | ``` 73 | sync-dl ytapi --push order [PLAYLIST] 74 | ``` 75 | sync-dl has a submodule which uses the youtube api the preform the reverse of Smart Sync called Push Order. sync-dl will prompt you to install the submodule if you use any of its options ie) --push-order. you must also sign in with google (so sync-dl can edit the order of your playlist). 76 | 77 | For more information see https://github.com/PrinceOfPuppers/sync-dl-ytapi 78 | 79 | ## Many More! 80 | Includes tools managing large playlists, For example `sync-dl edit --move-range [I1] [I2] [NI] [PLAYLIST]` which allows a user to move a block of songs From [I1] to [I2] to after song [N1]. 81 | 82 | Moving large blocks of songs on youtube requires dragging each song individually up/down a the page as it trys to dynamically load the hunders of songs you're scrolling past, which you would have to do every time you would want to add new music to somewhere other than the end of the playlist... (ask me how I know :^P) 83 | 84 | 85 | # EXAMPLE 86 | ``` 87 | sync-dl config -l my/local/music/folder 88 | ``` 89 | Will use my/local/music/folder to store and manipulate playlists in the future. 90 | ``` 91 | sync-dl new https://www.youtube.com/playlist?list=PL-lQgJ3eJ1HtLvM6xtxOY_1clTLov0qM3 sweetJams 92 | ``` 93 | Will create a new playlist at my/local/music/folder/sweetJams and 94 | download the playlist at the provided url to it. 95 | 96 | ``` 97 | sync-dl timestamps --scrape-range 0 4 sweetJams 98 | ``` 99 | Will scrape youtube comments for timestamps to add to songs 0 to 4 of sweetJams. Will ask you to review them before it adds them (can be changed with option -a). 100 | 101 | ``` 102 | sync-dl edit -m 1 5 sweetJams 103 | ``` 104 | Will move song number 1 in the playlist to position 5. 105 | 106 | ``` 107 | sync-dl sync -a sweetJams 108 | ``` 109 | Will check for any new songs in the remote playlist and append them to the end of sweetJams. 110 | 111 | ``` 112 | sync-dl sync -s sweetJams 113 | ``` 114 | Will use smart sync on sweetJams, downloading new songs from the remote playlist and reordering the playlist to match the order of the remote playlist without deleting any songs that are no longer available. 115 | 116 | ``` 117 | sync-dl edit --move-range 0 4 8 sweetJams 118 | ``` 119 | Will move all songs from 0 to 4 to after song 8. 120 | 121 | ``` 122 | sync-dl info -p sweetJams 123 | ``` 124 | Will give you all the urls for the songs in sweetJams. 125 | 126 | ``` 127 | sync-dl ytapi --push-order sweetJams 128 | ``` 129 | Will prompt you to install sync-dl-ytapi and sign in with google (if you havent already), after doing so it will push the local order of the playlist to youtube. 130 | 131 | ``` 132 | sync-dl ytapi --logout 133 | ``` 134 | Will remove invalidate and delete access and refresh token for the youtube api, requiring you to log in next time you use `sync-dl ytapi --pushorder`. 135 | 136 | 137 | # DEVLOPMENT 138 | To build for devlopment run: 139 | ``` 140 | git clone https://github.com/PrinceOfPuppers/sync-dl.git 141 | 142 | cd sync-dl 143 | 144 | pip install -e . 145 | ``` 146 | This will build and install sync-dl in place, allowing you to work on the code without having to reinstall after changes. 147 | 148 | 149 | ## Automated Testing 150 | ``` 151 | python test.py [options] TEST_PLAYLIST_URL 152 | ``` 153 | Will run all unit and integration tests, for the integration tests it will use the playlist TEST_PLAYLIST_URL, options are -u and -i to only run the unit/integration tests respectively. 154 | 155 | Testing requires sync-dl-ytapi to be installed aswell, and will test its helper functions. 156 | -------------------------------------------------------------------------------- /bin/sync-dl: -------------------------------------------------------------------------------- 1 | #!/url/bin/env python3 2 | 3 | if __name__ == "__main__": 4 | from sync_dl import main 5 | main() -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrinceOfPuppers/sync-dl/bb2cbf208664ac7407e18abd5e7d30779a7407a0/icon.png -------------------------------------------------------------------------------- /privacy-policy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | For information about sync-dl's privacy policy please visit: 3 | http://sync-dl.com/privacy-policy/ -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from ntpath import dirname 3 | 4 | with open('README.md', 'r') as f: 5 | longDescription = f.read() 6 | 7 | # single sourcing version number to __init__.py 8 | def getVersion(pkgDir): 9 | currentPath = dirname(__file__) 10 | initPath = f"{currentPath}/{pkgDir}/__init__.py" 11 | 12 | with open(initPath) as f: 13 | for line in f.readlines(): 14 | if line.startswith("__version__"): 15 | delim = '"' if '"' in line else "'" 16 | return line.split(delim)[1] 17 | 18 | else: 19 | raise RuntimeError("Unable to find version string.") 20 | 21 | setuptools.setup( 22 | name="sync-dl", 23 | version=getVersion("sync_dl"), 24 | author="Joshua McPherson", 25 | author_email="joshuamcpherson5@gmail.com", 26 | description="A tool for downloading and syncing remote playlists to your computer", 27 | long_description = longDescription, 28 | long_description_content_type = 'text/markdown', 29 | url="https://github.com/PrinceOfPuppers/sync-dl", 30 | packages=setuptools.find_packages(), 31 | include_package_data=True, 32 | install_requires=['yt-dlp', 'sync-dl-ytapi>=1.1.2'], 33 | classifiers=[ 34 | "Programming Language :: Python :: 3", 35 | "License :: OSI Approved :: MIT License", 36 | "Operating System :: OS Independent", 37 | "Environment :: Console", 38 | "Intended Audience :: End Users/Desktop", 39 | ], 40 | python_requires='>=3.6', 41 | scripts=["bin/sync-dl"], 42 | entry_points={ 43 | 'console_scripts': ['sync-dl = sync_dl:main'], 44 | }, 45 | ) 46 | 47 | 48 | -------------------------------------------------------------------------------- /sync_dl/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from threading import current_thread 3 | from sync_dl import config as cfg 4 | from signal import signal, SIGINT, Signals#,SIGABRT,SIGTERM 5 | 6 | __version__ = "2.3.8" 7 | 8 | class InterruptTriggered(Exception): 9 | pass 10 | 11 | class _NoInterrupt: 12 | noInterruptDepth = 0 13 | signalReceived=False 14 | 15 | def __enter__(self): 16 | if self.interruptible() and self.signalReceived: 17 | self.signalReceived = False 18 | self.interrupt() 19 | 20 | self.noInterruptDepth += 1 21 | 22 | 23 | def __exit__(self, type, value, traceback): 24 | self.noInterruptDepth -= 1 25 | self.noInterruptDepth = max(0, self.noInterruptDepth) 26 | if self.interruptible() and self.signalReceived: 27 | self.signalReceived = False 28 | self.interrupt() 29 | 30 | def interrupt(self): 31 | raise InterruptTriggered 32 | 33 | def notInterruptible(self): 34 | return self.noInterruptDepth > 0 35 | 36 | def interruptible(self): 37 | return self.noInterruptDepth == 0 38 | 39 | def handler(self,sig,frame): 40 | if current_thread().__class__.__name__ != '_MainThread': 41 | return 42 | 43 | if self.interruptible(): 44 | self.interrupt() 45 | 46 | self.signalReceived = True 47 | cfg.logger.info(f'{Signals(2).name} Received, Closing after this Operation') 48 | 49 | def simulateSigint(self): 50 | '''can be used to trigger intrrupt from another thread''' 51 | self.signalReceived = True 52 | 53 | 54 | 55 | noInterrupt = _NoInterrupt() 56 | signal(SIGINT,noInterrupt.handler) 57 | 58 | def main(): 59 | from sync_dl.cli import cli 60 | cli() 61 | -------------------------------------------------------------------------------- /sync_dl/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | 5 | import argparse 6 | import shelve 7 | 8 | 9 | from sync_dl import __version__, InterruptTriggered 10 | from sync_dl.plManagement import correctStateCorruption 11 | import sync_dl.config as cfg 12 | 13 | from sync_dl.commands import newPlaylist,smartSync,appendNew,manualAdd,swap, showPlaylist, compareMetaData, moveRange, peek, togglePrepends, addTimestampsFromComments 14 | from sync_dl.ytapiInterface import logout, pushLocalOrder, transferSongs 15 | 16 | 17 | 18 | #modified version of help formatter which only prints args once in help message 19 | class ArgsOnce(argparse.HelpFormatter): 20 | def __init__(self,prog): 21 | super().__init__(prog,max_help_position=40) 22 | 23 | def _format_action_invocation(self, action): 24 | if not action.option_strings: 25 | metavar, = self._metavar_formatter(action, action.dest)(1) 26 | return metavar 27 | else: 28 | parts = [] 29 | 30 | if action.nargs == 0: 31 | parts.extend(action.option_strings) 32 | 33 | else: 34 | default = action.dest.upper() 35 | args_string = self._format_args(action, default) 36 | for option_string in action.option_strings: 37 | parts.append('%s' % option_string) 38 | parts[-1] += ' %s'%args_string 39 | return ', '.join(parts) 40 | 41 | 42 | def setupLogger(args): 43 | '''sets cfg.logger level based on verbosity''' 44 | #verbosity 45 | stream = logging.StreamHandler(sys.stdout) 46 | if args.verbose: 47 | cfg.logger.setLevel(logging.DEBUG) 48 | stream.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) 49 | 50 | elif args.quiet: 51 | cfg.logger.setLevel(logging.ERROR) 52 | stream.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) 53 | else: 54 | cfg.logger.setLevel(logging.INFO) 55 | stream.setFormatter(logging.Formatter("%(message)s")) 56 | cfg.logger.addHandler(stream) 57 | 58 | def getCwd(): 59 | if cfg.musicDir == '': 60 | return os.getcwd() 61 | else: 62 | return cfg.musicDir 63 | 64 | 65 | def playlistExists(plPath, noError = False): 66 | '''tests if valid playlist with metadata file exists at provided path''' 67 | #everything past this point only works if the playlist exists 68 | if not os.path.exists(plPath): 69 | cfg.logger.error(f"Directory {plPath} Doesnt Exist") 70 | return False 71 | 72 | try: 73 | shelve.open(f'{plPath}/{cfg.metaDataName}', flag='r').close() 74 | except Exception as e: 75 | if noError: 76 | return False 77 | 78 | if e.args == (13, 'Permission denied') or e.__class__ == PermissionError: 79 | cfg.logger.error(f"Could Not Access Playlist at {plPath}, Permission Denined") 80 | else: 81 | cfg.logger.error(f"No Playlist Exists at {plPath}, Could not Find Metadata") 82 | return False 83 | #if not os.path.exists(f'{plPath}/{cfg.metaDataName}'): 84 | # cfg.logger.error(f"No Playlist Exists at {plPath}, Could not Find Metadata") 85 | # return False 86 | return True 87 | 88 | 89 | def getPlPath(playlist): 90 | cwd = getCwd() 91 | 92 | playlist = playlist.strip('/') 93 | if playlist == '.': 94 | return cwd 95 | 96 | return f"{cwd}/{playlist}" 97 | 98 | def getPlaylistPathsRecursive(path, plPaths): 99 | if playlistExists(path, noError = True): 100 | plPaths.append(path) 101 | return 102 | dir = os.listdir(path) 103 | for f in dir: 104 | fpath = f"{path}/{f}" 105 | if not os.path.isdir(fpath): 106 | continue 107 | getPlaylistPathsRecursive(fpath,plPaths) 108 | return 109 | 110 | def setupParsers(): 111 | description = ("A tool for downloading and syncing remote playlists to your computer. Created to avoid having\n" 112 | "music deleted but still have the convenience of browsing and adding and reordering new music using\n" 113 | "remote services such as youtube.") 114 | 115 | parser = argparse.ArgumentParser(description=description,formatter_class=ArgsOnce) 116 | parser.add_argument('-v','--verbose',action='store_true', help='runs application in verbose mode' ) 117 | parser.add_argument('-q','--quiet',action='store_true', help='runs application with no print outs' ) 118 | parser.add_argument('--version', action='version', version='%(prog)s ' + __version__) 119 | parser.set_defaults(func = lambda args: baseHandler(args, parser)) 120 | 121 | subparsers = parser.add_subparsers() 122 | timestamps = subparsers.add_parser('timestamps', help='detect, add and remove tracks from songs', formatter_class=ArgsOnce) 123 | timestamps.add_argument('-s', '--scrape', nargs=1, metavar='I', type=int, help='detect tracks in pinned/top comments for song index I') 124 | timestamps.add_argument('-r', '--scrape-range', nargs=2, metavar=('I1','I2'), type=int, help='detect tracks in pinned/top comments for song index I1 to I2') 125 | timestamps.add_argument('-a', '--auto-accept', action='store_true', help='automatically accept new timestamps') 126 | timestamps.add_argument('-o', '--overwrite', action='store_true', help='allow overwriting of existing timestamps, will be prompted') 127 | timestamps.add_argument('--auto-overwrite', action='store_true', help='allow overwriting of existing timestamps, will not be prompted to approve overwritng/accepting') 128 | 129 | timestamps.add_argument('PLAYLIST', type=str, help='the name of the directory for the playlist') 130 | timestamps.set_defaults(func = lambda args: timestampsHandler(args, timestamps)) 131 | 132 | new = subparsers.add_parser('new', help='downloads new playlist from URL with name PLAYLIST', formatter_class=ArgsOnce) 133 | new.add_argument('URL', type=str, help='playlist URL') 134 | new.add_argument('PLAYLIST', type=str, help='the name of the directory for the playlist') 135 | new.set_defaults(func = lambda args: newHandler(args, new)) 136 | 137 | sync = subparsers.add_parser("sync", help='smart sync playlist, unless options are added', formatter_class=ArgsOnce) 138 | sync.add_argument('-s','--smart-sync', action='store_true', help='smart sync local playlist with remote playlist') 139 | sync.add_argument('-a','--append-new', action='store_true', help='append new songs in remote playlist to end of local playlist') 140 | sync.add_argument('-r','--recursive', action='store_true', help='syncs all playlists in subfolders of PLAYLIST recursively (effected by config --local-dir)') 141 | sync.add_argument('PLAYLIST', type=str, help='the name of the directory for the playlist') 142 | sync.set_defaults(func = lambda args: syncHandler(args, sync)) 143 | 144 | 145 | edit = subparsers.add_parser("edit", help='change order of local playlist', formatter_class=ArgsOnce) 146 | edit.add_argument('-M','--manual-add',nargs=2, metavar=('PATH','INDEX'), type=str, help = 'manually add song at PATH to playlist in position INDEX') 147 | edit.add_argument('-m','--move',nargs=2, metavar=('I1','NI'), type = int, help='makes song index I1 come after NI (NI=-1 will move to start)') 148 | edit.add_argument('-r','--move-range',nargs=3, metavar=('I1','I2','NI'), type = int, help='makes songs in range [I1, I2] come after song index NI (NI=-1 will move to start)') 149 | edit.add_argument('-w','--swap',nargs=2, metavar=('I1','I2'), type = int, help='swaps order of songs index I1 and I2') 150 | edit.add_argument('-T','--toggle-prepends', action='store_true', help='toggles number prepends on/off') 151 | edit.add_argument('PLAYLIST', type=str, help='the name of the directory for the playlist') 152 | edit.set_defaults(func = lambda args: editHandler(args, edit)) 153 | 154 | #changing remote 155 | ytapi = subparsers.add_parser("ytapi", help='push local playlist order to youtube playlist', formatter_class=ArgsOnce) 156 | ytapi.add_argument('--logout', action='store_true', help='revokes youtube oauth access tokens and deletes them') 157 | ytapi.add_argument('--push-order', nargs=1, metavar='PLAYLIST', type=str, help='changes remote order of PLAYLIST to match local order (requires sign in with google)') 158 | ytapi.set_defaults(func = lambda args: ytapiHandler(args, ytapi)) 159 | 160 | #transfer 161 | ytapiSubParsers = ytapi.add_subparsers() 162 | transfer = ytapiSubParsers.add_parser("transfer", help="transfer songs between playlist on both local and remote", formatter_class=ArgsOnce) 163 | transfer.add_argument('-t','--transfer',nargs=2, metavar=('S1','DI'), type = int, help='makes SRC_PLAYLIST song index I1 come after DEST_PLAYLIST song index NI (NI=-1 will move to start)') 164 | transfer.add_argument('-r','--transfer-range',nargs=3, metavar=('S1','S2','DI'), type = int, help='makes SRC_PLAYLIST songs in range [I1, I2] come after DEST_PLAYLIST song index NI (NI=-1 will move to start)') 165 | transfer.add_argument('SRC_PLAYLIST', type=str, help='the name of the playlist to transfer songs from') 166 | transfer.add_argument('DEST_PLAYLIST', type=str, help='the name of the playlist to transfer songs to') 167 | transfer.set_defaults(func = lambda args: transferHandler(args, transfer)) 168 | 169 | 170 | config = subparsers.add_parser("config", help='change configuration (carries over between runs)', formatter_class=ArgsOnce) 171 | # the '\n' are used as defaults so they dont get confused with actual paths, '' will reset 172 | config.add_argument('-l','--local-dir',nargs='?',metavar='PATH',const='\n',type=str,help="sets music directory to PATH. if no PATH is provided, prints current music directory. to reset pass '' for PATH") 173 | config.add_argument('-f','--audio-format',nargs='?',metavar='FMT',const='\n',type=str,help='sets audio format to FMT (eg bestaudio, m4a, mp3, aac). if no FMT is provided, prints current FMT') 174 | config.add_argument('--list-formats',action='store_true', help='list all acceptable audio formats') 175 | config.add_argument('-t', '--toggle-timestamps', action='store_true', help='toggles automatic scraping of comments for timestamps when downloading') 176 | config.add_argument('-T', '--toggle-thumbnails', action='store_true', help='toggles embedding of thumbnails on download') 177 | config.add_argument('-s', '--show-config', action='store_true', help='shows current configuration') 178 | config.set_defaults(func= lambda args: configHandler(args, config)) 179 | 180 | #info 181 | info = subparsers.add_parser("info", help='get info about playlist', formatter_class=ArgsOnce) 182 | info.add_argument('-p','--print',action='store_true', help='shows song titles and urls for local playlist' ) 183 | info.add_argument('-d','--diff',action='store_true', help='shows differences between local and remote playlists' ) 184 | info.add_argument('--peek',nargs='?',metavar='FMT', const=cfg.defualtPeekFmt, type=str, help='prints remote PLAYLIST (url or name) without downloading, optional FMT string can contain: {id}, {url}, {title}, {duration}, {uploader}') 185 | info.add_argument('PLAYLIST',nargs='?', type=str, help='the name of the directory for the playlist') 186 | info.set_defaults(func = lambda args: infoHandler(args, info)) 187 | 188 | args = parser.parse_args() 189 | return args 190 | 191 | def baseHandler(_,parser): 192 | parser.print_help() 193 | 194 | def newHandler(args,_): 195 | plPath = getPlPath(args.PLAYLIST) 196 | if os.path.exists(plPath): 197 | cfg.logging.error(f"Cannot Make Playlist {args.PLAYLIST} Because Directory Already Exists at Path: \n{plPath}") 198 | return 199 | newPlaylist(plPath, args.URL) 200 | 201 | 202 | 203 | def syncHandler(args,parser): 204 | path = getPlPath(args.PLAYLIST) 205 | 206 | if args.recursive: 207 | plPaths = [] 208 | cfg.logger.info("Searching for Playlists...") 209 | getPlaylistPathsRecursive(path, plPaths) 210 | if len(plPaths) == 0: 211 | errMessage = f"No Playlists Found in: {path}" 212 | if cfg.musicDir: 213 | errMessage += f"\n(Note, Pathing is relative to {cfg.musicDir} use sync-dl config -l '' to change this)" 214 | cfg.logger.error(errMessage) 215 | return 216 | 217 | plPathsStr = '\n'.join(plPaths) 218 | answer = input(f"Playlists Found: \n{plPathsStr} \nContinue with Theses Playlists? (y)es/(n)o: ").lower().strip() 219 | if answer != 'y': 220 | return 221 | else: 222 | if not playlistExists(path): 223 | return 224 | plPaths = [path] 225 | 226 | 227 | for plPath in plPaths: 228 | if len(plPaths) > 1: 229 | cfg.logger.info("\n===============================================") 230 | 231 | #smart syncing 232 | if args.smart_sync: 233 | smartSync(plPath) 234 | 235 | #appending 236 | elif args.append_new: 237 | appendNew(plPath) 238 | 239 | else: 240 | parser.print_help() 241 | cfg.logger.error("Please Select an Option") 242 | break 243 | 244 | 245 | 246 | def configHandler(args,parser): 247 | if args.local_dir is not None: 248 | if args.local_dir == '\n': 249 | if cfg.musicDir=='': 250 | cfg.logger.error("Music Directory Not Set, Set With: sync-dl config -l PATH") 251 | else: 252 | cfg.logger.info(cfg.musicDir) 253 | return 254 | 255 | # reset musicDir to relative pathing 256 | if args.local_dir == '': 257 | cfg.writeToConfig('musicDir','') 258 | 259 | #set music dir to args.local_dir 260 | else: 261 | if not os.path.exists(args.local_dir): 262 | cfg.logger.error("Provided Music Directory Does not Exist") 263 | return 264 | #saves args.local_dir to config 265 | music = os.path.abspath(args.local_dir) 266 | 267 | cfg.writeToConfig('musicDir',music) 268 | 269 | if args.audio_format: 270 | if args.audio_format == '\n': 271 | cfg.logger.info(cfg.audioFormat) 272 | return 273 | fmt = args.audio_format.lower() 274 | if fmt not in cfg.knownFormats: 275 | cfg.logger.error(f"Unknown Format: {fmt}\nKnown Formats Are: {', '.join(cfg.knownFormats)}") 276 | return 277 | 278 | if fmt != 'best' and not cfg.testFfmpeg(): 279 | cfg.logger.error("ffmpeg is Required to Use Audio Format Other Than 'best'") 280 | return 281 | 282 | cfg.writeToConfig('audioFormat', fmt) 283 | cfg.setAudioFormat() 284 | cfg.logger.info(f"Audio Format Set to: {cfg.audioFormat}") 285 | 286 | if args.list_formats: 287 | cfg.logger.info(' '.join(cfg.knownFormats)) 288 | 289 | if args.toggle_thumbnails: 290 | if cfg.embedThumbnail: 291 | cfg.writeToConfig('embedThumbnail', '0') 292 | cfg.setEmbedThumbnails() 293 | else: 294 | cfg.writeToConfig('embedThumbnail', '1') 295 | cfg.setEmbedThumbnails() 296 | 297 | if cfg.embedThumbnail: 298 | cfg.logger.info("Embedding Thumbnails: ON") 299 | else: 300 | cfg.logger.info("Embedding Thumbnails: OFF") 301 | 302 | if args.toggle_timestamps: 303 | cfg.autoScrapeCommentTimestamps = not cfg.autoScrapeCommentTimestamps 304 | if cfg.autoScrapeCommentTimestamps: 305 | cfg.logger.info("Automatic Comment Timestamp Scraping: ON") 306 | else: 307 | cfg.logger.info("Automatic Comment Timestamp Scraping: OFF") 308 | 309 | cfg.writeToConfig('autoScrapeCommentTimestamps', str(int(cfg.autoScrapeCommentTimestamps))) 310 | 311 | if args.show_config: 312 | cfg.logger.info(f"(-l) (--local-dir): {cfg.musicDir}") 313 | 314 | cfg.logger.info(f"(-f) (--audio-format): {cfg.audioFormat}") 315 | 316 | if cfg.autoScrapeCommentTimestamps: 317 | cfg.logger.info("(-t) (--toggle-timestamps): ON") 318 | else: 319 | cfg.logger.info("(-t) (--toggle-timestamps): OFF") 320 | 321 | if cfg.embedThumbnail: 322 | cfg.logger.info("(-T) (--toggle-thumbnails): ON") 323 | else: 324 | cfg.logger.info("(-T) (--toggle-thumbnails): OFF") 325 | 326 | if not (args.toggle_timestamps or (args.local_dir is not None) or args.audio_format or args.list_formats or args.toggle_thumbnails or args.show_config): 327 | parser.print_help() 328 | cfg.logger.error("Please Select an Option") 329 | 330 | 331 | def editHandler(args,parser): 332 | 333 | plPath = getPlPath(args.PLAYLIST) 334 | if not playlistExists(plPath): 335 | return 336 | 337 | if args.move: 338 | moveRange(plPath,args.move[0],args.move[0],args.move[1]) 339 | 340 | elif args.move_range: 341 | moveRange(plPath,args.move_range[0],args.move_range[1],args.move_range[2]) 342 | 343 | elif args.swap: 344 | swap(plPath,args.swap[0],args.swap[1]) 345 | 346 | elif args.manual_add: 347 | if not args.manual_add[1].isdigit(): 348 | cfg.logger.error("Index must be positive Integer") 349 | else: 350 | manualAdd(plPath,args.manual_add[0],int(args.manual_add[1])) 351 | 352 | # if no options are selected, show help 353 | elif not args.toggle_prepends: 354 | parser.print_help() 355 | cfg.logger.error("Please Select an Option") 356 | 357 | if args.toggle_prepends: 358 | togglePrepends(plPath) 359 | 360 | 361 | def ytapiHandler(args,parser): 362 | if args.logout: 363 | logout() 364 | return 365 | 366 | if args.push_order: 367 | 368 | plPath = getPlPath(args.push_order[0]) 369 | if not playlistExists(plPath): 370 | return 371 | 372 | pushLocalOrder(plPath) 373 | 374 | else: 375 | parser.print_help() 376 | cfg.logger.error("Please Select an Option") 377 | 378 | def transferHandler(args, parser): 379 | if not args.SRC_PLAYLIST: 380 | cfg.logger.error("SRC_PLAYLIST required for transfer") 381 | 382 | if not args.DEST_PLAYLIST: 383 | cfg.logger.error("DEST_PLAYLIST required for transfer") 384 | 385 | srcPlPath = getPlPath(args.SRC_PLAYLIST) 386 | if not playlistExists(srcPlPath): 387 | return 388 | 389 | destPlPath = getPlPath(args.DEST_PLAYLIST) 390 | if not playlistExists(destPlPath): 391 | return 392 | 393 | if args.transfer: 394 | transferSongs(srcPlPath, destPlPath, args.transfer[0], args.transfer[0], args.transfer[1]) 395 | 396 | elif args.transfer_range: 397 | transferSongs(srcPlPath, destPlPath, args.transfer_range[0], args.transfer_range[1], args.transfer_range[2]) 398 | 399 | else: 400 | parser.print_help() 401 | cfg.logger.error("Please Select an Option") 402 | 403 | 404 | def infoHandler(args,parser): 405 | if args.peek: 406 | if not args.PLAYLIST: 407 | if args.peek != cfg.defualtPeekFmt: #removes the need to have posistional args before empty nargs='?' option 408 | url = args.peek 409 | fmt = cfg.defualtPeekFmt 410 | else: 411 | cfg.logger.error("Playlist URL Required") 412 | return 413 | else: 414 | url = args.PLAYLIST 415 | fmt = args.peek 416 | 417 | peek(url,fmt) 418 | return 419 | 420 | # if no playlist was provided all further functions cannot run 421 | if not args.PLAYLIST: 422 | cfg.logger.error("Playlist Name Required") 423 | return 424 | 425 | plPath = getPlPath(args.PLAYLIST) 426 | if not playlistExists(plPath): 427 | return 428 | 429 | #viewing playlist 430 | if args.print: 431 | showPlaylist(plPath) 432 | 433 | if args.diff: 434 | compareMetaData(plPath) 435 | 436 | if (not args.print) and (not args.diff): 437 | parser.print_help() 438 | cfg.logger.error("Please Select an Option") 439 | 440 | def timestampsHandler(args,parser): 441 | plPath = getPlPath(args.PLAYLIST) 442 | if not playlistExists(plPath): 443 | return 444 | 445 | autoAccept = args.auto_accept 446 | overwrite = args.overwrite 447 | autoOverwrite = args.auto_overwrite 448 | 449 | if autoOverwrite: 450 | autoAccept = True 451 | overwrite = True 452 | 453 | if args.scrape: 454 | addTimestampsFromComments(plPath, args.scrape[0], args.scrape[0], autoAccept=autoAccept, overwrite=overwrite, autoOverwrite=autoOverwrite) 455 | 456 | elif args.scrape_range: 457 | addTimestampsFromComments(plPath, args.scrape_range[0], args.scrape_range[1], autoAccept=autoAccept, overwrite=overwrite, autoOverwrite=autoOverwrite) 458 | 459 | else: 460 | parser.print_help() 461 | cfg.logger.error("Please Select an Option") 462 | 463 | 464 | def checkAllStateCorruption(args): 465 | 466 | plPaths = [] 467 | if 'PLAYLIST' in vars(args) and args.PLAYLIST: 468 | try: 469 | plPaths.append(getPlPath(args.PLAYLIST)) 470 | except: 471 | pass 472 | 473 | if 'SRC_PLAYLIST' in vars(args) and args.SRC_PLAYLIST: 474 | try: 475 | plPaths.append(getPlPath(args.SRC_PLAYLIST)) 476 | except: 477 | pass 478 | 479 | if 'DEST_PLAYLIST' in vars(args) and args.DEST_PLAYLIST: 480 | try: 481 | plPaths.append(getPlPath(args.DEST_PLAYLIST)) 482 | except: 483 | pass 484 | 485 | for plPath in plPaths: 486 | metaDataPath = f"{plPath}/{cfg.metaDataName}" 487 | if os.path.exists(metaDataPath): 488 | with shelve.open(metaDataPath, 'c',writeback=True) as metaData: 489 | correctStateCorruption(plPath,metaData) 490 | cfg.logger.info(f"State Recovered For Playlist: {plPath}") 491 | 492 | def cli(): 493 | ''' 494 | Runs command line application, talking in args and running commands 495 | ''' 496 | args = setupParsers() 497 | 498 | setupLogger(args) 499 | 500 | try: 501 | args.func(args) 502 | 503 | except InterruptTriggered as e: 504 | checkAllStateCorruption(args) 505 | 506 | except Exception as e: 507 | cfg.logger.exception(e) 508 | checkAllStateCorruption(args) 509 | 510 | -------------------------------------------------------------------------------- /sync_dl/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import shelve 4 | import re 5 | import ntpath 6 | import shutil 7 | 8 | from sync_dl import noInterrupt 9 | 10 | from sync_dl.ytdlWrappers import getIDs, getIdsAndTitles,getJsonPlData 11 | from sync_dl.plManagement import editPlaylist, correctStateCorruption, removePrepend 12 | from sync_dl.helpers import createNumLabel, smartSyncNewOrder, getLocalSongs, rename, relabel,download,getNumDigets, numOccurance, getNthOccuranceIndex, getOrdinalIndicator, padZeros 13 | 14 | from sync_dl.timestamps.scraping import scrapeCommentsForTimestamps 15 | from sync_dl.timestamps import createChapterFile, addTimestampsToChapterFile, applyChapterFileToSong, wipeChapterFile, addTimestampsIfNoneExist 16 | 17 | import sync_dl.config as cfg 18 | 19 | 20 | 21 | def newPlaylist(plPath,url): 22 | dirMade = False # if sync-dl cant download any songs, it will delete the directory only if it made it 23 | if not os.path.exists(plPath): 24 | dirMade = True 25 | os.makedirs(plPath) 26 | 27 | elif len(os.listdir(path=plPath))!=0: 28 | cfg.logger.error(f"Directory Exists and is Not Empty, Cannot Make Playlist in {plPath}") 29 | return 30 | 31 | 32 | cfg.logger.info(f"Creating New Playlist Named {ntpath.basename(plPath)} from URL: {url}") 33 | 34 | ids = getIDs(url) 35 | idsLen = len(ids) 36 | if idsLen == 0: 37 | cfg.logger.error(f"No Songs Found at {url}\nMake Sure the Playlist is Public!") 38 | answer = input("Proceed with Creating Empty Playlist (y)es/(n)o: ").lower().strip() 39 | if answer != 'y': 40 | os.rmdir(plPath) 41 | return 42 | 43 | numDigits = getNumDigets(idsLen) #needed for creating starting number for auto ordering ie) 001, 0152 44 | 45 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 46 | metaData["url"] = url 47 | metaData["ids"] = [] 48 | 49 | invalidSongs = 0 50 | for i,songId in enumerate(ids): 51 | 52 | counter = f'{i+1}/{idsLen}' 53 | songName = download(metaData,plPath,songId,-1,numDigits,counter) 54 | if not songName: 55 | invalidSongs+=1 56 | continue 57 | if cfg.autoScrapeCommentTimestamps: 58 | addTimestampsIfNoneExist(plPath, songName, songId) 59 | 60 | 61 | cfg.logger.info(f"Downloaded {idsLen-invalidSongs}/{idsLen} Songs") 62 | 63 | if (idsLen-invalidSongs == 0) and dirMade and idsLen!=0: 64 | cfg.logger.error(f"Unable to Download any Songs from {url}") 65 | answer = input("Proceed with Creating Empty Playlist (y)es/(n)o: ").lower().strip() 66 | if answer != 'y': 67 | shutil.rmtree(plPath) 68 | 69 | 70 | def smartSync(plPath): 71 | ''' 72 | Syncs to remote playlist however will Not delete local songs (will reorder). Songs not in remote (ie ones deleted) 73 | will be after the song they are currently after in local 74 | Example 1 75 | Local order: A B C D 76 | Remote order: A 1 B C 2 77 | 78 | Local becomes: A 1 B C D 2 79 | 80 | notice D was removed from remote but is still after C in Local 81 | 82 | Example 2 83 | Local order: A B C D 84 | Remote order: A 1 C B 2 85 | 86 | Local becomes: A 1 C D B 2 87 | 88 | notice C and B where swapped and D was deleted from Remote 89 | 90 | see test_smartSyncNewOrder in tests.py for more examples 91 | ''' 92 | cfg.logger.info(f"Smart Syncing {plPath}") 93 | 94 | 95 | 96 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 97 | correctStateCorruption(plPath,metaData) 98 | url = metaData["url"] 99 | localIds = metaData["ids"] 100 | 101 | remoteIds = getIDs(url) 102 | 103 | 104 | newOrder = smartSyncNewOrder(localIds,remoteIds) 105 | 106 | editPlaylist(plPath,newOrder) 107 | 108 | 109 | def appendNew(plPath): 110 | '''will append new songs in remote playlist to local playlist in order that they appear''' 111 | 112 | cfg.logger.info(f"Appending New Songs to {plPath}") 113 | 114 | 115 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 116 | correctStateCorruption(plPath,metaData) 117 | 118 | idsLen = len(metaData["ids"]) 119 | numDigits = getNumDigets(idsLen) 120 | 121 | remoteIds = getIDs(metaData['url']) 122 | 123 | for remoteId in remoteIds: 124 | if remoteId not in metaData['ids']: 125 | songName = download(metaData,plPath,remoteId,idsLen,numDigits) 126 | if songName and cfg.autoScrapeCommentTimestamps: 127 | addTimestampsIfNoneExist(plPath, songName, remoteId) 128 | 129 | 130 | 131 | def manualAdd(plPath, songPath, posistion): 132 | '''put song in posistion in the playlist''' 133 | 134 | if not os.path.exists(songPath): 135 | cfg.logger.error(f'{songPath} Does Not Exist') 136 | return 137 | 138 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 139 | correctStateCorruption(plPath,metaData) 140 | 141 | currentDir = getLocalSongs(plPath) 142 | 143 | idsLen = len(metaData["ids"]) 144 | numDigits = getNumDigets(idsLen) 145 | 146 | #clamp posistion 147 | if posistion > idsLen: 148 | posistion = idsLen 149 | elif posistion < 0: 150 | posistion = 0 151 | 152 | cfg.logger.info(f"Adding {ntpath.basename(songPath)} to {ntpath.basename(plPath)} in Posistion {posistion}") 153 | 154 | #shifting elements 155 | for i in reversed(range(posistion, idsLen)): 156 | oldName = currentDir[i] 157 | 158 | newName = re.sub(cfg.filePrependRE, f"{createNumLabel(i+1,numDigits)}_" , oldName) 159 | 160 | with noInterrupt: 161 | rename(metaData,cfg.logger.debug,plPath,oldName,newName,i+1,metaData["ids"][i]) 162 | 163 | metaData["ids"][i] = '' #wiped in case of crash, this blank entries can be removed restoring state 164 | 165 | 166 | newSongName = f"{createNumLabel(posistion,numDigits)}_" + ntpath.basename(songPath) 167 | 168 | with noInterrupt: 169 | os.rename(songPath,f'{plPath}/{newSongName}') 170 | 171 | if posistion >= len(metaData["ids"]): 172 | metaData["ids"].append(cfg.manualAddId) 173 | else: 174 | metaData["ids"][posistion] = cfg.manualAddId 175 | 176 | def swap(plPath, index1, index2): 177 | '''moves song to provided posistion, shifting all below it down''' 178 | if index1 == index2: 179 | cfg.logger.info(f"Given Index are the Same") 180 | 181 | 182 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 183 | correctStateCorruption(plPath,metaData) 184 | 185 | currentDir = getLocalSongs(plPath) 186 | 187 | idsLen = len(metaData["ids"]) 188 | numDigits = getNumDigets(idsLen) 189 | 190 | if index1>=idsLen or index2>=idsLen: 191 | cfg.logger.error(f"Given Index is Larger than Max {idsLen-1}") 192 | return 193 | elif index1<0 or index2<0: 194 | cfg.logger.error(f"Given Index is Negative") 195 | return 196 | 197 | cfg.logger.info(f"Swapping: \n{currentDir[index1]} \nand \n{currentDir[index2]}") 198 | #shift index1 out of the way (to idsLen) 199 | 200 | oldName = currentDir[index1] 201 | tempName = relabel(metaData,cfg.logger.debug,plPath,oldName,index1,idsLen,numDigits) 202 | 203 | 204 | #move index2 to index1's old location 205 | 206 | oldName = currentDir[index2] 207 | relabel(metaData,cfg.logger.debug,plPath,oldName,index2,index1,numDigits) 208 | 209 | #move index1 (now =idsLen) to index2's old location 210 | 211 | oldName = tempName 212 | relabel(metaData,cfg.logger.debug,plPath,oldName,idsLen,index2,numDigits) 213 | 214 | del metaData["ids"][idsLen] 215 | 216 | 217 | # move is no longer used in CLI, functionality has been merged with moveRange to be more consistant 218 | # move is still used in integration tests to reorder playlists before testing smart sync 219 | def move(plPath, currentIndex, newIndex): 220 | if currentIndex==newIndex: 221 | cfg.logger.info("Indexes Are the Same") 222 | return 223 | 224 | 225 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 226 | correctStateCorruption(plPath,metaData) 227 | 228 | currentDir = getLocalSongs(plPath) 229 | 230 | idsLen = len(metaData["ids"]) 231 | numDigits = getNumDigets(idsLen) 232 | 233 | 234 | if currentIndex>=idsLen: 235 | cfg.logger.error(f"No song has Index {currentIndex}, Largest Index is {idsLen-1}") 236 | return 237 | elif currentIndex<0: 238 | cfg.logger.error(f"No Song has a Negative Index") 239 | return 240 | 241 | #clamp newIndex 242 | if newIndex >= idsLen: 243 | newIndex = idsLen-1 244 | elif newIndex < 0: 245 | newIndex = 0 246 | 247 | cfg.logger.info(f"Moving {currentDir[currentIndex]} to Index {newIndex}") 248 | 249 | #moves song to end of list 250 | tempName = relabel(metaData,cfg.logger.debug,plPath,currentDir[currentIndex],currentIndex,idsLen,numDigits) 251 | 252 | if currentIndex>newIndex: 253 | #shifts all songs from newIndex to currentIndex-1 by +1 254 | for i in reversed(range(newIndex,currentIndex)): 255 | 256 | oldName = currentDir[i] 257 | relabel(metaData,cfg.logger.debug,plPath,oldName,i,i+1,numDigits) 258 | 259 | 260 | else: 261 | #shifts all songs from currentIndex+1 to newIndex by -1 262 | for i in range(currentIndex+1,newIndex+1): 263 | oldName = currentDir[i] 264 | relabel(metaData,cfg.logger.debug,plPath,oldName,i,i-1,numDigits) 265 | 266 | #moves song back 267 | relabel(metaData,cfg.logger.debug,plPath,tempName,idsLen,newIndex,numDigits) 268 | del metaData['ids'][idsLen] 269 | 270 | 271 | def moveRange(plPath, start, end, newStart): 272 | ''' 273 | moves block of songs from start to end indices, to after newStart 274 | ie) start = 4, end = 6, newStart = 2 275 | 0 1 2 3 4 5 6 7 -> 0 1 2 4 5 6 3 276 | ''' 277 | 278 | if start == newStart: 279 | return 280 | 281 | 282 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 283 | correctStateCorruption(plPath,metaData) 284 | 285 | currentDir = getLocalSongs(plPath) 286 | 287 | idsLen = len(metaData["ids"]) 288 | numDigits = getNumDigets(idsLen) 289 | 290 | 291 | if start>=idsLen: 292 | cfg.logger.error(f"No song has Index {start}, Largest Index is {idsLen-1}") 293 | return 294 | 295 | elif start<0: 296 | cfg.logger.error(f"No Song has a Negative Index") 297 | return 298 | 299 | #clamp end index 300 | if end>=idsLen or end == -1: 301 | end = idsLen-1 302 | 303 | elif end= idsLen: 309 | newStart = idsLen - 1 310 | elif newStart < -1: 311 | newStart = -1 312 | 313 | # Sanatization over 314 | 315 | # number of elements to move 316 | blockSize = end-start+1 317 | 318 | # make room for block 319 | for i in reversed(range(newStart+1,idsLen)): 320 | oldName = currentDir[i] 321 | newIndex =i+blockSize 322 | relabel(metaData,cfg.logger.debug,plPath,oldName,i,newIndex,numDigits) 323 | 324 | 325 | #accounts for block of songs being shifted if start>newStart 326 | offset = 0 327 | if start>newStart: 328 | currentDir = getLocalSongs(plPath) 329 | offset = blockSize 330 | 331 | # shift block into gap made 332 | for i,oldIndex in enumerate(range(start,end+1)): 333 | 334 | oldName = currentDir[oldIndex] 335 | 336 | newIndex = i + newStart+1 337 | relabel(metaData,cfg.logger.debug,plPath,oldName,oldIndex+offset,newIndex,numDigits) 338 | 339 | # remove number gap in playlist and remove blanks in metadata 340 | correctStateCorruption(plPath,metaData) 341 | 342 | # logged changes 343 | startSong = re.sub(cfg.filePrependRE,"",currentDir[start]) 344 | endSong = re.sub(cfg.filePrependRE,"",currentDir[end]) 345 | leaderSong = re.sub(cfg.filePrependRE,"",currentDir[newStart]) # the name of the song the range will come after 346 | 347 | ######## Single song moved ########## 348 | if start==end: 349 | cfg.logger.info(f"Song {start}: {startSong}") 350 | if newStart == -1: 351 | cfg.logger.info(f"Is Now First in The Playlist") 352 | else: 353 | cfg.logger.info(f"Is Now After Song {newStart}: {leaderSong}") 354 | 355 | return 356 | ##################################### 357 | 358 | 359 | ####### Multiple Songs Moved ######## 360 | cfg.logger.info(f"Moved Songs in Range [{start}, {end}] to After {newStart}") 361 | 362 | cfg.logger.info(f"Start Range: {startSong}") 363 | cfg.logger.info(f"End Range: {endSong}") 364 | 365 | if newStart != -1: 366 | leaderSong = re.sub(cfg.filePrependRE,"",currentDir[newStart]) # the name of the song the range will come after 367 | cfg.logger.info(f"Are Now After: {leaderSong}") 368 | else: 369 | cfg.logger.info(f"Are Now First in the Playlist") 370 | 371 | ###################################### 372 | 373 | 374 | 375 | # TODO not yet added to CLI (doesnt seem useful) 376 | def shuffle(plPath): 377 | '''randomizes playlist order''' 378 | from random import randint 379 | 380 | cfg.logger.info("Shuffling Playlist") 381 | 382 | 383 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 384 | correctStateCorruption(plPath,metaData) 385 | 386 | plLen = len(metaData["ids"]) 387 | ids = metaData["ids"] 388 | 389 | avalibleNums = [i for i in range(plLen)] 390 | newOrder = [] 391 | for _ in range(plLen): 392 | oldIndex = avalibleNums.pop(randint(0,len(avalibleNums)-1)) 393 | newOrder.append( (ids[oldIndex],oldIndex) ) 394 | 395 | editPlaylist(plPath, newOrder) 396 | 397 | 398 | def showPlaylist(plPath, lineBreak='', urlWithoutId = "https://www.youtube.com/watch?v="): 399 | ''' 400 | lineBreak can be set to newline if you wish to format for small screens 401 | urlWithoutId is added if you wish to print out all full urls 402 | ''' 403 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 404 | cfg.logger.info(f"Playlist URL: {metaData['url']}") 405 | 406 | correctStateCorruption(plPath,metaData) 407 | 408 | currentDir = getLocalSongs(plPath) 409 | 410 | maxNum = len(currentDir) 411 | numDigits = len(str(maxNum)) 412 | 413 | if urlWithoutId != None: 414 | spacer=' '*(len(urlWithoutId)+10) 415 | 416 | cfg.logger.info(f"{' '*numDigits}: URL{spacer}{lineBreak}-> Local Title{lineBreak}") 417 | for i,songId in enumerate(metaData['ids']): 418 | url = f"{urlWithoutId}{songId}" 419 | title = re.sub(cfg.filePrependRE, '' , currentDir[i]) 420 | cfg.logger.info(f"{str(i).zfill(numDigits)}: {url}{lineBreak} -> {title}{lineBreak}") 421 | 422 | 423 | 424 | def compareMetaData(plPath): 425 | '''Tool for comparing ids held in metadata and their order compared to remote playlist ids''' 426 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 427 | correctStateCorruption(plPath, metaData) 428 | 429 | remoteIds, remoteTitles = getIdsAndTitles(metaData["url"]) 430 | localIds = metaData["ids"] 431 | assert isinstance(localIds, list) 432 | currentDir = getLocalSongs(plPath) 433 | 434 | maxNum = max(len(localIds), len(remoteIds)) 435 | numDigits = len(str(maxNum)) 436 | 437 | 438 | cfg.logger.info(f"\n==================[Playlist Data]==================") 439 | cfg.logger.info(f"{' '*numDigits}: Local ID -> {' '*numDigits}: Remote ID : Title") 440 | 441 | inLocalNotRemote = [] 442 | inRemoteNotLocal = [(i,numOccurance(remoteIds, i),remoteId,remoteTitles[i]) for i,remoteId in enumerate(remoteIds)] 443 | 444 | for i,localId in enumerate(localIds): 445 | localOccuranceNum = numOccurance(localIds, i) 446 | 447 | title = re.sub(cfg.filePrependRE, '' , currentDir[i]) 448 | remoteIndex = getNthOccuranceIndex(remoteIds, localId, localOccuranceNum) 449 | if remoteIndex is not None: 450 | cfg.logger.info(f"{str(i).zfill(numDigits)}: {localId} -> {str(remoteIndex).zfill(numDigits)}: {localId} : {title}") 451 | inRemoteNotLocal[remoteIndex] = None 452 | 453 | else: 454 | cfg.logger.info(f"{str(i).zfill(numDigits)}: {localId} -> {' '*numDigits}: : {title}") 455 | inLocalNotRemote.append((i,localOccuranceNum,localId,title)) 456 | 457 | 458 | j=0 459 | while j {str(x).zfill(numDigits)}: {remoteId} : {title}") 466 | j+=1 467 | 468 | # summery 469 | cfg.logger.info(f"\n=====================[Summary]=====================") 470 | 471 | if len(inLocalNotRemote)>0: 472 | cfg.logger.info(f"\n------------[In Local But Not In Remote]-----------") 473 | cfg.logger.info(f"{' '*numDigits}: Local ID : Local Title") 474 | for i, localOccuranceNum, localId, title in inLocalNotRemote: 475 | occuranceText = '' if localOccuranceNum == 0 else f' : {str(localOccuranceNum+1)}{getOrdinalIndicator(localOccuranceNum+1)} Occurance' 476 | cfg.logger.info(f"{str(i).zfill(numDigits)}: {localId} : {title}{occuranceText}") 477 | 478 | if len(inRemoteNotLocal)>0: 479 | cfg.logger.info(f"\n------------[In Remote But Not In Local]-----------") 480 | cfg.logger.info(f"{' '*numDigits}: Remote ID : Remote Title") 481 | for j, remoteOccuranceNum, remoteId, title in inRemoteNotLocal: 482 | occuranceText = '' if remoteOccuranceNum== 0 else f' : {str(remoteOccuranceNum+1)}{getOrdinalIndicator(remoteOccuranceNum+1)} Occurance' 483 | cfg.logger.info(f"{str(j).zfill(numDigits)}: {remoteId} : {title}{occuranceText}") 484 | 485 | if len(inLocalNotRemote) == 0 and len(inRemoteNotLocal) == 0: 486 | cfg.logger.info(f"Local And Remote Contain The Same Songs") 487 | 488 | 489 | 490 | def peek(urlOrPlName,fmt="{index}: {url} {title}"): 491 | ''' 492 | prints out data about the playlist without downloading it, fmt parameters include: 493 | - id 494 | - url 495 | - title 496 | - duration 497 | - view_count (currently bugged in youtube-dl, will always be none) 498 | - uploader (soon to be fixed in youtube-dl) 499 | ''' 500 | 501 | # check if urlOrPlName is playlist name 502 | if not cfg.musicDir: 503 | musicPath = os.getcwd() 504 | else: 505 | musicPath = cfg.musicDir 506 | 507 | musicDir = os.listdir(path= musicPath) 508 | 509 | if urlOrPlName in musicDir: 510 | with shelve.open(f"{musicPath}/{urlOrPlName}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 511 | urlOrPlName = metaData['url'] 512 | 513 | 514 | # at this point urlOrPlName is a url 515 | plData = getJsonPlData(urlOrPlName) 516 | 517 | for i,songData in enumerate(plData): 518 | songStr = fmt.format(index = i,**songData) 519 | cfg.logger.info(songStr) 520 | 521 | 522 | def togglePrepends(plPath): 523 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 524 | if "removePrependOrder" in metaData: 525 | # prepends where removed, hence we must add them 526 | correctStateCorruption(plPath,metaData) # part of correcting state corruption is re-adding prepends 527 | cfg.logger.info("Prepends Added") 528 | return 529 | removePrepend(plPath,metaData) 530 | cfg.logger.info("Prepends Removed") 531 | 532 | 533 | def addTimestampsFromComments(plPath, start, end, autoAccept = False, overwrite = False, autoOverwrite = False): 534 | 535 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 536 | correctStateCorruption(plPath,metaData) 537 | 538 | currentDir = getLocalSongs(plPath) 539 | idsLen = len(metaData["ids"]) 540 | 541 | ### Sanitize Inputs ### 542 | if start >= idsLen: 543 | cfg.logger.error(f"No song has Index {start}, Largest Index is {idsLen-1}") 544 | return 545 | 546 | elif start < 0: 547 | cfg.logger.error(f"No Song has a Negative Index") 548 | return 549 | 550 | #clamp end index 551 | if end >= idsLen or end == -1: 552 | end = idsLen-1 553 | 554 | elif end < start: 555 | cfg.logger.error("End Index Must be Greater Than or Equal To Start Index (or -1)") 556 | return 557 | ### Sanitize Inputs Over ### 558 | 559 | 560 | multipleSongs = start!=end 561 | for i in range(start,end+1): 562 | 563 | if autoOverwrite and not overwrite: 564 | cfg.logger.error("auto-overwrite can only be enabled if overwrite is") 565 | return 566 | 567 | if autoOverwrite and not autoAccept: 568 | cfg.logger.error("auto-overwrite can only be enabled if auto-accept is") 569 | return 570 | 571 | songName = currentDir[i] 572 | songPath = f"{plPath}/{songName}" 573 | videoId = metaData['ids'][i] 574 | 575 | if not createChapterFile(songPath, songName): 576 | continue 577 | 578 | # Check for existing chapters in ffmpegMetadata file, wipe them, and prompt user to continue 579 | existingTimestamps = wipeChapterFile() 580 | if len(existingTimestamps) > 0: 581 | if not overwrite: 582 | cfg.logger.info(f"{i}: Existing Timestamps Detected on Song: {songName}\nSkipping...\n") 583 | continue 584 | 585 | # Get timestamps 586 | cfg.logger.info(f"{i}: Scraping Comments for Timestamps of Song: {songName}") 587 | timestamps = scrapeCommentsForTimestamps(videoId) 588 | 589 | if len(timestamps) == 0: 590 | cfg.logger.info(f"No Timestamps Found\n") 591 | continue 592 | 593 | if existingTimestamps == timestamps: 594 | cfg.logger.info("Timestamps Found are Idential to Existing Timestamps\n") 595 | continue 596 | 597 | if len(existingTimestamps) > 0: 598 | if not overwrite: 599 | cfg.logger.info("Comment Timestamps and Existing Timestamps Found but Overwrite is Disabled\n") 600 | continue 601 | 602 | # existing timestamps, plus timestamps found 603 | cfg.logger.info(f"\nExisting Timestamps Found:") 604 | numDigitsExistingTimestamps = len(str(len(existingTimestamps))) 605 | numDigitsCommentTimestamps = len(str(len(timestamps))) 606 | for i,timestamp in enumerate(existingTimestamps): 607 | leadChar = ' ' 608 | if timestamp not in timestamps: 609 | leadChar = '-' 610 | cfg.logger.info(f"{leadChar} {padZeros(i, numDigitsExistingTimestamps)}) {timestamp}") 611 | cfg.logger.info(f"\nComment Timestamps Found:") 612 | for i,timestamp in enumerate(timestamps): 613 | leadChar = ' ' 614 | if timestamp not in existingTimestamps: 615 | leadChar = '+' 616 | cfg.logger.info(f"{leadChar} {padZeros(i, numDigitsCommentTimestamps)}) {timestamp}") 617 | 618 | cfg.logger.info('\n') 619 | 620 | if autoOverwrite and autoAccept: 621 | cfg.logger.info("Auto Overwriting\n") 622 | 623 | else: 624 | cfg.logger.info("Existing and Comment Timestamps Found") 625 | response = (input(f"OVERWRITE Timestamps for: {songName}? \n[y]es, [n]o" + (", [a]uto-overwrite/accepts, [d]eny-overwrites" if multipleSongs else "") + ":")).lower() 626 | if response == 'a': 627 | autoAccept = True 628 | overwrite = True 629 | autoOverwrite = True 630 | elif response == 'd': 631 | overwrite = False 632 | continue 633 | elif response != 'y' and response != 'a': 634 | cfg.logger.info('\n') 635 | continue 636 | 637 | else: 638 | # comment timestamps found, no existing timestamps found 639 | cfg.logger.info(f"\nComment Timestamps Found:") 640 | for timestamp in timestamps: 641 | cfg.logger.info(timestamp) 642 | cfg.logger.info('\n') 643 | 644 | 645 | if not autoAccept: 646 | response = (input(f"\nAccept Timestamps for: {songName}? \n[y]es, [n]o" + (", [a]uto-accept" if multipleSongs else "") + ":")).lower() 647 | if response == 'a': 648 | autoAccept = True 649 | if response != 'y' and response != 'a': 650 | cfg.logger.info('\n') 651 | continue 652 | else: 653 | cfg.logger.info("Auto Accepting Timestamps") 654 | 655 | addTimestampsToChapterFile(timestamps, songPath) 656 | 657 | if not applyChapterFileToSong(songPath, songName): 658 | cfg.logger.error(f"Failed to Add Timestamps To Song {songName}\n") 659 | continue 660 | 661 | cfg.logger.info("Timestamps Applied!\n") 662 | 663 | 664 | 665 | -------------------------------------------------------------------------------- /sync_dl/config/__init__.py: -------------------------------------------------------------------------------- 1 | from re import compile 2 | from yt_dlp.postprocessor import FFmpegExtractAudioPP 3 | 4 | from sync_dl.config.tmpdir import createTmpDir, clearTmpDir, clearTmpSubPath, \ 5 | tmpDownloadPath, songSegmentsPath, thumbnailPath, songDownloadPath, ffmpegMetadataPath, songEditPath 6 | 7 | from sync_dl.config.parsing import modulePath, writeToConfig, readConfig 8 | 9 | from sync_dl.config.ytdlParams import setAudioFormat, setEmbedThumbnails, dlParams 10 | 11 | ''' 12 | contains all global variables, also parses config into global variables 13 | ''' 14 | 15 | # global config variables 16 | filePrependRE = compile(r'\d+_') 17 | plIdRe = compile(r'list=.{34}') 18 | 19 | knownFormats = (*FFmpegExtractAudioPP.SUPPORTED_EXTS, 'best') 20 | 21 | metaDataName = readConfig('metaDataName') 22 | manualAddId = readConfig('manualAddId') 23 | testPlPath = f"{modulePath}/{readConfig('testPlPath')}" 24 | testSongName = "test.mp3" 25 | testSongPath = f"{modulePath}/tests/{testSongName}" 26 | musicDir = readConfig('musicDir') 27 | autoScrapeCommentTimestamps = readConfig('autoScrapeCommentTimestamps', boolean=True) 28 | audioFormat = readConfig('audioFormat') 29 | embedThumbnail = readConfig('embedThumbnail', boolean=True) 30 | 31 | 32 | #TODO move add to ini 33 | defualtPeekFmt='{index}: {url} {title}' 34 | 35 | # logger 36 | import logging 37 | logger = logging.getLogger('sync_dl') 38 | 39 | 40 | -------------------------------------------------------------------------------- /sync_dl/config/parsing.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from ntpath import dirname 3 | import os 4 | 5 | modulePath = dirname(dirname(__file__)) 6 | 7 | _defaultConfig = { 8 | 'metaDataName' : '.metaData', 9 | 'manualAddId' : '-manualAddition', 10 | 'testPlPath' : 'tests/testPlaylists', 11 | 'musicDir' : '', 12 | 'autoScrapeCommentTimestamps': '0', 13 | 'audioFormat': 'best', 14 | 'embedThumbnail': '0' 15 | } 16 | 17 | #loading config 18 | _parser = configparser.ConfigParser(allow_no_value=True) 19 | _parser.optionxform = str 20 | 21 | 22 | def writeToConfig(key,value): 23 | global _parser 24 | global _config 25 | _parser.set('CONFIG',key,value) 26 | with open(f'{modulePath}/config.ini', 'w') as configfile: 27 | _parser.write(configfile) 28 | 29 | _config[key] = value 30 | 31 | def readConfig(key, boolean = False): 32 | global _config 33 | if boolean: 34 | return _config.getboolean(key) 35 | return _config[key] 36 | 37 | def checkConfig(): 38 | global _defaultConfig 39 | global _config 40 | cfgPath = f'{modulePath}/config.ini' 41 | 42 | if not os.path.exists(cfgPath): 43 | _parser['CONFIG'] = _defaultConfig 44 | with open(cfgPath, 'w+') as f: 45 | _parser.write(f) 46 | else: 47 | _parser.read(cfgPath) 48 | _config = _parser['CONFIG'] 49 | 50 | for key in _defaultConfig.keys(): 51 | if not key in _config.keys(): 52 | writeToConfig(key, _defaultConfig[key]) 53 | 54 | checkConfig() 55 | -------------------------------------------------------------------------------- /sync_dl/config/tmpdir.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from tempfile import TemporaryDirectory 3 | import os 4 | 5 | 6 | def _clearSubPath(path): 7 | try: 8 | if os.path.isfile(path) or os.path.islink(path): 9 | os.unlink(path) 10 | return 11 | for f in os.listdir(path=path): 12 | p = f"{path}/{f}" 13 | if os.path.isfile(p) or os.path.islink(p): 14 | os.unlink(p) 15 | elif os.path.isdir(p): 16 | shutil.rmtree(p) 17 | except OSError: 18 | pass 19 | #cfg.logger.error(f"Error Clearing Directory: {dir}") 20 | #cfg.logger.debug(f"Reason: {e}") 21 | 22 | # tmp paths 23 | _tmpDir = None 24 | tmpDownloadPath = '' #top level 25 | 26 | # dirs 27 | songSegmentsPath = '' 28 | thumbnailPath = '' 29 | songDownloadPath = '' 30 | songEditPath = '' 31 | 32 | # files 33 | ffmpegMetadataPath = '' 34 | 35 | def _createSubDirs(): 36 | os.mkdir(songSegmentsPath) 37 | os.mkdir(thumbnailPath) 38 | os.mkdir(songDownloadPath) 39 | os.mkdir(songEditPath) 40 | 41 | def createTmpDir(): 42 | global _tmpDir 43 | global tmpDownloadPath 44 | global ffmpegMetadataPath 45 | global songSegmentsPath 46 | global thumbnailPath 47 | global songDownloadPath 48 | global songEditPath 49 | _tmpDir = TemporaryDirectory() 50 | tmpDownloadPath = _tmpDir.name 51 | ffmpegMetadataPath = f'{tmpDownloadPath}/FFMETADATAFILE' 52 | songSegmentsPath = f'{tmpDownloadPath}/songSegmants' 53 | thumbnailPath = f'{tmpDownloadPath}/thumbnails' 54 | songDownloadPath = f'{tmpDownloadPath}/songDownloads' 55 | songEditPath = f'{tmpDownloadPath}/songEdit' 56 | _createSubDirs() 57 | createTmpDir() 58 | 59 | def clearTmpDir(): 60 | if not os.path.exists(tmpDownloadPath): 61 | createTmpDir() 62 | 63 | _clearSubPath(tmpDownloadPath) 64 | _createSubDirs() 65 | 66 | def clearTmpSubPath(path:str): 67 | '''clears file or directory subtree''' 68 | if not path.startswith(tmpDownloadPath): 69 | raise Exception(f"{path} is not contained in {tmpDownloadPath}") 70 | 71 | if not os.path.exists(tmpDownloadPath): 72 | createTmpDir() 73 | _clearSubPath(path) 74 | -------------------------------------------------------------------------------- /sync_dl/config/ytdlParams.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from sync_dl.config.parsing import writeToConfig, readConfig 3 | 4 | # youtube-dl dlParams, used in downloadToTmp 5 | dlParams={"quiet": True, "noplaylist": True, 'format': 'bestaudio'} 6 | 7 | 8 | _ffmpegTested = False 9 | _hasFfmpeg = False 10 | 11 | def testFfmpeg(): 12 | global _ffmpegTested 13 | global _hasFfmpeg 14 | if _ffmpegTested: 15 | return _hasFfmpeg 16 | try: 17 | subprocess.check_output(['ffmpeg', '-version']) 18 | except: 19 | _ffmpegTested = True 20 | _hasFfmpeg = False 21 | return False 22 | 23 | _ffmpegTested = True 24 | _hasFfmpeg = True 25 | return True 26 | 27 | 28 | dlParams['postprocessors'] = [] 29 | if testFfmpeg(): 30 | dlParams['postprocessors'].append( 31 | {'key': 'FFmpegMetadata'} 32 | ) 33 | 34 | def _addIfNotExists(l, key, val): 35 | for i in range(len(l)): 36 | if l[i]['key'] == key: 37 | l[i] = val 38 | return 39 | 40 | l.append(val) 41 | 42 | def _removeIfExists(l, key): 43 | for i in range(len(l)): 44 | if l[i]['key'] == key: 45 | l.pop(i) 46 | return 47 | 48 | 49 | def setAudioFormat(): 50 | global audioFormat 51 | global dlParams 52 | audioFormat = readConfig('audioFormat') 53 | 54 | if audioFormat == 'best': 55 | dlParams['format'] = f'bestaudio' 56 | if testFfmpeg(): 57 | _addIfNotExists(dlParams['postprocessors'], 'FFmpegExtractAudio', {'key': 'FFmpegExtractAudio'}) 58 | else: 59 | if not testFfmpeg(): 60 | writeToConfig('audioFormat', 'best') 61 | raise Exception("ffmpeg is Required to Use Audio Format Other Than 'best'") 62 | dlParams['format'] = f'{audioFormat}/bestaudio' 63 | _addIfNotExists(dlParams['postprocessors'], 'FFmpegExtractAudio', { 64 | 'key': 'FFmpegExtractAudio', 65 | 'preferredcodec': audioFormat, 66 | 'preferredquality': 'bestaudio', 67 | 'nopostoverwrites': True 68 | }) 69 | 70 | setAudioFormat() 71 | 72 | def setEmbedThumbnails(): 73 | global embedThumbnail 74 | embedThumbnail = readConfig('embedThumbnail', boolean=True) 75 | if embedThumbnail: 76 | dlParams['writethumbnail'] = True 77 | _addIfNotExists(dlParams['postprocessors'], 'EmbedThumbnail', { 78 | 'key': 'EmbedThumbnail', 79 | 'already_have_thumbnail': False 80 | }) 81 | else: 82 | _removeIfExists(dlParams['postprocessors'], 'EmbedThumbnail') 83 | if 'writethumbnail' in dlParams: 84 | dlParams.pop('writethumbnail') 85 | 86 | setEmbedThumbnails() 87 | -------------------------------------------------------------------------------- /sync_dl/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | from typing import Union, List 5 | 6 | from sync_dl import noInterrupt 7 | import sync_dl.config as cfg 8 | from sync_dl.ytdlWrappers import downloadToTmp,moveFromTmp 9 | 10 | _ords = ('th', 'st', 'nd', 'rd') 11 | def getOrdinalIndicator(n: int): 12 | return 'th' if ( 4 <= n%100 <= 20 ) or n%10 >= 3 else _ords[n%10] 13 | 14 | 15 | 16 | def getNumDigets(plLen): 17 | return len(str( plLen+1 )) 18 | 19 | def padZeros(s, numDigits): 20 | return str(s).zfill(numDigits) 21 | 22 | def rename(metaData, printer, plPath, oldName, newName, index, newId): 23 | with noInterrupt: 24 | printer(f"Renaming {oldName} to {newName}") 25 | os.rename(f"{plPath}/{oldName}",f"{plPath}/{newName}") 26 | 27 | if index >= len(metaData["ids"]): 28 | metaData["ids"].append(newId) 29 | else: 30 | metaData["ids"][index] = newId 31 | printer("Renaming Complete") 32 | 33 | def relabel(metaData, printer,plPath, oldName, oldIndex, newIndex, numDigets): 34 | ''' 35 | used for changing the number of an element in the playlist 36 | will blank out old posistion in the metaData 37 | 38 | note this does NOT prevent you from overwriting numbering of 39 | an existing song 40 | 41 | returns new name which is needed in some cases (ie when a song is temporarily moved) 42 | ''' 43 | 44 | newName = re.sub(cfg.filePrependRE, f"{createNumLabel(newIndex,numDigets)}_" , oldName) 45 | 46 | songId = metaData['ids'][oldIndex] 47 | with noInterrupt: 48 | printer(f"Relabeling {oldName} to {newName}") 49 | 50 | os.rename(f"{plPath}/{oldName}",f"{plPath}/{newName}") 51 | 52 | numIds = len(metaData["ids"]) 53 | if newIndex >= numIds: 54 | 55 | for _ in range(newIndex-numIds): 56 | metaData["ids"].append('') 57 | 58 | metaData["ids"].append(songId) 59 | else: 60 | metaData["ids"][newIndex] = songId 61 | 62 | metaData['ids'][oldIndex] = '' 63 | printer("Relabeling Complete") 64 | return newName 65 | 66 | def copy(srcMetaData, destMetaData, printer, srcPlPath, destPlPath, srcName, srcIndex, destIndex, numDigets): 67 | destName = re.sub(cfg.filePrependRE, f"{createNumLabel(destIndex,numDigets)}_" , srcName) 68 | songId = srcMetaData['ids'][srcIndex] 69 | numDestIds = len(destMetaData["ids"]) 70 | 71 | with noInterrupt: 72 | printer(f"Copying {srcPlPath}/{srcName} to {destPlPath}/{destName}") 73 | 74 | shutil.copy(f"{srcPlPath}/{srcName}",f"{destPlPath}/{destName}") 75 | 76 | if destIndex >= numDestIds: 77 | for _ in range(destIndex-numDestIds): 78 | destMetaData["ids"].append('') 79 | 80 | destMetaData["ids"].append(songId) 81 | else: 82 | destMetaData["ids"][destIndex] = songId 83 | 84 | printer("Copy Complete") 85 | return destName 86 | 87 | 88 | def delete(metaData, plPath, name, index): 89 | with noInterrupt: 90 | cfg.logger.debug(f"Deleting {metaData['ids'][index]} {name}") 91 | os.remove(f"{plPath}/{name}") 92 | 93 | del metaData["ids"][index] 94 | 95 | cfg.logger.debug("Deleting Complete") 96 | 97 | 98 | 99 | def download(metaData,plPath, songId, index,numDigets, counter = ''): 100 | ''' 101 | downloads song and adds it to metadata at index 102 | returns whether or not the download succeeded 103 | 104 | counter is an optional string indicating what number the download is ie) 10 or 23/149 105 | ''' 106 | if index == -1: 107 | index = len(metaData["ids"]) 108 | 109 | num = createNumLabel(index,numDigets) 110 | 111 | if counter: 112 | message = f"Dowloading song {counter}, Id {songId}" 113 | else: 114 | message = f"Dowloading song Id {songId}" 115 | 116 | cfg.logger.info(message) 117 | if downloadToTmp(songId,num): #returns true if succeeds 118 | 119 | with noInterrupt: # moving the song from tmp and editing the metadata must occur togeather 120 | songName = moveFromTmp(plPath) 121 | if index >= len(metaData["ids"]): 122 | metaData["ids"].append(songId) 123 | else: 124 | metaData["ids"][index] = songId 125 | cfg.logger.debug("Download Complete") 126 | return songName 127 | return '' 128 | 129 | 130 | def createNumLabel(n,numDigets): 131 | n = str(n) 132 | lenN = len(n) 133 | if lenN>numDigets: 134 | 135 | cfg.logger.warning(f"Number Label Too large! Expected {numDigets} but got {lenN} digets") 136 | #raise Exception(f"Number Label Too large! Expected {numDigets} but got {lenN} digets") 137 | numDigets+=1 138 | 139 | return (numDigets-lenN)*"0"+n 140 | 141 | 142 | def getSongNum(element): 143 | ''' 144 | returns the number in front of each song file 145 | ''' 146 | match = re.match(cfg.filePrependRE,element) 147 | 148 | assert match is not None 149 | return int(match.group()[:-1]) 150 | 151 | def _filterFunc(element): 152 | ''' 153 | returns false for any string not preceded by some number followed by an underscore 154 | used for filtering non song files 155 | ''' 156 | match = re.match(cfg.filePrependRE,element) 157 | if match: 158 | return True 159 | 160 | return False 161 | 162 | 163 | def getLocalSongs(plPath): 164 | ''' 165 | returns sanatized list of all songs in local playlist, in order 166 | ''' 167 | currentDir = os.listdir(path=plPath) 168 | currentDir = sorted(filter(_filterFunc,currentDir), key= getSongNum) #sorted and sanitized dir 169 | return currentDir 170 | 171 | def smartSyncNewOrder(localIds,remoteIds): 172 | ''' 173 | used by smartSync, localIds will not be mutated but remoteIds will 174 | output is newOrder, a list of tuples ( Id of song, where to find it ) 175 | the "where to find it" is the number in the old ordering (None if song is to be downloaded) 176 | ''' 177 | newOrder=[] 178 | 179 | localIdPairs = [(localIds[index],index) for index in range(len(localIds))] #contins ( Id of song, local order ) 180 | 181 | while True: 182 | if len(localIdPairs)==0: 183 | newOrder.extend( (remoteId,None) for remoteId in remoteIds ) 184 | break 185 | 186 | elif len(remoteIds)==0: 187 | newOrder.extend( localIdPairs ) 188 | break 189 | 190 | remoteId=remoteIds[0] 191 | localId,localIndex = localIdPairs[0] 192 | 193 | if localId==remoteId: 194 | #remote song is already saved locally in correct posistion 195 | newOrder.append( localIdPairs.pop(0) ) 196 | 197 | remoteId = remoteIds.pop(0) #must also remove this remote element 198 | 199 | elif localId not in remoteIds: 200 | # current local song has been removed from remote playlist, it must remain in current order 201 | newOrder.append( localIdPairs.pop(0) ) 202 | 203 | 204 | # at this point the current local song and remote song arent the same, but the current local 205 | # song still exists remotly, hence we can insert the remote song into the current posistion 206 | elif remoteId in localIds: 207 | # remote song exists in local but in wrong place 208 | 209 | index = localIds[localIndex+1:].index(remoteId)+localIndex+1 210 | j = -1 211 | for i,_ in enumerate(localIdPairs): 212 | if localIdPairs[i][1]==index: 213 | j = i 214 | break 215 | 216 | assert j != -1 217 | newOrder.append( localIdPairs.pop(j) ) 218 | remoteId = remoteIds.pop(0) #must also remove this remote element 219 | 220 | #checks if songs after the moved song are not in remote, if so they must be moved with it 221 | while j Union[int, None]: 232 | '''returns the nth occurance of item in l''' 233 | num = 0 234 | lastOccuranceIndex = None 235 | for i,item2 in enumerate(l): 236 | if item == item2: 237 | if num == n: 238 | return i 239 | lastOccuranceIndex = i 240 | 241 | num+=1 242 | 243 | return lastOccuranceIndex 244 | 245 | def numOccurance(l: list, index: int) -> int: 246 | ''' 247 | the reverse of getNthOccuranceIndex 248 | let A = l[index], and this function returns 1, that means l[index] is the 1th A in l (after the 0th) 249 | ie) l = [A, B ,A ,C, B , B] index = 2 will return 1 250 | ''' 251 | item = l[index] 252 | num = 0 253 | for i in range(0, index): 254 | if l[i] == item: 255 | num+=1 256 | 257 | return num 258 | 259 | class TransferMove: 260 | songId: str 261 | songName: str 262 | 263 | performCopy:bool = False 264 | srcCopyName: str 265 | srcCopyIndex: int 266 | destCopyIndex: int 267 | 268 | performRemoteAdd:bool = False 269 | destRemoteAddIndex: int 270 | 271 | performLocalDelete: bool = False 272 | srcLocalDeleteIndex: int 273 | srcLocalDeleteName: str 274 | 275 | performRemoteDelete:bool = False 276 | srcRemoteDeleteIndex: int 277 | 278 | def __init__(self, songId, songName): 279 | self.songId = songId 280 | self.songName = songName 281 | return 282 | 283 | def copyAction(self, srcCopyName, srcCopyIndex, destCopyIndex): 284 | self.performCopy = True 285 | self.srcCopyName = srcCopyName 286 | self.srcCopyIndex = srcCopyIndex 287 | self.destCopyIndex = destCopyIndex 288 | 289 | def remoteAddAction(self, destRemoteAddIndex): 290 | self.performRemoteAdd = True 291 | self.destRemoteAddIndex = destRemoteAddIndex 292 | 293 | assert self.performCopy 294 | 295 | def localDeleteAction(self, srcLocalDeleteIndex, srcLocalDeleteName): 296 | self.performLocalDelete = True 297 | self.srcLocalDeleteIndex = srcLocalDeleteIndex 298 | self.srcLocalDeleteName = srcLocalDeleteName 299 | 300 | assert self.performCopy 301 | 302 | assert self.srcLocalDeleteIndex == self.srcCopyIndex 303 | assert self.srcCopyName == self.srcLocalDeleteName 304 | 305 | def remoteDeleteAction(self, srcRemoteDeleteIndex): 306 | self.performRemoteDelete = True 307 | self.srcRemoteDeleteIndex = srcRemoteDeleteIndex 308 | 309 | assert self.performRemoteAdd 310 | assert self.performLocalDelete 311 | 312 | 313 | def calcuateTransferMoves(currentSrcDir: List[str], 314 | srcLocalIds: List[str], destLocalIds: List[str], 315 | srcRemoteIds: List[str], destRemoteIds: List[str], 316 | srcStart:int, srcEnd:int, destIndex:int) -> List[TransferMove]: 317 | 318 | '''calculates moves for transferSongs''' 319 | 320 | # number of elements to move 321 | blockSize = srcEnd-srcStart+1 322 | 323 | 324 | # get index to add songs in remote dest 325 | if destIndex != -1: 326 | destLocalNumOccurances = numOccurance(destLocalIds, destIndex) 327 | destRemoteIndex = getNthOccuranceIndex(destRemoteIds, destLocalIds[destIndex], destLocalNumOccurances) 328 | if destRemoteIndex == None: 329 | destRemoteIndex = 0 330 | else: 331 | destRemoteIndex += 1 332 | else: 333 | destRemoteIndex = 0 334 | 335 | 336 | # store moves 337 | songTransfers: List[TransferMove] = [] 338 | 339 | ### Calculate moves 340 | copyIndex = destIndex + blockSize 341 | for srcIndex in reversed(range(srcStart, srcEnd+1)): 342 | songId = srcLocalIds[srcIndex] 343 | srcLocalNumOccurances = numOccurance(srcLocalIds, srcIndex) 344 | srcRemoteIndex = getNthOccuranceIndex(srcRemoteIds, songId, srcLocalNumOccurances) 345 | # if srcRemoteIndex is None, or if there are more of songId in srcLocalIds, dont delete 346 | 347 | # copy song to dest local 348 | srcName = currentSrcDir[srcIndex] 349 | songName = re.sub(cfg.filePrependRE,"",srcName) 350 | data = TransferMove(songId, songName) 351 | 352 | data.copyAction(srcName, srcIndex, copyIndex) 353 | copyIndex -= 1 354 | 355 | # add song to dest remote 356 | if songId != cfg.manualAddId: 357 | data.remoteAddAction(destRemoteIndex) 358 | 359 | # delete song from src local 360 | data.localDeleteAction(srcIndex, srcName) 361 | 362 | # delete song from src remote 363 | if srcRemoteIndex is not None: 364 | data.remoteDeleteAction(srcRemoteIndex) 365 | 366 | songTransfers.append(data) 367 | 368 | return songTransfers 369 | 370 | def promptAndSanitize(promptText, *args): 371 | while True: 372 | answer = input(promptText).lower().strip() 373 | if answer not in args: 374 | cfg.logger.error(f"{answer} is not an option") 375 | continue 376 | return answer 377 | 378 | 379 | 380 | def logTransferInfo(songTransfers, srcPlName, destPlName, srcIdsLen, destIdsLen, srcRemoteIds, destRemoteIds, currentDestDir, srcStart, srcEnd, destIndex): 381 | blockSize = srcEnd-srcStart+1 382 | 383 | maxNum = max(srcIdsLen, destIdsLen, len(srcRemoteIds), len(destRemoteIds)) 384 | numIndexDigits = len(str(maxNum)) 385 | 386 | numMoveDigits = len(str(len(songTransfers))) 387 | 388 | cfg.logger.info(f"------------[Transfer Moves (applied in reverse)]-----------") 389 | cfg.logger.info(f"{'i'*numMoveDigits}: Move Parts | Song Id | Song name\n") 390 | for i,move in reversed(list(enumerate(songTransfers))): 391 | actionPrompt = f"{padZeros(i, numMoveDigits)}: " 392 | actionPrompt += f"{'LC' if move.performCopy else ' '} " 393 | actionPrompt += f"{'RA' if move.performRemoteAdd else ' '} " 394 | actionPrompt += f"{'LR' if move.performLocalDelete else ' '} " 395 | actionPrompt += f"{'RR' if move.performRemoteDelete else ' '} | " 396 | prompt = f"{move.songId} | {move.songName}\n" 397 | 398 | localPrompt = "" 399 | if move.performCopy or move.performLocalDelete: 400 | localPrompt += ' '*len(actionPrompt) + 'Local ' 401 | if move.performLocalDelete: 402 | localPrompt += f"{srcPlName}: {padZeros(move.srcLocalDeleteIndex, numIndexDigits)} " 403 | if move.performCopy: 404 | localPrompt += f"-> {destPlName}: {padZeros(move.destCopyIndex, numIndexDigits)} | " 405 | 406 | 407 | remotePrompt = "" 408 | if move.performRemoteAdd or move.performRemoteDelete: 409 | remotePrompt += "Remote " 410 | if move.performRemoteDelete: 411 | remotePrompt += f"{srcPlName}: {padZeros(move.srcRemoteDeleteIndex, numIndexDigits)} " 412 | if move.performRemoteAdd: 413 | remoteActualMoveNum = padZeros(move.destRemoteAddIndex, numIndexDigits) 414 | remoteFinalMoveNum = padZeros(blockSize + move.destRemoteAddIndex - i - 1, numIndexDigits) 415 | remotePrompt += f"-> {destPlName}: {remoteFinalMoveNum} ({remoteActualMoveNum})\n" 416 | 417 | 418 | 419 | cfg.logger.info(actionPrompt + prompt + localPrompt + remotePrompt) 420 | 421 | 422 | cfg.logger.info(f"------------[Legend]-----------") 423 | cfg.logger.info ( 424 | f"LC: Local Copy, from {srcPlName} to {destPlName}\n" \ 425 | f"RA: Remote Add to {destPlName} \n" \ 426 | f"LR: Local Remove from {srcPlName} \n" \ 427 | f"RR: Remote Remove from {srcPlName}\n" \ 428 | ) 429 | 430 | cfg.logger.info(f"------------[Summery]-----------") 431 | cfg.logger.info(f"{srcPlName}: [{srcStart}, {srcEnd}] -> {destPlName}: [{srcStart + destIndex}, {srcEnd + destIndex}]\n") 432 | 433 | cfg.logger.info(f"Transfering Songs in Range [{srcStart}, {srcEnd}] in {srcPlName}") 434 | cfg.logger.info(f" Start Range: {songTransfers[-1].songName}") 435 | cfg.logger.info(f" End Range: {songTransfers[0].songName}") 436 | 437 | if destIndex != -1: 438 | leaderSong = re.sub(cfg.filePrependRE,"", currentDestDir[destIndex]) # the name of the song the range will come after 439 | cfg.logger.info(f"\nTo After song Index {destIndex} in {destPlName}") 440 | cfg.logger.info(f" {destIndex}: {leaderSong}") 441 | else: 442 | cfg.logger.info(f"\nTo Start of {destPlName}") 443 | 444 | 445 | 446 | cfg.logger.info(f"\n------------[Note]-----------") 447 | cfg.logger.info(f"For Best Remote Playlists Results, Ensure Local and Remote Playlists are Synced") 448 | cfg.logger.info(f"(failing to do so may lead to ordering changes in remote if there are duplicate songs)") 449 | 450 | 451 | -------------------------------------------------------------------------------- /sync_dl/plManagement.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shelve 4 | 5 | from sync_dl import noInterrupt 6 | from sync_dl.helpers import createNumLabel, getLocalSongs,download, delete, relabel, getNumDigets, getSongNum 7 | from sync_dl.timestamps import addTimestampsIfNoneExist 8 | import sync_dl.config as cfg 9 | 10 | def _checkDeletions(plPath,metaData): 11 | ''' 12 | checks if metadata has songs that are no longer in directory 13 | ''' 14 | currentDir = getLocalSongs(plPath) 15 | idsLen = len(metaData['ids']) 16 | 17 | # there have been no deletions, however there may be a gap in numberings 18 | # which would be falsely detected as a deletion if this check wheren't here 19 | if idsLen == len(currentDir): 20 | return 21 | 22 | #song numbers in currentDir 23 | currentDirNums = [int(re.match(cfg.filePrependRE,song).group()[:-1]) for song in currentDir] 24 | 25 | numRange = range(idsLen) 26 | 27 | #difference between whats in the folder and whats in metadata 28 | deleted = [i for i in numRange if i not in currentDirNums] 29 | numDeleted = len(deleted) 30 | 31 | if numDeleted > 0: 32 | cfg.logger.debug(f"songs numbered {deleted} are no longer in playlist") 33 | 34 | numDidgets = len(str( len(metaData["ids"]) - numDeleted )) 35 | newIndex = 0 36 | 37 | for newIndex, oldIndex in enumerate(currentDirNums): 38 | if newIndex != oldIndex: 39 | oldName = currentDir[newIndex] 40 | newName = re.sub(cfg.filePrependRE, f"{createNumLabel(newIndex,numDidgets)}_" , oldName) 41 | 42 | with noInterrupt: 43 | cfg.logger.debug(f"Renaming {oldName} to {newName}") 44 | os.rename(f"{plPath}/{oldName}",f"{plPath}/{newName}") 45 | 46 | if newIndex in deleted: 47 | # we only remove the deleted entry from metadata when its posistion has been filled 48 | cfg.logger.debug(f"Removing {metaData['ids'][newIndex]} from metadata") 49 | 50 | #need to adjust for number already deleted 51 | removedAlready = (numDeleted - len(deleted)) 52 | del metaData["ids"][newIndex - removedAlready] 53 | del deleted[0] 54 | 55 | cfg.logger.debug("Renaming Complete") 56 | 57 | 58 | 59 | while len(deleted)!=0: 60 | index = deleted[0] 61 | # we remove any remaining deleted entries from metadata 62 | # note even if the program crashed at this point, running this fuction 63 | # again would yeild an uncorrupted state 64 | removedAlready = (numDeleted - len(deleted)) 65 | with noInterrupt: 66 | cfg.logger.debug(f"Removing {metaData['ids'][index - removedAlready]} from metadata") 67 | del metaData["ids"][index - removedAlready] 68 | del deleted[0] 69 | 70 | def _checkBlanks(plPath,metaData): 71 | for i in reversed(range(len(metaData["ids"]))): 72 | songId = metaData['ids'][i] 73 | if songId == '': 74 | cfg.logger.debug(f'Blank MetaData id Found at Index {i}, removing') 75 | del metaData["ids"][i] 76 | 77 | def _removeGaps(plPath): 78 | currentDir = getLocalSongs(plPath) 79 | numDidgets = len(str(len(currentDir))) 80 | 81 | for i,oldName in enumerate(currentDir): 82 | newPrepend = f"{createNumLabel(i,numDidgets)}_" 83 | oldPrepend = re.search(cfg.filePrependRE, oldName).group(0) 84 | if oldPrepend!=newPrepend: 85 | newName = re.sub(cfg.filePrependRE, f"{createNumLabel(i,numDidgets)}_" , oldName) 86 | cfg.logger.debug(f"Renaming {oldName} to {newName}") 87 | os.rename(f"{plPath}/{oldName}",f"{plPath}/{newName}") 88 | cfg.logger.debug("Renaming Complete") 89 | 90 | 91 | 92 | def _restorePrepend(plPath,metaData): 93 | 94 | if "removePrependOrder" not in metaData: 95 | #prepends already restored 96 | return 97 | 98 | cfg.logger.debug("Restoring Prepends") 99 | 100 | currentDir = os.listdir(path=plPath) 101 | idsLen = len(metaData["ids"]) 102 | numDigets = getNumDigets(idsLen) 103 | 104 | for file in currentDir: 105 | try: 106 | index = metaData["removePrependOrder"][file] 107 | except KeyError: 108 | # file isn't in playlist (or its the metadata 109 | continue 110 | 111 | # at this point we add the prepend to the file 112 | label = createNumLabel(index,numDigets) 113 | 114 | cfg.logger.debug(f"Adding Prepend {label} to {file}") 115 | with noInterrupt: 116 | os.rename(f"{plPath}/{file}",f"{plPath}/{label}_{file}") 117 | 118 | # removed item from dictionary to prevent double restoring 119 | del metaData["removePrependOrder"][file] 120 | 121 | # metaData["removePrependOrder"] is removed, this is used to signal that all the prepends are there 122 | # and the playlist can be treated like normal 123 | 124 | del metaData["removePrependOrder"] 125 | 126 | 127 | def correctStateCorruption(plPath,metaData): 128 | cfg.logger.debug("Checking for playlist state Corruption") 129 | 130 | _checkBlanks(plPath,metaData) # must come first so later steps dont assume blanks are valid when checking len 131 | 132 | _restorePrepend(plPath,metaData) # can only restore if prepends where removed by remove prepends 133 | 134 | _checkDeletions(plPath,metaData) 135 | 136 | _removeGaps(plPath) # must come after check deletions (if user manually deletes, then we only have number 137 | # on song to go off of, hence removing gaps by chaning the numbers would break this) 138 | 139 | 140 | def removePrepend(plPath, metaData): 141 | #TODO this step might be unnessisary because its already done in togglePrepend 142 | if "removePrependOrder" in metaData: 143 | # prepends already removed or partially removed 144 | return 145 | 146 | currentDir = getLocalSongs(plPath) 147 | 148 | metaData["removePrependOrder"] = {} 149 | 150 | for oldName in currentDir: 151 | index = getSongNum(oldName) 152 | newName = re.sub(cfg.filePrependRE, "" , oldName) 153 | 154 | with noInterrupt: 155 | os.rename(f"{plPath}/{oldName}",f"{plPath}/{newName}") 156 | metaData["removePrependOrder"][newName] = index 157 | 158 | 159 | def editPlaylist(plPath, newOrder, deletions=False): 160 | ''' 161 | metaData is json as defined in newPlaylist 162 | newOrder is an ordered list of tuples (Id of song, where to find it ) 163 | the "where to find it" is the number in the old ordering (None if song is to be downloaded) 164 | 165 | note if song is in playlist already the id of song in newOrder will not be used 166 | ''' 167 | 168 | currentDir = getLocalSongs(plPath) 169 | numDigets = len(str(2*len(newOrder))) #needed for creating starting number for auto ordering ie) 001, 0152 170 | # len is doubled because we will be first numbering with numbers above the 171 | # so state remains recoverable in event of crash 172 | 173 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 174 | idsLen = len(metaData['ids']) 175 | cfg.logger.info(f"Editing Playlist...") 176 | cfg.logger.debug(f"Old Order: {metaData['ids']}") 177 | 178 | for i in range(len(newOrder)): 179 | newId,oldIndex = newOrder[i] 180 | newIndex = idsLen + i # we reorder the playlist with exclusivly new numbers in case a crash occurs 181 | 182 | if oldIndex == None: 183 | # must download new song 184 | songName = download(metaData,plPath,newId,newIndex,numDigets) 185 | if songName and cfg.autoScrapeCommentTimestamps: 186 | addTimestampsIfNoneExist(plPath, songName, newId) 187 | 188 | else: 189 | #song exists locally, but must be reordered/renamed 190 | oldName = currentDir[oldIndex] 191 | relabel(metaData,cfg.logger.debug,plPath,oldName,oldIndex,newIndex,numDigets) 192 | 193 | 194 | if deletions: 195 | oldIndices = [item[1] for item in newOrder] 196 | 197 | for i in reversed(range(len(currentDir))): 198 | if i not in oldIndices: 199 | delete(metaData,plPath,currentDir[i],i) 200 | 201 | _checkBlanks(plPath,metaData) 202 | _removeGaps(plPath) 203 | -------------------------------------------------------------------------------- /sync_dl/tests/integrationTests.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | import os 4 | import shelve 5 | import shutil 6 | import random 7 | 8 | from sync_dl.commands import smartSync,newPlaylist,swap,shuffle,move 9 | import sync_dl.config as cfg 10 | from sync_dl.commands import compareMetaData, showPlaylist 11 | from sync_dl.helpers import getLocalSongs 12 | from sync_dl.ytdlWrappers import getTitle,getIdsAndTitles 13 | 14 | from sync_dl.timestamps import getTimestamps, extractChapters, createChapterFile, wipeChapterFile, addTimestampsToChapterFile, applyChapterFileToSong 15 | from sync_dl.timestamps.scraping import Timestamp, scrapeCommentsForTimestamps 16 | 17 | 18 | def metaDataSongsCorrect(metaData,plPath): 19 | ''' 20 | tests if metadata ids corrispond to the correct remote songs. 21 | returns boolian test result and logs details 22 | 23 | test fails if local title does not match remote title 24 | manually added songs are ignored 25 | ''' 26 | cfg.logger.info("Testing if Metadata IDs Corrispond to Correct Songs") 27 | 28 | currentDir = getLocalSongs(plPath) 29 | 30 | localIds = metaData['ids'] 31 | localTitles = map(lambda title: re.sub(cfg.filePrependRE,'',title), currentDir) 32 | 33 | for i,localTitle in enumerate(localTitles): 34 | localId = localIds[i] 35 | if localId!=cfg.manualAddId: 36 | 37 | remoteTitle = getTitle(f"https://www.youtube.com/watch?v={localId}") 38 | if localTitle != remoteTitle: 39 | message = (f"{i}th Local Title: {localTitle}\n" 40 | f"Differes from Remote Title: {remoteTitle}\n" 41 | f"With same Id: {localId}") 42 | cfg.logger.error(message) 43 | return False 44 | cfg.logger.info("Test Passed") 45 | return True 46 | 47 | def metaDataMatches(metaData,plPath): 48 | ''' 49 | metadata and local playlist must perfectly match remote playlist for this to return true 50 | ''' 51 | cfg.logger.info("Testing If Metadata Perfectly Matches Remote Playlist") 52 | 53 | currentDir = getLocalSongs(plPath) 54 | 55 | localIds = metaData['ids'] 56 | 57 | remoteIds, remoteTitles = getIdsAndTitles(metaData['url']) 58 | 59 | if len(localIds) != len(currentDir): 60 | cfg.logger.error(f"metadata ids and local playlist differ in length {len(localIds)} to {len(currentDir)}") 61 | return False 62 | if len(localIds)!= len(remoteIds): 63 | cfg.logger.error(f"local and remote playlists differ in length {len(localIds)} to {len(remoteIds)}") 64 | return False 65 | 66 | 67 | for i,localTitle in enumerate(currentDir): 68 | localTitle,_ = os.path.splitext(localTitle) 69 | localTitle = re.sub(cfg.filePrependRE,'',localTitle) 70 | 71 | localId = localIds[i] 72 | remoteId = remoteIds[i] 73 | remoteTitle = remoteTitles[i] 74 | 75 | if localId!=remoteId: 76 | message = (f"{i}th Local id: {localId}\n" 77 | f"With title: {localTitle}\n" 78 | f"Differes from Remote id: {remoteId}\n" 79 | f"With title: {remoteTitle}") 80 | cfg.logger.error(message) 81 | return False 82 | 83 | 84 | if localTitle!=remoteTitle: 85 | message = (f"{i}th Local Title: {localTitle}\n" 86 | f"With Id: {localId}\n" 87 | f"Differes from Remote Title: {remoteTitle}\n" 88 | f"With Id: {localId}") 89 | cfg.logger.error(message) 90 | return False 91 | return True 92 | 93 | 94 | 95 | class test_integration(unittest.TestCase): 96 | '''All tests are in order, failing one will fail subsequent tests. 97 | This is intentional, redownloading entire playlist for each 98 | test would be a waste of bandwidth''' 99 | 100 | PL_URL = None #must be passed 101 | plName = 'integration' 102 | plPath = f'{cfg.testPlPath}/{plName}' 103 | 104 | def test_0_creation(self): 105 | 106 | cfg.logger.info("Running test_creation") 107 | 108 | if os.path.exists(self.plPath): 109 | shutil.rmtree(self.plPath) 110 | 111 | newPlaylist(self.plPath,self.PL_URL) 112 | 113 | with shelve.open(f"{self.plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 114 | passed = metaDataMatches(metaData,self.plPath) 115 | 116 | 117 | self.assertTrue(passed) 118 | 119 | 120 | 121 | def test_1_smartSyncNoEdit(self): 122 | cfg.logger.info("Running test_smartSyncNoEdit") 123 | smartSync(self.plPath) 124 | with shelve.open(f"{self.plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 125 | passed = metaDataMatches(metaData,self.plPath) 126 | 127 | self.assertTrue(passed) 128 | 129 | 130 | def test_2_smartSyncSwap(self): 131 | '''Simulates remote reordering by reordering local''' 132 | cfg.logger.info("Running test_smartSyncSwap") 133 | swap(self.plPath,0 , 1) 134 | 135 | smartSync(self.plPath) 136 | with shelve.open(f"{self.plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 137 | passed = metaDataMatches(metaData,self.plPath) 138 | 139 | self.assertTrue(passed) 140 | 141 | def test_3_smartSyncMove(self): 142 | cfg.logger.info("Running test_smartSyncSwap") 143 | 144 | with shelve.open(f"{self.plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 145 | numIds = len(metaData['ids']) 146 | 147 | move(self.plPath,0 , int(numIds/2)) 148 | 149 | smartSync(self.plPath) 150 | 151 | with shelve.open(f"{self.plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 152 | passed = metaDataMatches(metaData,self.plPath) 153 | 154 | self.assertTrue(passed) 155 | 156 | 157 | def test_4_smartSyncShuffle(self): 158 | '''Simulates remote reordering by shuffling local''' 159 | cfg.logger.info("Running test_smartSyncShuffle") 160 | shuffle(self.plPath) 161 | 162 | smartSync(self.plPath) 163 | with shelve.open(f"{self.plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 164 | passed = metaDataMatches(metaData,self.plPath) 165 | 166 | self.assertTrue(passed) 167 | 168 | def test_5_smartSyncDelAndShuffle(self): 169 | cfg.logger.info("Running test_smartSyncDelAndShuffle") 170 | shuffle(self.plPath) 171 | 172 | currentDir = getLocalSongs(self.plPath) 173 | 174 | #deletes a third (rounded down) of the songs in the playlist 175 | toDelete = [] 176 | for _ in range(int(len(currentDir)/3)): 177 | randSong = random.choice(currentDir) 178 | 179 | while randSong in toDelete: 180 | #ensures we dont try to delete the same song twice 181 | randSong = random.choice(currentDir) 182 | 183 | toDelete.append(randSong) 184 | 185 | for song in toDelete: 186 | os.remove(f'{self.plPath}/{song}') 187 | 188 | 189 | smartSync(self.plPath) 190 | 191 | with shelve.open(f"{self.plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 192 | passed = metaDataMatches(metaData,self.plPath) 193 | 194 | self.assertTrue(passed) 195 | 196 | def test_7_scrapeTimeStamps(self): 197 | videoId = '9WbtgupHTPA' 198 | knownTimeStamps = [ 199 | Timestamp(time = 0, label = 'beginning'), 200 | Timestamp(time = 20, label = 'some stuff'), 201 | Timestamp(time = 90, label = 'shaking up the format'), 202 | Timestamp(time = 201, label = 'more shaking up the format'), 203 | Timestamp(time = 361, label = 'wowee whats this'), 204 | ] 205 | 206 | scrapedTimestamps = scrapeCommentsForTimestamps(videoId) 207 | 208 | 209 | self.assertEqual(len(knownTimeStamps), len(scrapedTimestamps)) 210 | 211 | for i in range(0,len(scrapedTimestamps)): 212 | self.assertEqual(scrapedTimestamps[i], knownTimeStamps[i]) 213 | 214 | def test_8_stateSummery(self): 215 | '''logs state of playlist after all tests (should be last in test chain)''' 216 | cfg.logger.info("Integration Test End Report:") 217 | 218 | compareMetaData(self.plPath) 219 | showPlaylist(self.plPath) 220 | 221 | -------------------------------------------------------------------------------- /sync_dl/tests/test.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrinceOfPuppers/sync-dl/bb2cbf208664ac7407e18abd5e7d30779a7407a0/sync_dl/tests/test.mp3 -------------------------------------------------------------------------------- /sync_dl/tests/unitTests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import shutil 4 | import shelve 5 | import sys 6 | import inspect 7 | from string import ascii_uppercase 8 | from typing import List 9 | 10 | import sync_dl.config as cfg 11 | from sync_dl.helpers import smartSyncNewOrder,createNumLabel,getLocalSongs,getNumDigets, calcuateTransferMoves, TransferMove 12 | from sync_dl.plManagement import editPlaylist,correctStateCorruption 13 | 14 | from sync_dl.commands import move, swap, manualAdd, moveRange,togglePrepends 15 | 16 | from sync_dl.timestamps import getTimestamps, extractChapters, createChapterFile, wipeChapterFile, addTimestampsToChapterFile, applyChapterFileToSong 17 | from sync_dl.timestamps.scraping import Timestamp 18 | 19 | try: 20 | from sync_dl_ytapi.helpers import longestIncreasingSequence,oldToNewPushOrder,pushOrderMoves 21 | except: 22 | cfg.logger.error("Please Install sync_dl_ytapi to Run Unittests") 23 | sys.exit(1) 24 | 25 | def createFakePlaylist(name,songs): 26 | '''creates fake playlist with all songs being as if they where locally added''' 27 | 28 | if not os.path.exists(cfg.testPlPath): 29 | os.mkdir(cfg.testPlPath) 30 | 31 | os.mkdir(f'{cfg.testPlPath}/{name}') 32 | 33 | numDigets = getNumDigets(len(songs)) 34 | 35 | with shelve.open(f"{cfg.testPlPath}/{name}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 36 | metaData["url"] = "placeholder" 37 | metaData["ids"] = [] 38 | 39 | for i,song in enumerate(songs): 40 | songName = f"{createNumLabel(i,numDigets)}_{song}" 41 | open(f"{cfg.testPlPath}/{name}/{songName}",'a').close() 42 | metaData["ids"].append(str(i)) # i is used to trace songs in metadata during testing 43 | 44 | 45 | def getPlaylistData(name): 46 | '''used to validate playlist returns list of tups (id, song name)''' 47 | result = [] 48 | songs = getLocalSongs(f"{cfg.testPlPath}/{name}") 49 | with shelve.open(f"{cfg.testPlPath}/{name}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 50 | for i,songId in enumerate(metaData['ids']): 51 | result.append( (songId,songs[i]) ) 52 | 53 | return result 54 | 55 | class test_correctStateCorruption(unittest.TestCase): 56 | 57 | def test_removedSongs(self): 58 | name = inspect.currentframe().f_code.co_name 59 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 60 | 61 | songs = ['A' ,'B' ,'C' ,'D', 'E'] 62 | 63 | plPath = f'{cfg.testPlPath}/{name}' 64 | 65 | createFakePlaylist(name,songs) 66 | 67 | os.remove(f'{plPath}/0_A') 68 | os.remove(f'{plPath}/4_E') 69 | os.remove(f'{plPath}/2_C') 70 | 71 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 72 | correctStateCorruption(f'{cfg.testPlPath}/{name}',metaData) 73 | 74 | correct = [ ('1', '0_B'), ('3','1_D') ] 75 | 76 | 77 | result = getPlaylistData(name) 78 | 79 | shutil.rmtree(f'{cfg.testPlPath}/{name}') 80 | self.assertEqual(result,correct) 81 | 82 | def test_blankMetaData(self): 83 | name = inspect.currentframe().f_code.co_name 84 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 85 | 86 | plPath = f'{cfg.testPlPath}/{name}' 87 | 88 | songs = ['A' ,'B' ,'C' ,'D'] 89 | 90 | createFakePlaylist(name,songs) 91 | 92 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 93 | metaData['ids'].insert(2,'') 94 | 95 | correctStateCorruption(plPath,metaData) 96 | 97 | correct = [ ('0', '0_A'), ('1', '1_B'), ('2','2_C'), ('3','3_D') ] 98 | 99 | 100 | result = getPlaylistData(name) 101 | 102 | shutil.rmtree(f'{cfg.testPlPath}/{name}') 103 | self.assertEqual(result,correct) 104 | 105 | 106 | def test_removePrepend(self): 107 | name = inspect.currentframe().f_code.co_name 108 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 109 | 110 | songs = ['A' ,'B' ,'C' ,'D'] 111 | createFakePlaylist(name,songs) 112 | 113 | correct = [ ('0', '0_A'), ('1','1_B'), ('2','2_C'), ('3','3_D') ] 114 | 115 | plPath = f'{cfg.testPlPath}/{name}' 116 | 117 | togglePrepends(plPath) 118 | 119 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 120 | correctStateCorruption(plPath,metaData) 121 | 122 | result = getPlaylistData(name) 123 | shutil.rmtree(plPath) 124 | self.assertEqual(result,correct) 125 | 126 | def test_removeSongsAndPrepends(self): 127 | name = inspect.currentframe().f_code.co_name 128 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 129 | 130 | songs = ['A' ,'B' ,'C' ,'D', 'E'] 131 | 132 | createFakePlaylist(name,songs) 133 | 134 | plPath = f'{cfg.testPlPath}/{name}' 135 | 136 | os.remove(f'{plPath}/0_A') 137 | os.remove(f'{plPath}/4_E') 138 | os.remove(f'{plPath}/2_C') 139 | togglePrepends(plPath) 140 | 141 | 142 | with shelve.open(f"{plPath}/{cfg.metaDataName}", 'c',writeback=True) as metaData: 143 | correctStateCorruption(plPath,metaData) 144 | 145 | correct = [ ('1', '0_B'), ('3','1_D') ] 146 | 147 | 148 | result = getPlaylistData(name) 149 | 150 | shutil.rmtree(f'{cfg.testPlPath}/{name}') 151 | self.assertEqual(result,correct) 152 | 153 | class test_togglePrepends(unittest.TestCase): 154 | def test_togglePrepends1(self): 155 | name = inspect.currentframe().f_code.co_name 156 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 157 | 158 | songs = ['A' ,'B' ,'C' ,'D'] 159 | plPath = f'{cfg.testPlPath}/{name}' 160 | 161 | createFakePlaylist(name,songs) 162 | 163 | togglePrepends(plPath) 164 | togglePrepends(plPath) 165 | 166 | correct = [ ('0', '0_A'), ('1','1_B'), ('2','2_C'), ('3','3_D') ] 167 | 168 | result = getPlaylistData(name) 169 | shutil.rmtree(plPath) 170 | self.assertEqual(result,correct) 171 | 172 | 173 | 174 | class test_editPlaylist(unittest.TestCase): 175 | 176 | def test_editPl1(self): 177 | name = inspect.currentframe().f_code.co_name 178 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 179 | 180 | songs = ['A' ,'B' ,'C' ,'D'] 181 | 182 | createFakePlaylist(name,songs) 183 | 184 | newOrder = [ ('3', 3), ('1',1), ('2',2), ('0',0) ] 185 | 186 | correct = [ ('3', '0_D'), ('1','1_B'), ('2','2_C'), ('0','3_A') ] 187 | 188 | editPlaylist(f'{cfg.testPlPath}/{name}',newOrder) 189 | 190 | 191 | result = getPlaylistData(name) 192 | 193 | shutil.rmtree(f'{cfg.testPlPath}/{name}') 194 | self.assertEqual(result,correct) 195 | 196 | 197 | def test_editPl2(self): 198 | name = inspect.currentframe().f_code.co_name 199 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 200 | 201 | songs = ['A' ,'B' ,'C' ,'D','E','F'] 202 | 203 | createFakePlaylist(name,songs) 204 | 205 | newOrder = [ ('3', 3), ('4',4), ('2',2), ('0',0) ] 206 | 207 | correct = [ ('3', '0_D'), ('4','1_E'), ('2','2_C'), ('0','3_A') ] 208 | 209 | editPlaylist(f'{cfg.testPlPath}/{name}',newOrder,True) 210 | 211 | 212 | result = getPlaylistData(name) 213 | 214 | shutil.rmtree(f'{cfg.testPlPath}/{name}') 215 | self.assertEqual(result,correct) 216 | 217 | def test_editPl3(self): 218 | name = inspect.currentframe().f_code.co_name 219 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 220 | 221 | songs = ['A' ,'B' ,'C'] 222 | 223 | createFakePlaylist(name,songs) 224 | 225 | newOrder = [ ] 226 | 227 | correct = [ ] 228 | 229 | editPlaylist(f'{cfg.testPlPath}/{name}',newOrder,True) 230 | 231 | 232 | result = getPlaylistData(name) 233 | 234 | shutil.rmtree(f'{cfg.testPlPath}/{name}') 235 | self.assertEqual(result,correct) 236 | 237 | def test_editPl4(self): 238 | name = inspect.currentframe().f_code.co_name 239 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 240 | 241 | songs = ['A' ,'B' ,'C' ,'D', 'E', 'F', 'G'] 242 | 243 | createFakePlaylist(name,songs) 244 | 245 | newOrder = [ ('6',6), ('3', 3), ('4',4), ('5',5), ('2',2) ] 246 | 247 | correct = [ ('6', '0_G'), ('3','1_D'), ('4','2_E'),('5','3_F'), ('2','4_C') ] 248 | editPlaylist(f'{cfg.testPlPath}/{name}',newOrder,True) 249 | 250 | 251 | result = getPlaylistData(name) 252 | 253 | shutil.rmtree(f'{cfg.testPlPath}/{name}') 254 | self.assertEqual(result,correct) 255 | 256 | class test_smartSyncNewOrder(unittest.TestCase): 257 | 258 | def test_insertAndDelete(self): 259 | name = inspect.currentframe().f_code.co_name 260 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 261 | 262 | localIds = ['A' ,'B' ,'C' ,'D'] 263 | remoteIds = ['A' ,'1' ,'B' ,'C' ,'2'] 264 | 265 | correct = [('A',0) ,('1',None) ,('B',1) ,('C',2) ,('D',3) ,('2',None)] 266 | 267 | 268 | result = smartSyncNewOrder(localIds,remoteIds) 269 | self.assertEqual(result,correct) 270 | 271 | def test_insertDeleteSwap(self): 272 | name = inspect.currentframe().f_code.co_name 273 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 274 | localIds = ['A' ,'B' ,'C' ,'D'] 275 | remoteIds = ['A' ,'1' ,'C' ,'B' ,'2'] 276 | 277 | 278 | correct = [('A',0) ,('1',None) ,('C',2) ,('D',3) ,('B',1) ,('2',None)] 279 | 280 | 281 | result = smartSyncNewOrder(localIds,remoteIds) 282 | self.assertEqual(result,correct) 283 | 284 | def test_3(self): 285 | name = inspect.currentframe().f_code.co_name 286 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 287 | 288 | localIds = ['A' ,'B' ,'C' ,'D', 'E', 'F','G'] 289 | remoteIds = ['A' ,'1' ,'C' ,'B' ,'2','F'] 290 | 291 | 292 | correct = [('A',0) ,('1',None) ,('C',2),('D',3),('E',4) ,('B',1) , ('2',None), ('F',5), ('G',6)] 293 | 294 | 295 | result = smartSyncNewOrder(localIds,remoteIds) 296 | self.assertEqual(result,correct) 297 | 298 | 299 | def test_LocalDeleteAll(self): 300 | name = inspect.currentframe().f_code.co_name 301 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 302 | 303 | localIds = [] 304 | remoteIds = ['A' ,'1' ,'C' ,'B' ,'2','F'] 305 | 306 | 307 | correct = [('A',None) ,('1',None) ,('C',None),('B',None) , ('2',None), ('F',None)] 308 | 309 | 310 | result = smartSyncNewOrder(localIds,remoteIds) 311 | self.assertEqual(result,correct) 312 | 313 | def test_RemoteDeleteAll(self): 314 | name = inspect.currentframe().f_code.co_name 315 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 316 | 317 | localIds = ['A' ,'B' ,'C' ,'D', 'E', 'F','G'] 318 | remoteIds = [] 319 | 320 | 321 | correct = [('A',0) ,('B',1), ('C',2), ('D',3), ('E',4), ('F',5), ('G',6)] 322 | 323 | 324 | result = smartSyncNewOrder(localIds,remoteIds) 325 | self.assertEqual(result,correct) 326 | 327 | def test_Reversal(self): 328 | name = inspect.currentframe().f_code.co_name 329 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 330 | 331 | localIds = ['A' ,'B' ,'C' ,'D', 'E'] 332 | remoteIds = ['E','D','C','B','A'] 333 | 334 | 335 | correct = [('E',4), ('D',3), ('C',2), ('B',1), ('A',0)] 336 | 337 | 338 | result = smartSyncNewOrder(localIds,remoteIds) 339 | self.assertEqual(result,correct) 340 | 341 | 342 | def test_7(self): 343 | name = inspect.currentframe().f_code.co_name 344 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 345 | 346 | localIds = ['A' ,'B' ,'C' ,'D', 'E'] 347 | remoteIds = ['E','1','D','2','B','A'] 348 | 349 | 350 | correct = [('E',4), ('1',None),('D',3),('2',None), ('B',1), ('C',2), ('A',0)] 351 | 352 | 353 | result = smartSyncNewOrder(localIds,remoteIds) 354 | self.assertEqual(result,correct) 355 | 356 | 357 | class test_move(unittest.TestCase): 358 | 359 | def test_moveLarger(self): 360 | name = inspect.currentframe().f_code.co_name 361 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 362 | 363 | songs = ['A' ,'B' ,'C' ,'D','E'] 364 | 365 | createFakePlaylist(name,songs) 366 | 367 | 368 | correct = [ ('0', '0_A'), ('2','1_C'), ('3','2_D'), ('1','3_B'), ('4','4_E') ] 369 | 370 | plPath = f'{cfg.testPlPath}/{name}' 371 | move(plPath,1,3) 372 | 373 | result = getPlaylistData(name) 374 | 375 | shutil.rmtree(plPath) 376 | self.assertEqual(result,correct) 377 | 378 | def test_moveSmaller(self): 379 | name = inspect.currentframe().f_code.co_name 380 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 381 | 382 | songs = ['A' ,'B' ,'C' ,'D','E'] 383 | 384 | createFakePlaylist(name,songs) 385 | 386 | 387 | correct = [ ('2', '0_C'), ('0','1_A'), ('1','2_B'), ('3','3_D'), ('4','4_E') ] 388 | 389 | plPath = f'{cfg.testPlPath}/{name}' 390 | move(plPath,2,0) 391 | 392 | result = getPlaylistData(name) 393 | 394 | shutil.rmtree(plPath) 395 | self.assertEqual(result,correct) 396 | 397 | 398 | class test_swap(unittest.TestCase): 399 | 400 | def test_swap1(self): 401 | name = inspect.currentframe().f_code.co_name 402 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 403 | songs = ['A' ,'B' ,'C' ,'D','E'] 404 | 405 | createFakePlaylist(name,songs) 406 | 407 | 408 | correct = [ ('0', '0_A'), ('3','1_D'), ('2','2_C'), ('1','3_B'), ('4','4_E') ] 409 | 410 | plPath = f'{cfg.testPlPath}/{name}' 411 | swap(plPath,1,3) 412 | 413 | result = getPlaylistData(name) 414 | 415 | shutil.rmtree(plPath) 416 | self.assertEqual(result,correct) 417 | 418 | def test_swap2(self): 419 | name = inspect.currentframe().f_code.co_name 420 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 421 | songs = ['A' ,'B' ,'C' ,'D','E'] 422 | 423 | createFakePlaylist(name,songs) 424 | 425 | 426 | correct = [ ('4', '0_E'), ('1','1_B'), ('2','2_C'), ('3','3_D'), ('0','4_A') ] 427 | 428 | plPath = f'{cfg.testPlPath}/{name}' 429 | swap(plPath,0,4) 430 | 431 | result = getPlaylistData(name) 432 | 433 | shutil.rmtree(plPath) 434 | self.assertEqual(result,correct) 435 | 436 | 437 | class test_manualAdd(unittest.TestCase): 438 | def test_manualAdd1(self): 439 | name = inspect.currentframe().f_code.co_name 440 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 441 | songs = ['A' ,'B' ,'C' ,'D','E'] 442 | 443 | createFakePlaylist(name,songs) 444 | 445 | songPath = f"{cfg.testPlPath}/X" 446 | open(songPath,'a').close() 447 | 448 | 449 | correct = [ ('0', '0_A'), ('1','1_B'), ('2','2_C'), ('3','3_D'),(cfg.manualAddId,'4_X'), ('4','5_E') ] 450 | plPath = f'{cfg.testPlPath}/{name}' 451 | manualAdd(plPath,songPath,4) 452 | 453 | result = getPlaylistData(name) 454 | 455 | shutil.rmtree(plPath) 456 | self.assertEqual(result,correct) 457 | 458 | 459 | def test_manualAdd2(self): 460 | name = inspect.currentframe().f_code.co_name 461 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 462 | songs = ['A' ,'B' ,'C' ,'D','E'] 463 | 464 | createFakePlaylist(name,songs) 465 | 466 | songPath = f"{cfg.testPlPath}/X" 467 | open(songPath,'a').close() 468 | 469 | 470 | correct = [ (cfg.manualAddId,'0_X'), ('0', '1_A'), ('1','2_B'), ('2','3_C'), ('3','4_D'), ('4','5_E') ] 471 | plPath = f'{cfg.testPlPath}/{name}' 472 | manualAdd(plPath,songPath,0) 473 | 474 | result = getPlaylistData(name) 475 | 476 | shutil.rmtree(plPath) 477 | self.assertEqual(result,correct) 478 | 479 | 480 | 481 | class test_moveRange(unittest.TestCase): 482 | 483 | def test_moveRangeLarger1(self): 484 | 485 | name = inspect.currentframe().f_code.co_name 486 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 487 | songs = ['A' ,'B' ,'C' ,'D','E','F','G'] 488 | 489 | createFakePlaylist(name,songs) 490 | 491 | 492 | correct = [ ('0', '0_A'), ('4','1_E'), ('5','2_F'), ('1','3_B'), ('2','4_C'), ('3','5_D'), ('6','6_G') ] 493 | 494 | plPath = f'{cfg.testPlPath}/{name}' 495 | moveRange(plPath,1,3,5) 496 | 497 | result = getPlaylistData(name) 498 | 499 | shutil.rmtree(plPath) 500 | self.assertEqual(result,correct) 501 | 502 | def test_moveRangeLarger2(self): 503 | 504 | name = inspect.currentframe().f_code.co_name 505 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 506 | songs = ['A' ,'B' ,'C' ,'D','E','F','G'] 507 | 508 | createFakePlaylist(name,songs) 509 | 510 | 511 | correct = [ ('3','0_D'),('4','1_E'), ('5','2_F'), ('6','3_G'),('0', '4_A'), ('1','5_B'), ('2','6_C') ] 512 | 513 | plPath = f'{cfg.testPlPath}/{name}' 514 | moveRange(plPath,0,2,6) 515 | 516 | result = getPlaylistData(name) 517 | 518 | shutil.rmtree(plPath) 519 | self.assertEqual(result,correct) 520 | 521 | def test_moveRangeSmaller1(self): 522 | 523 | name = inspect.currentframe().f_code.co_name 524 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 525 | songs = ['A' ,'B' ,'C' ,'D','E','F','G'] 526 | 527 | createFakePlaylist(name,songs) 528 | 529 | 530 | correct = [ ('3','0_D'), ('4','1_E'),('5','2_F'),('0', '3_A'), ('1','4_B'), ('2','5_C') , ('6','6_G') ] 531 | 532 | plPath = f'{cfg.testPlPath}/{name}' 533 | moveRange(plPath,3,5,-1) 534 | 535 | result = getPlaylistData(name) 536 | 537 | shutil.rmtree(plPath) 538 | self.assertEqual(result,correct) 539 | 540 | 541 | def test_moveRangeSmaller2(self): 542 | 543 | name = inspect.currentframe().f_code.co_name 544 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 545 | songs = ['A' ,'B' ,'C' ,'D','E','F','G'] 546 | 547 | createFakePlaylist(name,songs) 548 | 549 | correct = [ ('0', '0_A'), ('1','1_B'),('4','2_E'),('5','3_F'),('6','4_G'), ('2','5_C'), ('3','6_D') ] 550 | 551 | plPath = f'{cfg.testPlPath}/{name}' 552 | moveRange(plPath,4,-1,1) 553 | 554 | result = getPlaylistData(name) 555 | 556 | shutil.rmtree(plPath) 557 | self.assertEqual(result,correct) 558 | 559 | def test_moveRangeSingleSmaller(self): 560 | 561 | name = inspect.currentframe().f_code.co_name 562 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 563 | songs = ['A' ,'B' ,'C' ,'D','E','F','G'] 564 | 565 | createFakePlaylist(name,songs) 566 | 567 | correct = [ ('0', '0_A'), ('1','1_B'),('4','2_E'), ('2','3_C'), ('3','4_D'),('5','5_F'),('6','6_G') ] 568 | 569 | plPath = f'{cfg.testPlPath}/{name}' 570 | moveRange(plPath,4,4,1) 571 | 572 | result = getPlaylistData(name) 573 | 574 | shutil.rmtree(plPath) 575 | self.assertEqual(result,correct) 576 | 577 | def test_moveRangeSingleLarger(self): 578 | 579 | name = inspect.currentframe().f_code.co_name 580 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 581 | songs = ['A' ,'B' ,'C' ,'D','E','F','G'] 582 | 583 | createFakePlaylist(name,songs) 584 | 585 | correct = [ ('0', '0_A'), ('1','1_B'), ('2','2_C'),('4','3_E'),('5','4_F'), ('3','5_D'),('6','6_G') ] 586 | 587 | plPath = f'{cfg.testPlPath}/{name}' 588 | moveRange(plPath,3,3,5) 589 | 590 | result = getPlaylistData(name) 591 | 592 | shutil.rmtree(plPath) 593 | self.assertEqual(result,correct) 594 | 595 | ################################# 596 | ## youtube api submodule tests ## 597 | ################################# 598 | 599 | class test_yt_api_helpers(unittest.TestCase): 600 | def test_longestIncreasingSequence(self): 601 | name = inspect.currentframe().f_code.co_name 602 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 603 | 604 | def acending(numList): 605 | 606 | for i in range(1,len(numList)): 607 | if numList[i] <= numList[i-1]: 608 | return False 609 | return True 610 | 611 | def isSubSequenceInSequence(subSequence, sequence): 612 | prevIndex = 0 613 | for _,num in enumerate(subSequence): 614 | try: 615 | prevIndex = sequence[prevIndex:].index(num) + 1 616 | except: 617 | return False 618 | return True 619 | 620 | pairs = [ 621 | ([7, 9, 1, 4, 0, 8, 3, 6, 5, 2],3), 622 | ([5, 4, 2, 1, 0, 6, 3, 9, 8, 7],3), 623 | ([4, 1, 9, 5, 3, 7, 0, 6, 2, 8],4), 624 | ([2, 0, 5, 7, 4, 9, 8, 6, 1, 3],4), 625 | ([0, 1, 7, 5, 9, 6, 4, 8, 3, 2],5), 626 | ([0, 4, 5, 7, 9, 8, 2, 3, 1, 6],5), 627 | ([5, 0, 9, 1, 2, 8, 4, 3, 7, 6],5), 628 | ([5, 4, 6, 0, 2, 8, 3, 9, 1, 7],4), 629 | ([5, 2, 1, 4, 0, 8, 9, 6, 7, 3],4), 630 | ([2, 0, 3, 8, 5, 6, 4, 7, 9, 1],6), 631 | ([1, 3, 5, 9, 4, 8, 2, 0, 6, 7],5), 632 | ([9, 2, 6, 4, 3, 5, 7, 0, 8, 1],5), 633 | ([7, 1, 2, 8, 9, 0, 5, 3, 4, 6],5), 634 | ([3, 4, 8, 1, 9, 5, 6, 2, 0, 7],5), 635 | ([4, 1, 2, 8, 7, 0, 9, 3, 5, 6],5), 636 | ([1, 4, 2, 0, 7, 9, 3, 6, 5, 8],5), 637 | ([6, 0, 2, 3, 8, 9, 1, 4, 5, 7],6), 638 | ([4, 1, 5, 2, 8, 0, 9, 3, 6, 7],5), 639 | ([4, 8, 0, 2, 9, 7, 6, 1, 3, 5],4), 640 | ] 641 | 642 | for pair in pairs: 643 | numList,correctLen = pair 644 | ans = longestIncreasingSequence(numList) 645 | 646 | if not acending(ans): 647 | self.fail(f'Answer is not acending! \nanswer: {ans} \ninput: {numList}') 648 | if len(ans)!=correctLen: 649 | self.fail(f'Answer has Length {len(ans)}, correct Length is {correctLen}. \nanswer: {ans} \ninput: {numList}') 650 | 651 | if not isSubSequenceInSequence(ans,numList): 652 | self.fail(f'Answer Subsequence is not in Input Sequence. \nanswer: {ans} \ninput: {numList}') 653 | 654 | 655 | 656 | class test_yt_api_getNewRemoteOrder(unittest.TestCase): 657 | def test_insertAndDelete(self): 658 | name = inspect.currentframe().f_code.co_name 659 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 660 | 661 | localIds = ['A','1' ,'C','2' ,'D'] 662 | remoteIds = ['A','B','C','D'] 663 | 664 | 665 | correct = [0, 1 ,2 ,3] 666 | 667 | 668 | result = oldToNewPushOrder(remoteIds,localIds) 669 | self.assertEqual(result,correct) 670 | 671 | def test_insertDeleteSwap(self): 672 | name = inspect.currentframe().f_code.co_name 673 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 674 | 675 | localIds = ['C','1','A','2' ,'D'] 676 | remoteIds = ['A','B','C','D'] 677 | 678 | correct = [1,2,0,3] 679 | 680 | 681 | result = oldToNewPushOrder(remoteIds,localIds) 682 | 683 | self.assertEqual(result,correct) 684 | 685 | def test_3(self): 686 | name = inspect.currentframe().f_code.co_name 687 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 688 | 689 | localIds = ['A', '1', 'E','B', 'D', 'C', '2'] 690 | remoteIds = ['A','B','C','D','E','F','G'] 691 | correct = [ 0, 4, 6, 5, 1, 2, 3 ] 692 | 693 | 694 | result = oldToNewPushOrder(remoteIds,localIds) 695 | self.assertEqual(result,correct) 696 | 697 | 698 | def test_4(self): 699 | name = inspect.currentframe().f_code.co_name 700 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 701 | 702 | localIds = ['1', 'E', 'D', 'C', '2'] 703 | remoteIds = ['A','B','C','D','E','F','G'] 704 | correct = [ 0, 1, 6, 5, 2, 3, 4 ] 705 | 706 | 707 | result = oldToNewPushOrder(remoteIds,localIds) 708 | self.assertEqual(result,correct) 709 | 710 | def test_5(self): 711 | name = inspect.currentframe().f_code.co_name 712 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 713 | 714 | localIds = [] 715 | remoteIds = ['A','B','C','D','E','F','G'] 716 | correct = [ 0, 1, 2, 3, 4, 5, 6 ] 717 | 718 | 719 | result = oldToNewPushOrder(remoteIds,localIds) 720 | self.assertEqual(result,correct) 721 | 722 | 723 | def test_6(self): 724 | name = inspect.currentframe().f_code.co_name 725 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 726 | 727 | localIds = ['A','B','C','D','E','F','G'] 728 | remoteIds = [] 729 | correct = [] 730 | 731 | 732 | result = oldToNewPushOrder(remoteIds,localIds) 733 | self.assertEqual(result,correct) 734 | 735 | 736 | def test_7(self): 737 | name = inspect.currentframe().f_code.co_name 738 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 739 | 740 | localIds = [ 741 | 'NigPr2mmENA',#0 742 | 'DzoYNerl4P8',#1 743 | '6WYsoeVNEDY',#2 744 | '0Wc2Og4vr2I',#3 745 | 'jhFDyDgMVUI',#4 746 | 'MfB3l3PHEFQ',#5 747 | 'I3EEhcLlKdo',#6 748 | 'bYwnf8r92G0',#7 749 | 'HiLKVdNJt_o',#8 750 | 'irIxkjXOp5M',#9 751 | '8Yuorb-rB9A',# 752 | 'lUYZJRlOHyw',#10 753 | 'p1cymwvDf0w',#11 754 | '4TAGhEVNCFc',#12 755 | 'RkMzJGAV7Vs',#13 756 | 'BhEfvGZM6jw',#14 757 | ] 758 | remoteIds = [ 759 | 'MfB3l3PHEFQ',#5 760 | 'DzoYNerl4P8',#1 761 | '6WYsoeVNEDY',#2 762 | '0Wc2Og4vr2I',#3 763 | 'jhFDyDgMVUI',#4 764 | 'I3EEhcLlKdo',#6 765 | 'bYwnf8r92G0',#7 766 | 'HiLKVdNJt_o',#8 767 | 'irIxkjXOp5M',#9 768 | 'lUYZJRlOHyw',#10 769 | 'p1cymwvDf0w',#11 770 | '4TAGhEVNCFc',#12 771 | 'RkMzJGAV7Vs',#13 772 | 'BhEfvGZM6jw',#14 773 | 'NigPr2mmENA',#0 774 | ] 775 | 776 | correct = [5,1,2,3,4,6,7,8,9,10,11,12,13,14,0] 777 | 778 | 779 | result = oldToNewPushOrder(remoteIds,localIds) 780 | self.assertEqual(result,correct) 781 | 782 | 783 | 784 | # Helpers for test_yt_api_pushOrderMoves 785 | def simulateMove(remoteIds,remoteItemIds,move): 786 | newIndex,_,remoteItemId = move 787 | oldIndex = remoteItemIds.index(remoteItemId) 788 | 789 | remoteIds.insert(newIndex,remoteIds.pop(oldIndex)) 790 | remoteItemIds.insert(newIndex,remoteItemIds.pop(oldIndex)) 791 | 792 | def compareLocalAndRemote(remoteIds, localIds): 793 | remoteIndex = 0 794 | localIndex = 0 795 | while True: 796 | if remoteIndex>=len(remoteIds) or localIndex>=len(localIds): 797 | break 798 | 799 | remoteId = remoteIds[remoteIndex] 800 | localId = localIds[localIndex] 801 | if remoteId not in localIds: 802 | remoteIndex+=1 803 | continue 804 | 805 | if localId not in remoteIds: 806 | localIndex+=1 807 | continue 808 | 809 | if remoteId!=localId: 810 | return False 811 | 812 | remoteIndex+=1 813 | localIndex+=1 814 | 815 | return True 816 | 817 | def remoteCorrectOrder(remoteIds,localIds): 818 | ''' any id not in local must remain after the id that came before it before moving ''' 819 | 820 | 821 | alphaToNum = lambda char: ord(char.lower()) - 96 822 | 823 | for i in range(1,len(remoteIds)): 824 | currentId = remoteIds[i] 825 | prevId = remoteIds[i-1] 826 | 827 | if currentId not in localIds: 828 | if alphaToNum(currentId)!= alphaToNum(prevId)+1: 829 | return False 830 | 831 | return True 832 | 833 | 834 | 835 | class test_yt_api_pushOrderMoves(unittest.TestCase): 836 | def test_1(self): 837 | name = inspect.currentframe().f_code.co_name 838 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 839 | 840 | localIds = ['A','1','B','C'] 841 | remoteIds = ['A','B','C','D','E','F','G'] # must be in alphabetical order for remoteCorrectOrder 842 | remoteItemIds = ['A','B','C','D','E','F','G'] 843 | 844 | moves = pushOrderMoves(remoteIds,remoteItemIds,localIds) 845 | for move in moves: 846 | simulateMove(remoteIds,remoteItemIds,move) 847 | 848 | # Checks if remote ids have taken on localIds order 849 | if not compareLocalAndRemote(remoteIds,localIds): 850 | self.fail(f'RemoteIds Do Not Match LocalIds After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 851 | 852 | # Checks if any remoteIds not in localIds stayed after the correct Id 853 | if not remoteCorrectOrder(remoteIds,localIds): 854 | self.fail(f'RemoteIds Ids Not In Correct Order After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 855 | 856 | def test_2(self): 857 | name = inspect.currentframe().f_code.co_name 858 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 859 | 860 | localIds = ['1','B','E','A','C'] 861 | remoteIds = ['A','B','C','D','E','F','G'] # must be in alphabetical order for remoteCorrectOrder 862 | remoteItemIds = ['A','B','C','D','E','F','G'] 863 | 864 | moves = pushOrderMoves(remoteIds,remoteItemIds,localIds) 865 | for move in moves: 866 | simulateMove(remoteIds,remoteItemIds,move) 867 | 868 | # Checks if remote ids have taken on localIds order 869 | if not compareLocalAndRemote(remoteIds,localIds): 870 | self.fail(f'RemoteIds Do Not Match LocalIds After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 871 | 872 | # Checks if any remoteIds not in localIds stayed after the correct Id 873 | if not remoteCorrectOrder(remoteIds,localIds): 874 | self.fail(f'RemoteIds Ids Not In Correct Order After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 875 | 876 | def test_3(self): 877 | name = inspect.currentframe().f_code.co_name 878 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 879 | 880 | localIds = ['1','F','B','A','C'] 881 | remoteIds = ['A','B','C','D','E','F','G'] # must be in alphabetical order for remoteCorrectOrder 882 | remoteItemIds = ['A','B','C','D','E','F','G'] 883 | 884 | moves = pushOrderMoves(remoteIds,remoteItemIds,localIds) 885 | for move in moves: 886 | simulateMove(remoteIds,remoteItemIds,move) 887 | 888 | # Checks if remote ids have taken on localIds order 889 | if not compareLocalAndRemote(remoteIds,localIds): 890 | self.fail(f'RemoteIds Do Not Match LocalIds After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 891 | 892 | # Checks if any remoteIds not in localIds stayed after the correct Id 893 | if not remoteCorrectOrder(remoteIds,localIds): 894 | self.fail(f'RemoteIds Ids Not In Correct Order After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 895 | 896 | def test_reversal(self): 897 | name = inspect.currentframe().f_code.co_name 898 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 899 | 900 | localIds = ['G','F','E','D','C','B','A'] 901 | remoteIds = ['A','B','C','D','E','F','G'] # must be in alphabetical order for remoteCorrectOrder 902 | remoteItemIds = ['A','B','C','D','E','F','G'] 903 | 904 | moves = pushOrderMoves(remoteIds,remoteItemIds,localIds) 905 | for move in moves: 906 | simulateMove(remoteIds,remoteItemIds,move) 907 | 908 | # Checks if remote ids have taken on localIds order 909 | if not compareLocalAndRemote(remoteIds,localIds): 910 | self.fail(f'RemoteIds Do Not Match LocalIds After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 911 | 912 | # Checks if any remoteIds not in localIds stayed after the correct Id 913 | if not remoteCorrectOrder(remoteIds,localIds): 914 | self.fail(f'RemoteIds Ids Not In Correct Order After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 915 | 916 | 917 | def test_partialReversal(self): 918 | name = inspect.currentframe().f_code.co_name 919 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 920 | 921 | localIds = ['F','E','C','B'] 922 | remoteIds = ['A','B','C','D','E','F','G'] # must be in alphabetical order for remoteCorrectOrder 923 | remoteItemIds = ['A','B','C','D','E','F','G'] 924 | 925 | moves = pushOrderMoves(remoteIds,remoteItemIds,localIds) 926 | for move in moves: 927 | simulateMove(remoteIds,remoteItemIds,move) 928 | 929 | # Checks if remote ids have taken on localIds order 930 | if not compareLocalAndRemote(remoteIds,localIds): 931 | self.fail(f'RemoteIds Do Not Match LocalIds After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 932 | 933 | # Checks if any remoteIds not in localIds stayed after the correct Id 934 | if not remoteCorrectOrder(remoteIds,localIds): 935 | self.fail(f'RemoteIds Ids Not In Correct Order After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 936 | 937 | 938 | 939 | def test_firstLastSwap(self): 940 | name = inspect.currentframe().f_code.co_name 941 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 942 | 943 | localIds = ['G','1','B','E','C','A'] 944 | remoteIds = ['A','B','C','D','E','F','G'] # must be in alphabetical order for remoteCorrectOrder 945 | remoteItemIds = ['A','B','C','D','E','F','G'] 946 | 947 | moves = pushOrderMoves(remoteIds,remoteItemIds,localIds) 948 | for move in moves: 949 | simulateMove(remoteIds,remoteItemIds,move) 950 | 951 | # Checks if remote ids have taken on localIds order 952 | if not compareLocalAndRemote(remoteIds,localIds): 953 | self.fail(f'RemoteIds Do Not Match LocalIds After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 954 | 955 | # Checks if any remoteIds not in localIds stayed after the correct Id 956 | if not remoteCorrectOrder(remoteIds,localIds): 957 | self.fail(f'RemoteIds Ids Not In Correct Order After Moves\nremoteIds: {remoteIds}\nlocalIds: {localIds}') 958 | 959 | # helper for test_calculateTransferMoves 960 | def getGenericState(length: int, filePrepends: str = '', idPrepends: str = ''): 961 | if length > len(ascii_uppercase): 962 | raise Exception() 963 | files = list(ascii_uppercase[0:length]) 964 | ids = list(ascii_uppercase[0:length]) 965 | for i in range(0, length): 966 | files[i] = filePrepends + files[i] 967 | ids[i] = idPrepends + ids[i] 968 | 969 | return files, ids 970 | 971 | 972 | def _setPadded(l: list, index: int, ele): 973 | for _ in range(index - len(l) + 1): 974 | l.append('') 975 | l[index] = ele 976 | 977 | def _movePadded(l: list, srcIndex: int, destIndex: int): 978 | for _ in range(destIndex - len(l) + 1): 979 | l.append('') 980 | l[destIndex] = l[srcIndex] 981 | l[srcIndex] = '' 982 | 983 | def _removeBlanks(l: list): 984 | for i in reversed(range(len(l))): 985 | if l[i] == '': 986 | l.pop(i) 987 | 988 | 989 | def simulateTransferMoves(moves: List[TransferMove], srcStart: int, srcEnd: int, destIndex: int, 990 | srcDir:list, destDir:list, srcLocalIds:list, destLocalIds:list, srcRemoteIds:list, destRemoteIDs:list): 991 | 992 | # make room for block 993 | blockSize = srcEnd - srcStart + 1 994 | for i in reversed(range(destIndex+1,len(destLocalIds))): 995 | _movePadded(destDir, i, i+blockSize) 996 | _movePadded(destLocalIds, i, i+blockSize) 997 | 998 | 999 | for move in moves: 1000 | if move.performCopy: 1001 | _setPadded(destDir, move.destCopyIndex, srcDir[move.srcCopyIndex]) 1002 | _setPadded(destLocalIds, move.destCopyIndex, srcLocalIds[move.srcCopyIndex]) 1003 | 1004 | if move.performRemoteAdd: 1005 | destRemoteIDs.insert(move.destRemoteAddIndex, move.songId) 1006 | 1007 | if move.performLocalDelete: 1008 | srcDir.pop(move.srcLocalDeleteIndex) 1009 | srcLocalIds.pop(move.srcLocalDeleteIndex) 1010 | 1011 | # remote deletes are done using playlist item ids, whos posisitions are calculated at the start of the 1012 | # process, hence we must keep the posistion of remote list items static during testing to mimic this 1013 | if move.performRemoteDelete: 1014 | srcRemoteIds[move.srcRemoteDeleteIndex] = '' 1015 | 1016 | _removeBlanks(srcRemoteIds) 1017 | 1018 | 1019 | 1020 | 1021 | class test_calculateTransferMoves(unittest.TestCase): 1022 | def test_emptyDestIdenticalRemote(self): 1023 | name = inspect.currentframe().f_code.co_name 1024 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1025 | 1026 | srcDir, srcLocalIds = getGenericState(5, 'f', 'i') 1027 | srcRemoteIds = srcLocalIds.copy() 1028 | 1029 | destLocalIds = [] 1030 | destRemoteIds = [] 1031 | destDir = [] 1032 | 1033 | srcStart = 1 1034 | srcEnd = 3 1035 | destIndex = -1 1036 | 1037 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1038 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1039 | 1040 | self.assertListEqual(srcDir, ['fA', 'fE']) 1041 | self.assertListEqual(srcLocalIds, ['iA', 'iE']) 1042 | self.assertListEqual(srcRemoteIds, ['iA', 'iE']) 1043 | 1044 | self.assertListEqual(destDir, ['fB', 'fC', 'fD']) 1045 | self.assertListEqual(destLocalIds, ['iB', 'iC', 'iD']) 1046 | self.assertListEqual(destRemoteIds, ['iB', 'iC', 'iD']) 1047 | 1048 | def test_appendDestIdenticalRemote(self): 1049 | name = inspect.currentframe().f_code.co_name 1050 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1051 | 1052 | srcDir, srcLocalIds = getGenericState(5, 's', 's') 1053 | srcRemoteIds = srcLocalIds.copy() 1054 | 1055 | destDir, destLocalIds = getGenericState(3, 'd', 'd') 1056 | destRemoteIds = destLocalIds.copy() 1057 | 1058 | srcStart = 1 1059 | srcEnd = 3 1060 | destIndex = len(destDir) - 1 1061 | 1062 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1063 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1064 | 1065 | self.assertListEqual(srcDir, ['sA', 'sE']) 1066 | self.assertListEqual(srcLocalIds, ['sA', 'sE']) 1067 | self.assertListEqual(srcRemoteIds, ['sA', 'sE']) 1068 | 1069 | self.assertListEqual(destDir, ['dA', 'dB', 'dC', 'sB', 'sC', 'sD']) 1070 | self.assertListEqual(destLocalIds, ['dA', 'dB', 'dC', 'sB', 'sC', 'sD']) 1071 | self.assertListEqual(destRemoteIds, ['dA', 'dB', 'dC', 'sB', 'sC', 'sD']) 1072 | 1073 | def test_prependDestIdenticalRemote(self): 1074 | name = inspect.currentframe().f_code.co_name 1075 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1076 | 1077 | srcDir, srcLocalIds = getGenericState(5, 's', 's') 1078 | srcRemoteIds = srcLocalIds.copy() 1079 | 1080 | destDir, destLocalIds = getGenericState(3, 'd', 'd') 1081 | destRemoteIds = destLocalIds.copy() 1082 | 1083 | srcStart = 1 1084 | srcEnd = 3 1085 | destIndex = -1 1086 | 1087 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1088 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1089 | 1090 | self.assertListEqual(srcDir, ['sA', 'sE']) 1091 | self.assertListEqual(srcLocalIds, ['sA', 'sE']) 1092 | self.assertListEqual(srcRemoteIds, ['sA', 'sE']) 1093 | 1094 | self.assertListEqual(destDir, ['sB', 'sC', 'sD', 'dA', 'dB', 'dC']) 1095 | self.assertListEqual(destLocalIds, ['sB', 'sC', 'sD', 'dA', 'dB', 'dC']) 1096 | self.assertListEqual(destRemoteIds, ['sB', 'sC', 'sD', 'dA', 'dB', 'dC']) 1097 | 1098 | def test_middleDestIdenticalRemote(self): 1099 | name = inspect.currentframe().f_code.co_name 1100 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1101 | 1102 | srcDir, srcLocalIds = getGenericState(5, 's', 's') 1103 | srcRemoteIds = srcLocalIds.copy() 1104 | 1105 | destDir, destLocalIds = getGenericState(3, 'd', 'd') 1106 | destRemoteIds = destLocalIds.copy() 1107 | 1108 | srcStart = 0 1109 | srcEnd = 3 1110 | destIndex = 0 1111 | 1112 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1113 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1114 | 1115 | self.assertListEqual(srcDir, ['sE']) 1116 | self.assertListEqual(srcLocalIds, ['sE']) 1117 | self.assertListEqual(srcRemoteIds, ['sE']) 1118 | 1119 | self.assertListEqual(destDir, ['dA', 'sA', 'sB', 'sC', 'sD', 'dB', 'dC']) 1120 | self.assertListEqual(destLocalIds, ['dA', 'sA', 'sB', 'sC', 'sD', 'dB', 'dC']) 1121 | self.assertListEqual(destRemoteIds, ['dA', 'sA', 'sB', 'sC', 'sD', 'dB', 'dC']) 1122 | 1123 | def test_AllSrcIdenticalRemote(self): 1124 | name = inspect.currentframe().f_code.co_name 1125 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1126 | 1127 | srcDir, srcLocalIds = getGenericState(5, 's', 's') 1128 | srcRemoteIds = srcLocalIds.copy() 1129 | 1130 | destDir, destLocalIds = getGenericState(3, 'd', 'd') 1131 | destRemoteIds = destLocalIds.copy() 1132 | 1133 | srcStart = 0 1134 | srcEnd = len(srcDir) - 1 1135 | destIndex = 0 1136 | 1137 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1138 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1139 | 1140 | self.assertListEqual(srcDir, []) 1141 | self.assertListEqual(srcLocalIds, []) 1142 | self.assertListEqual(srcRemoteIds, []) 1143 | 1144 | self.assertListEqual(destDir, ['dA', 'sA', 'sB', 'sC', 'sD', 'sE', 'dB', 'dC']) 1145 | self.assertListEqual(destLocalIds, ['dA', 'sA', 'sB', 'sC', 'sD', 'sE', 'dB', 'dC']) 1146 | self.assertListEqual(destRemoteIds, ['dA', 'sA', 'sB', 'sC', 'sD', 'sE', 'dB', 'dC']) 1147 | 1148 | def test_srcRemoteIsSubset1(self): 1149 | name = inspect.currentframe().f_code.co_name 1150 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1151 | 1152 | srcDir, srcLocalIds = getGenericState(5, 's', 's') 1153 | srcRemoteIds = srcLocalIds.copy() 1154 | srcRemoteIds.pop(1) 1155 | 1156 | destDir, destLocalIds = getGenericState(3, 'd', 'd') 1157 | destRemoteIds = destLocalIds.copy() 1158 | 1159 | srcStart = 1 1160 | srcEnd = 2 1161 | destIndex = 0 1162 | 1163 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1164 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1165 | 1166 | self.assertListEqual(srcDir, ['sA', 'sD', 'sE']) 1167 | self.assertListEqual(srcLocalIds, ['sA', 'sD', 'sE']) 1168 | self.assertListEqual(srcRemoteIds, ['sA', 'sD', 'sE']) 1169 | 1170 | self.assertListEqual(destDir, ['dA', 'sB', 'sC', 'dB', 'dC']) 1171 | self.assertListEqual(destLocalIds, ['dA', 'sB', 'sC', 'dB', 'dC']) 1172 | self.assertListEqual(destRemoteIds, ['dA', 'sB', 'sC', 'dB', 'dC']) 1173 | 1174 | def test_srcRemoteIsSubset2(self): 1175 | name = inspect.currentframe().f_code.co_name 1176 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1177 | 1178 | srcDir, srcLocalIds = getGenericState(5, 's', 's') 1179 | srcRemoteIds = srcLocalIds.copy() 1180 | srcRemoteIds.pop(2) 1181 | srcRemoteIds.pop(1) 1182 | 1183 | destDir, destLocalIds = getGenericState(3, 'd', 'd') 1184 | destRemoteIds = destLocalIds.copy() 1185 | 1186 | srcStart = 1 1187 | srcEnd = 2 1188 | destIndex = 0 1189 | 1190 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1191 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1192 | 1193 | self.assertListEqual(srcDir, ['sA', 'sD', 'sE']) 1194 | self.assertListEqual(srcLocalIds, ['sA', 'sD', 'sE']) 1195 | self.assertListEqual(srcRemoteIds, ['sA', 'sD', 'sE']) 1196 | 1197 | self.assertListEqual(destDir, ['dA', 'sB', 'sC', 'dB', 'dC']) 1198 | self.assertListEqual(destLocalIds, ['dA', 'sB', 'sC', 'dB', 'dC']) 1199 | self.assertListEqual(destRemoteIds, ['dA', 'sB', 'sC', 'dB', 'dC']) 1200 | 1201 | def test_srcRemoteIsArbitrary(self): 1202 | name = inspect.currentframe().f_code.co_name 1203 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1204 | 1205 | srcDir, srcLocalIds = getGenericState(5, 's', 's') 1206 | srcRemoteIds = ['sG', 'sC', 'sB', 'sC', 'sA', 'sE', 'sB', 'sF'] 1207 | 1208 | destDir, destLocalIds = getGenericState(4, 'd', 'd') 1209 | destRemoteIds = destLocalIds.copy() 1210 | 1211 | srcStart = 1 1212 | srcEnd = 3 1213 | destIndex = 0 1214 | 1215 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1216 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1217 | 1218 | self.assertListEqual(srcDir, ['sA', 'sE']) 1219 | self.assertListEqual(srcLocalIds, ['sA', 'sE']) 1220 | self.assertListEqual(srcRemoteIds, ['sG', 'sC', 'sA', 'sE', 'sB', 'sF']) 1221 | 1222 | self.assertListEqual(destDir, ['dA', 'sB', 'sC', 'sD', 'dB', 'dC', 'dD']) 1223 | self.assertListEqual(destLocalIds, ['dA', 'sB', 'sC', 'sD', 'dB', 'dC', 'dD']) 1224 | self.assertListEqual(destRemoteIds, ['dA', 'sB', 'sC', 'sD', 'dB', 'dC', 'dD']) 1225 | 1226 | def test_repeats1(self): 1227 | name = inspect.currentframe().f_code.co_name 1228 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1229 | 1230 | srcDir, srcLocalIds = getGenericState(3, 's', 's') 1231 | srcDir.extend(srcDir) 1232 | srcLocalIds.extend(srcLocalIds) 1233 | srcRemoteIds = srcLocalIds.copy() 1234 | 1235 | destDir, destLocalIds = getGenericState(4, 'd', 'd') 1236 | destDir.extend(destDir) 1237 | destLocalIds.extend(destLocalIds) 1238 | destRemoteIds = destLocalIds.copy() 1239 | 1240 | srcStart = 4 1241 | srcEnd = 5 1242 | destIndex = 5 1243 | 1244 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1245 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1246 | 1247 | self.assertListEqual(srcDir, ['sA', 'sB', 'sC', 'sA']) 1248 | self.assertListEqual(srcLocalIds, ['sA', 'sB', 'sC', 'sA']) 1249 | self.assertListEqual(srcRemoteIds, ['sA', 'sB', 'sC', 'sA']) 1250 | 1251 | self.assertListEqual(destDir, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sB', 'sC', 'dC', 'dD']) 1252 | self.assertListEqual(destLocalIds, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sB', 'sC', 'dC', 'dD']) 1253 | self.assertListEqual(destRemoteIds, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sB', 'sC', 'dC', 'dD']) 1254 | 1255 | def test_repeats2(self): 1256 | name = inspect.currentframe().f_code.co_name 1257 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1258 | 1259 | srcDir, srcLocalIds = getGenericState(3, 's', 's') 1260 | srcDir.extend(srcDir) 1261 | srcLocalIds.extend(srcLocalIds) 1262 | srcRemoteIds = srcLocalIds.copy() 1263 | 1264 | destDir, destLocalIds = getGenericState(4, 'd', 'd') 1265 | destDir.extend(destDir) 1266 | destLocalIds.extend(destLocalIds) 1267 | destRemoteIds = destLocalIds.copy() 1268 | 1269 | srcStart = 1 1270 | srcEnd = 4 1271 | destIndex = 5 1272 | 1273 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1274 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1275 | 1276 | self.assertListEqual(srcDir, ['sA', 'sC']) 1277 | self.assertListEqual(srcLocalIds, ['sA', 'sC']) 1278 | self.assertListEqual(srcRemoteIds, ['sA', 'sC']) 1279 | 1280 | self.assertListEqual(destDir, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sB', 'sC', 'sA', 'sB', 'dC', 'dD']) 1281 | self.assertListEqual(destLocalIds, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sB', 'sC', 'sA', 'sB', 'dC', 'dD']) 1282 | self.assertListEqual(destRemoteIds, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sB', 'sC', 'sA', 'sB', 'dC', 'dD']) 1283 | 1284 | def test_repeats3(self): 1285 | name = inspect.currentframe().f_code.co_name 1286 | cfg.logger.info(f"Running {self.__class__.__name__}: {name}") 1287 | 1288 | srcDir, srcLocalIds = getGenericState(3, 's', 's') 1289 | srcDir.extend(srcDir) 1290 | srcLocalIds.extend(srcLocalIds) 1291 | srcRemoteIds = srcLocalIds.copy() 1292 | 1293 | destDir, destLocalIds = getGenericState(4, 'd', 'd') 1294 | destDir.extend(destDir) 1295 | destLocalIds.extend(destLocalIds) 1296 | destRemoteIds = destLocalIds.copy() 1297 | 1298 | srcStart = 0 1299 | srcEnd = 5 1300 | destIndex = 5 1301 | 1302 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1303 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1304 | 1305 | self.assertListEqual(srcDir, []) 1306 | self.assertListEqual(srcLocalIds, []) 1307 | self.assertListEqual(srcRemoteIds, []) 1308 | 1309 | self.assertListEqual(destDir, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sA', 'sB', 'sC', 'sA', 'sB', 'sC', 'dC', 'dD']) 1310 | self.assertListEqual(destLocalIds, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sA', 'sB', 'sC', 'sA', 'sB', 'sC', 'dC', 'dD']) 1311 | self.assertListEqual(destRemoteIds, ['dA', 'dB', 'dC', 'dD', 'dA', 'dB', 'sA', 'sB', 'sC', 'sA', 'sB', 'sC', 'dC', 'dD']) 1312 | 1313 | def test_manualAdd(self): 1314 | srcDir, srcLocalIds = getGenericState(3, 's', 's') 1315 | srcRemoteIds = srcLocalIds.copy() 1316 | srcDir.insert(1, cfg.manualAddId) 1317 | srcLocalIds.insert(1, cfg.manualAddId) 1318 | 1319 | destDir, destLocalIds = getGenericState(4, 'd', 'd') 1320 | destRemoteIds = destLocalIds.copy() 1321 | 1322 | srcStart = 1 1323 | srcEnd = 2 1324 | destIndex = 0 1325 | 1326 | moves = calcuateTransferMoves(srcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 1327 | simulateTransferMoves(moves, srcStart, srcEnd, destIndex, srcDir, destDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds) 1328 | 1329 | self.assertListEqual(srcDir, ['sA', 'sC']) 1330 | self.assertListEqual(srcLocalIds, ['sA', 'sC']) 1331 | self.assertListEqual(srcRemoteIds, ['sA', 'sC']) 1332 | 1333 | self.assertListEqual(destDir, ['dA', cfg.manualAddId, 'sB', 'dB', 'dC', 'dD']) 1334 | self.assertListEqual(destLocalIds, ['dA', cfg.manualAddId, 'sB', 'dB', 'dC', 'dD']) 1335 | self.assertListEqual(destRemoteIds, ['dA', 'sB', 'dB', 'dC', 'dD']) 1336 | 1337 | def test_addTimeStamps(self): 1338 | # Get timestamps 1339 | timestamps = [Timestamp(time=0, label="test 0"), Timestamp(time = 1, label="test1"), Timestamp(time=2, label="test2")] 1340 | 1341 | if not createChapterFile(cfg.testSongPath, cfg.testSongName): 1342 | self.fail("Chapter Creation Failed") 1343 | 1344 | wipeChapterFile() 1345 | 1346 | addTimestampsToChapterFile(timestamps, cfg.testSongPath) 1347 | 1348 | if not applyChapterFileToSong(cfg.testSongPath, cfg.testSongName): 1349 | cfg.logger.error(f"Failed to Add Timestamps To Song {cfg.testSongName}") 1350 | self.fail("Chapter Creation Failed") 1351 | 1352 | preAppliedTimestamps = getTimestamps(cfg.ffmpegMetadataPath) 1353 | appliedTimestamps = extractChapters(cfg.testSongPath) 1354 | 1355 | self.assertEqual(len(timestamps), len(appliedTimestamps)) 1356 | self.assertEqual(len(timestamps), len(preAppliedTimestamps)) 1357 | 1358 | for i in range(0,len(timestamps)): 1359 | self.assertEqual(timestamps[i], appliedTimestamps[i]) 1360 | self.assertEqual(timestamps[i], preAppliedTimestamps[i]) 1361 | -------------------------------------------------------------------------------- /sync_dl/timestamps/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import subprocess 4 | import shutil 5 | 6 | import sync_dl.config as cfg 7 | from sync_dl.timestamps.scraping import Timestamp 8 | from typing import List 9 | from sync_dl import noInterrupt 10 | 11 | from sync_dl.timestamps.scraping import scrapeCommentsForTimestamps 12 | 13 | 14 | chapterRe = re.compile(r"\[CHAPTER\]\nTIMEBASE=(.+)\nSTART=(.+)\nEND=(.+)\ntitle=(.+)\n",flags = re.M) 15 | 16 | def _getSongLengthSeconds(songPath:str) -> float: 17 | result = subprocess.run(["ffprobe", "-v", "error", "-show_entries", 18 | "format=duration", "-of", 19 | "default=noprint_wrappers=1:nokey=1", songPath], 20 | stdout=subprocess.PIPE, 21 | stderr=subprocess.STDOUT) 22 | return float(result.stdout) 23 | 24 | 25 | def getTimestamps(ffmpegMetadataPath:str) -> List[Timestamp]: 26 | with open(ffmpegMetadataPath, "r") as f: 27 | contents = f.read() 28 | timestamps = [] 29 | for (timebase, start, _, title) in chapterRe.findall(contents): 30 | timestamps.append(Timestamp.fromFfmpegChapter(timebase, start, title)) 31 | 32 | return timestamps 33 | 34 | def extractChapters(songPath:str) -> List[Timestamp]: 35 | cfg.clearTmpSubPath(cfg.ffmpegMetadataPath) 36 | createChapterFileCmd = ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-i', songPath, '-an', '-vn', '-sn','-f', 'ffmetadata', cfg.ffmpegMetadataPath] 37 | 38 | try: 39 | cfg.logger.debug(f"Extracting Chapters from Song Using FFMPEG Metadata File") 40 | subprocess.run(createChapterFileCmd, check=True) 41 | except subprocess.CalledProcessError as e: 42 | cfg.logger.debug(e) 43 | cfg.logger.error(f"Failed to Extract Chapters for Song: {songName}") 44 | return [] 45 | 46 | return getTimestamps(cfg.ffmpegMetadataPath) 47 | 48 | 49 | def createChapterFile(songPath:str, songName:str) -> bool: 50 | if not os.path.exists(songPath): 51 | cfg.logger.error(f"No Song at Path {songPath}") 52 | return False 53 | 54 | cfg.clearTmpSubPath(cfg.ffmpegMetadataPath) 55 | 56 | createChapterFileCmd = ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-i', songPath,'-f', 'ffmetadata', cfg.ffmpegMetadataPath] 57 | 58 | try: 59 | cfg.logger.debug(f"Creating FFMPEG Metadata File") 60 | subprocess.run(createChapterFileCmd, check=True) 61 | except subprocess.CalledProcessError as e: 62 | cfg.logger.debug(e) 63 | cfg.logger.error(f"Failed to Create ffmpeg Chapter File for Song: {songName}") 64 | return False 65 | 66 | return True 67 | 68 | def wipeChapterFile() -> List[Timestamp]: 69 | '''detects chapters in file and wipes them. returns list of existing timestamps''' 70 | 71 | existingTimestamps = [] 72 | with open(cfg.ffmpegMetadataPath, "r+") as f: 73 | contents = f.read() 74 | 75 | for (timebase, start, _, title) in chapterRe.findall(contents): 76 | existingTimestamps.append(Timestamp.fromFfmpegChapter(timebase, start, title)) 77 | 78 | if len(existingTimestamps) > 0: 79 | cfg.logger.debug(f"Wiping Chapters from FFMPEG Metadata File") 80 | newContents = chapterRe.sub("", contents) 81 | f.seek(0) 82 | f.write(newContents) 83 | f.truncate() 84 | 85 | return existingTimestamps 86 | 87 | 88 | def addTimestampsToChapterFile(timestamps:List[Timestamp], songPath:str): 89 | 90 | timestamps.sort(key = lambda ele: ele.time) 91 | 92 | if len(timestamps) > 0: 93 | with open(cfg.ffmpegMetadataPath, "a") as f: 94 | for i in range(0, len(timestamps) - 1): 95 | t1 = timestamps[i] 96 | t2 = timestamps[i+1] 97 | ch = t1.toFfmpegChapter(t2.time) 98 | cfg.logger.debug(f"Adding Chapter to FFMPEG Metadata File: \n{ch}") 99 | f.write(ch) 100 | 101 | t1 = timestamps[-1] 102 | end = _getSongLengthSeconds(songPath) 103 | ch = t1.toFfmpegChapter(end) 104 | cfg.logger.debug(f"Adding Chapter to FFMPEG Metadata File: \n{ch}") 105 | f.write(ch) 106 | 107 | 108 | def applyChapterFileToSong(songPath:str, songName:str) -> bool: 109 | cfg.clearTmpSubPath(cfg.songEditPath) 110 | 111 | applyChapterFile = ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-i', songPath, '-i', cfg.ffmpegMetadataPath, '-map_metadata', '1', '-map_chapters', '1', '-codec', 'copy', f"{cfg.songEditPath}/{songName}"] 112 | 113 | try: 114 | subprocess.run(applyChapterFile,check=True) 115 | except subprocess.CalledProcessError as e: 116 | cfg.logger.debug(e) 117 | return False 118 | 119 | with noInterrupt: 120 | shutil.move(f"{cfg.songEditPath}/{songName}", songPath) 121 | return True 122 | 123 | 124 | def addTimestampsIfNoneExist(plPath, songName, videoId): 125 | songPath = f"{plPath}/{songName}" 126 | if not createChapterFile(songPath, songName): 127 | return 128 | 129 | existingTimestamps = wipeChapterFile() 130 | if len(existingTimestamps) > 0: 131 | cfg.logger.info(f"Timestamps Found\n") 132 | return 133 | 134 | # Get timestamps 135 | cfg.logger.debug(f"Scraping Comments for Timestamps of Song: {songName}") 136 | timestamps = scrapeCommentsForTimestamps(videoId) 137 | 138 | if len(timestamps) == 0: 139 | cfg.logger.info(f"No Comment Timestamps Found\n") 140 | return 141 | 142 | # comment timestamps found, no existing timestamps found 143 | cfg.logger.debug(f"\nComment Timestamps Found:") 144 | for timestamp in timestamps: 145 | cfg.logger.debug(timestamp) 146 | 147 | cfg.logger.info(f"Adding Comment Timestamps\n") 148 | addTimestampsToChapterFile(timestamps, songPath) 149 | 150 | if not applyChapterFileToSong(songPath, songName): 151 | cfg.logger.error(f"Failed to Add Timestamps To Song {songName}\n") 152 | 153 | 154 | -------------------------------------------------------------------------------- /sync_dl/timestamps/scraping.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import re 3 | import json 4 | from typing import NamedTuple, List, Union 5 | 6 | import sync_dl.config as cfg 7 | 8 | 9 | def jsonRegex(*args, surroundingBrace = False): 10 | r = "" 11 | 12 | numPairs = len(args)//2 13 | for i in range(numPairs): 14 | r += r"\s*[\'\"]" + args[2*i] + r"[\'\"]\s*:\s*" 15 | r += r"[\'\"]" + args[2*i+1] + r"[\'\"]\s*.?" 16 | 17 | if surroundingBrace: 18 | r = "{" + r + "}" 19 | return r 20 | 21 | apiKeyRe = re.compile(jsonRegex("INNERTUBE_API_KEY", "(.*?)")) 22 | clientVersionRe = re.compile(jsonRegex("key", "cver", "value", "(.*?)" , surroundingBrace = True)) 23 | continuationTokenRe = re.compile(r'[\'\"]token[\'\"]:[\'\"](.*?)[\'\"]') 24 | 25 | labelSanitizeRe = re.compile(r'^(?:[:\-|\s>]*)(?:\d{1,3}[\.:|\)])?\]?(?:\s)?(.*?)[:\-|\s>]*$') 26 | 27 | class Timestamp(NamedTuple): 28 | time: int 29 | label: str 30 | 31 | chapterFmt ="[CHAPTER]\nTIMEBASE=1/1000\nSTART={start}\nEND={end}\ntitle={title}\n\n" 32 | 33 | reprFmt = "{hh}:{mm:02d}:{ss:02d} - {ll}" 34 | 35 | def __eq__(self, other): 36 | return (self.time == other.time) and (self.label == other.label) 37 | 38 | def __repr__(self): 39 | secondsRemainder = self.time%60 40 | 41 | minutes = (self.time//60) 42 | minutesRemainder = minutes % 60 43 | 44 | hours = minutes//60 45 | 46 | return self.reprFmt.format(hh=hours, mm=minutesRemainder, ss=secondsRemainder, ll=self.label) 47 | 48 | @classmethod 49 | def fromFfmpegChapter(cls, timeBase:str, start, title) -> 'Timestamp': 50 | timeBaseNum = eval(timeBase) 51 | return cls(label = title, time = round(timeBaseNum*int(start))) 52 | 53 | 54 | def toFfmpegChapter(self, nextTime): 55 | return self.chapterFmt.format(start = round(1000*self.time), end = round(1000*nextTime) - 1, title = self.label) 56 | 57 | 58 | 59 | 60 | def scrapeJson(j, desiredKey: str, results:List): 61 | if isinstance(j,List): 62 | for value in j: 63 | if isinstance(value,List) or isinstance(value,dict): 64 | scrapeJson(value, desiredKey, results) 65 | return 66 | 67 | if isinstance(j, dict): 68 | for key,value in j.items(): 69 | if key == desiredKey: 70 | results.append(value) 71 | elif isinstance(value, dict) or isinstance(value, List): 72 | scrapeJson(value, desiredKey, results) 73 | return 74 | 75 | def scrapeFirstJson(j, desiredKey: str): 76 | if isinstance(j,List): 77 | for value in j: 78 | if isinstance(value,List) or isinstance(value,dict): 79 | res = scrapeFirstJson(value, desiredKey) 80 | if res is not None: 81 | return res 82 | return None 83 | 84 | if isinstance(j, dict): 85 | for key,value in j.items(): 86 | if key == desiredKey: 87 | return value 88 | elif isinstance(value, dict) or isinstance(value, List): 89 | res = scrapeFirstJson(value, desiredKey) 90 | if res is not None: 91 | return res 92 | return None 93 | 94 | return None 95 | 96 | def _sanitizeLabel(label): 97 | match = labelSanitizeRe.match(label) 98 | if match: 99 | return match.group(1) 100 | return label 101 | 102 | 103 | 104 | def _getComments(url): 105 | r=requests.get(url) 106 | 107 | 108 | x,y,z = apiKeyRe.search(r.text), continuationTokenRe.search(r.text), clientVersionRe.search(r.text) 109 | 110 | if not x: 111 | raise Exception("Unable to Find INNERTUBE_API_KEY") 112 | 113 | if not y: 114 | raise Exception("Unable to Find Continuation Token") 115 | 116 | if not z: 117 | raise Exception("Unable to Find Youtube Client Version") 118 | 119 | key = x.group(1) 120 | continuationToken = y.group(1) 121 | clientVersion = z.group(1) 122 | 123 | requestData = '''{ 124 | "context": { 125 | "adSignalsInfo": { 126 | }, 127 | "clickTracking": { 128 | }, 129 | "client": { 130 | "clientName": "WEB", 131 | "clientVersion": "'''+clientVersion+'''", 132 | }, 133 | "request": { 134 | }, 135 | "user": { 136 | } 137 | }, 138 | "continuation": "'''+continuationToken+'''" 139 | }''' 140 | 141 | b = requests.post('https://www.youtube.com/youtubei/v1/next?key='+key,data=requestData) 142 | commentJson:dict = json.loads(b.text) 143 | comments = [] 144 | scrapeJson(commentJson,"contentText",comments) 145 | return comments 146 | 147 | 148 | 149 | def _getTime(url, timeRe): 150 | matches = timeRe.search(url) 151 | if matches and matches.group(0): 152 | if not matches.group(2): 153 | return 0 154 | return int(matches.group(2)) 155 | return None 156 | 157 | def _getTimestamp(line, timeRe) -> Union[Timestamp, None]: 158 | '''line must be of form [text] [time] or [time] [text]''' 159 | text = "" 160 | url = "" 161 | time = -1 162 | if len(line) == 0: 163 | return None 164 | 165 | urlFirst = False 166 | ele = line[0] 167 | foundUrl = scrapeFirstJson(ele, "url") 168 | if foundUrl is None: 169 | text+=ele['text'] 170 | else: 171 | urlFirst = True 172 | url = foundUrl 173 | foundTime = _getTime(url,timeRe) 174 | if foundTime is None: 175 | return None 176 | time=foundTime 177 | 178 | for i in range(1,len(line)): 179 | ele = line[i] 180 | foundUrl = scrapeFirstJson(ele, "url") 181 | 182 | # found text 183 | if foundUrl is None: 184 | if url and not urlFirst: 185 | if ele['text'].isspace(): 186 | continue 187 | return None 188 | 189 | text+=ele['text'] 190 | continue 191 | 192 | # found url 193 | if foundUrl is not None: 194 | if urlFirst: 195 | return None 196 | if url: 197 | return None 198 | 199 | url = foundUrl 200 | foundTime = _getTime(url,timeRe) 201 | if foundTime is None: 202 | return None 203 | time = foundTime 204 | 205 | if text and url and time != -1: 206 | return Timestamp(label=_sanitizeLabel(text), time=time) 207 | return None 208 | 209 | 210 | def _getTimeStamps(comments, videoId): 211 | timeRe = re.compile(r'\/watch\?v=' + videoId + r'(&t=(\d+)s)?') 212 | 213 | timeStampCandidates = [] 214 | for comment in comments: 215 | runs = scrapeFirstJson(comment,'runs') 216 | 217 | if runs is None: 218 | return [] 219 | 220 | lines = [] 221 | line = [] 222 | # group into lines 223 | for ele in runs: 224 | if ele['text'] == '\n': 225 | lines.append(line) 226 | line = list() 227 | else: 228 | line.append(ele) 229 | 230 | lines.append(line) 231 | 232 | # parse lines for timestamps 233 | timeStamps:List[Timestamp] = [] 234 | for line in lines: 235 | timeStamp = _getTimestamp(line,timeRe) 236 | if timeStamp is not None: 237 | timeStamps.append(timeStamp) 238 | 239 | if len(timeStamps) > 1: 240 | timeStamps.sort(key = lambda ele: ele.time) 241 | timeStampCandidates.append(timeStamps) 242 | timeStampCandidates.sort(key=lambda ele: len(ele), reverse=True) 243 | 244 | if len(timeStampCandidates) > 0: 245 | return timeStampCandidates[0] 246 | return [] 247 | 248 | 249 | def scrapeCommentsForTimestamps(videoId): 250 | url = 'https://www.youtube.com/watch?v=' + videoId 251 | 252 | try: 253 | comments = _getComments(url) 254 | except: 255 | cfg.logger.info("No Comments Found") 256 | return [] 257 | 258 | timeStamps = _getTimeStamps(comments, videoId) 259 | return timeStamps 260 | 261 | if __name__ == '__main__': 262 | tests = ['- 01. "Goodmorning America!"', 263 | '- 01. "Goodmorning America!"', 264 | '- 00: "Goodmorning America!"', 265 | '- 00:"Goodmorning America!"', 266 | '-00: "Goodmorning America!"', 267 | '|->: 01. "Goodmorning America!"', 268 | '- "Goodmorning America!"', 269 | '-> "Goodmorning America!"', 270 | '04.1 "Goodmorning America!"', 271 | '04.1 "Goodmorning America!"', 272 | '104. "Goodmorning America!"', 273 | "01. LOOKING UP_______________ " 274 | ] 275 | -------------------------------------------------------------------------------- /sync_dl/ytapiInterface.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shelve 3 | import re 4 | 5 | from sync_dl import noInterrupt 6 | from sync_dl.ytdlWrappers import getIDs 7 | from sync_dl.plManagement import correctStateCorruption 8 | from sync_dl.helpers import getLocalSongs, relabel, getNumDigets, copy, delete, padZeros, calcuateTransferMoves, logTransferInfo, promptAndSanitize 9 | import sync_dl.config as cfg 10 | 11 | 12 | def pushLocalOrder(plPath): 13 | from sync_dl_ytapi.commands import pushLocalOrder 14 | pushLocalOrder(plPath) 15 | 16 | 17 | def logout(): 18 | from sync_dl_ytapi.commands import logout 19 | logout() 20 | 21 | 22 | def transferSongs(srcPlPath: str, destPlPath: str, srcStart: int, srcEnd: int, destIndex: int): 23 | ''' 24 | transfers block of songs in srcPl from srcStart to srcEnd indices, to after destIndex in destPl 25 | 26 | ie)srcStart = 4, srcEnd = 6, destIndex = 2 27 | srcPl: s0 s1 s2 s3 s4 s5 s6 s7 destPl: d0 d1 d2 d3 d4 d5 d6 28 | 29 | becomes: 30 | srcPl: s0 s1 s2 s3 s7 destPl: d0 d1 d2 s4 s5 s6 d3 d4 d5 d6 31 | ''' 32 | 33 | from sync_dl_ytapi.commands import getPlAdder, getPlRemover 34 | 35 | 36 | srcMetaDataPath = f"{srcPlPath}/{cfg.metaDataName}" 37 | destMetaDataPath = f"{destPlPath}/{cfg.metaDataName}" 38 | with shelve.open(srcMetaDataPath, 'c',writeback=True) as srcMetaData, shelve.open(destMetaDataPath, 'c',writeback=True) as destMetaData: 39 | correctStateCorruption(srcPlPath, srcMetaData) 40 | correctStateCorruption(destPlPath, destMetaData) 41 | 42 | ### Loading 43 | srcPlUrl = srcMetaData["url"] 44 | srcLocalIds = srcMetaData["ids"] 45 | assert isinstance(srcLocalIds, list) 46 | srcIdsLen = len(srcLocalIds) 47 | currentSrcDir = getLocalSongs(srcPlPath) 48 | srcPlName = os.path.basename(srcPlPath) 49 | 50 | destPlUrl = destMetaData["url"] 51 | destLocalIds = destMetaData["ids"] 52 | assert isinstance(destLocalIds, list) 53 | destIdsLen = len(destLocalIds) 54 | currentDestDir = getLocalSongs(destPlPath) 55 | destPlName = os.path.basename(destPlPath) 56 | 57 | cfg.logger.info("Loading Youtube Api Resources...") 58 | plAdder = getPlAdder(destPlUrl) 59 | if plAdder is None: 60 | return 61 | 62 | destRemoteIds = getIDs(destPlUrl) 63 | 64 | plRemover, srcRemoteIds = getPlRemover(srcPlUrl) 65 | if plRemover is None or srcRemoteIds is None: 66 | return 67 | 68 | 69 | ### Src start/end sanitization 70 | if srcStart>=srcIdsLen: 71 | cfg.logger.error(f"No Song has Index {srcStart} in {srcPlName}, Largest Index is {srcIdsLen-1}") 72 | return 73 | 74 | elif srcStart<0: 75 | cfg.logger.error(f"No Song has a Negative Index") 76 | return 77 | 78 | if srcEnd>=srcIdsLen or srcEnd == -1: 79 | srcEnd = srcIdsLen-1 80 | 81 | elif srcEnd= destIdsLen: 87 | destIndex = destIdsLen -1 88 | elif destIndex < -1: 89 | destIndex = -1 90 | 91 | 92 | # number of elements to move 93 | blockSize = srcEnd-srcStart+1 94 | 95 | numDestDigits = getNumDigets(destIdsLen + blockSize) 96 | 97 | songTransfers = calcuateTransferMoves(currentSrcDir, srcLocalIds, destLocalIds, srcRemoteIds, destRemoteIds, srcStart, srcEnd, destIndex) 98 | 99 | ### Inform User 100 | logTransferInfo(songTransfers, srcPlName, destPlName, srcIdsLen, destIdsLen, srcRemoteIds, destRemoteIds, currentDestDir, srcStart, srcEnd, destIndex) 101 | 102 | ### Prompt User 103 | cfg.logger.info(f"\n------------[Prompt]-----------") 104 | answer = input("Prefrom Transfer? (y)es/(n)o: ").lower().strip() 105 | if answer != 'y': 106 | return 107 | 108 | cfg.logger.info("") 109 | 110 | #actual editing 111 | # make room for block 112 | for i in reversed(range(destIndex+1,destIdsLen)): 113 | oldName = currentDestDir[i] 114 | newIndex =i+blockSize 115 | relabel(destMetaData, cfg.logger.debug, destPlPath, oldName, i,newIndex, numDestDigits) 116 | 117 | 118 | endEarly = False 119 | songsCompleted = 0 120 | songsPartiallyCompleated = 0 121 | 122 | for i,move in enumerate(songTransfers): 123 | if endEarly: 124 | break 125 | 126 | cfg.logger.info(f"Transfering Song: {move.songName}") 127 | with noInterrupt: 128 | if move.performCopy: 129 | copyDestName = copy(srcMetaData, destMetaData, cfg.logger.debug, srcPlPath, destPlPath, move.srcCopyName, move.srcCopyIndex, move.destCopyIndex, numDestDigits) 130 | cfg.logger.debug(f"Locally Copied Song {move.songName} from {srcPlName} to {destPlName}") 131 | 132 | if move.performRemoteAdd: 133 | if not plAdder(move.songId, move.destRemoteAddIndex): 134 | if not move.performRemoteDelete: 135 | cfg.logger.error(f"Error When Adding {move.songName} to Remote Dest: {destPlName}, URL: {destPlUrl}") 136 | cfg.logger.error(f"This Song Does Not Occur in Remote Src: {srcPlName}, So It was Probably Removed from Youtube") 137 | cfg.logger.info(f"Continuing with Transfer") 138 | else: 139 | cfg.logger.error(f"Error When Adding {move.songName} to Remote Dest: {destPlName}, URL: {destPlUrl}") 140 | cfg.logger.error(f"Fix by Either:") 141 | cfg.logger.error(f"- (r)evert Transfer for This Song") 142 | cfg.logger.error(f"- (m)anually Adding https://www.youtube.com/watch?v={move.songId} to Remote Playlist Provided Above And (c)ontinuing or (f)inishing this song.") 143 | 144 | 145 | answer = promptAndSanitize("Would You Like to: (r)evert song, (m)anual fix, (q)uit: ", 'r', 'm', 'q') 146 | 147 | if answer == 'r': 148 | if move.performCopy: 149 | delete(destMetaData, destPlPath, copyDestName, move.destCopyIndex) 150 | answer = promptAndSanitize("Would You Like to finish the rest of the transfer (y)es/(n)o: ", 'y','n') 151 | 152 | if answer != 'y': 153 | break 154 | continue 155 | 156 | elif answer == 'q': 157 | songsPartiallyCompleated += 1 158 | break 159 | 160 | elif answer == 'm': 161 | cfg.logger.info(f"Please Add, {move.songName}: https://www.youtube.com/watch?v={move.songId} \nTo Playlist {destPlName}: {destPlUrl}") 162 | input("Hit Enter to Proceed: ") 163 | 164 | 165 | answer = promptAndSanitize("Would You Like to: (c)ontinue transfer, (f)inish this song, (q)uit: ", 'c', 'f', 'q') 166 | if answer == 'f': 167 | endEarly = True 168 | if answer == 'q': 169 | songsPartiallyCompleated += 1 170 | break 171 | 172 | 173 | if move.performLocalDelete: 174 | delete(srcMetaData, srcPlPath, move.srcLocalDeleteName, move.srcLocalDeleteIndex) 175 | cfg.logger.debug(f"Locally Deleted Song {move.songName} from {srcPlName}") 176 | 177 | if move.performRemoteDelete: 178 | if not (plRemover(move.srcRemoteDeleteIndex)): 179 | cfg.logger.error(f"Error When Removing {move.songName} from Remote Src: {srcPlName}, URL: {srcPlUrl}") 180 | cfg.logger.error(f"Fix by:") 181 | cfg.logger.error(f"- Manually Removing Song Index: {move.srcRemoteDeleteIndex} URL: https://www.youtube.com/watch?v={move.songId} from Remote Playlist Provided Above") 182 | input("Hit Enter to Proceed: ") 183 | answer = promptAndSanitize("Would You Like to: (c)ontinue transfer, (q)uit: ", 'c', 'q') 184 | if answer == 'q': 185 | songsPartiallyCompleated += 1 186 | break 187 | 188 | songsCompleted += 1 189 | 190 | #remove gaps, removeBlanks 191 | correctStateCorruption(srcPlPath, srcMetaData) 192 | correctStateCorruption(destPlPath, destMetaData) 193 | 194 | # end transfer summery: 195 | cfg.logger.info(f"{songsCompleted}/{len(songTransfers)} Songs Transfered Successfully") 196 | if songsPartiallyCompleated != 0: 197 | cfg.logger.error(f"{songsPartiallyCompleated} / {len(songTransfers)} Songs Had Issues with Transfering, logged above") 198 | 199 | cfg.logger.info("\nTransfer Complete.") 200 | return 201 | 202 | 203 | -------------------------------------------------------------------------------- /sync_dl/ytdlWrappers.py: -------------------------------------------------------------------------------- 1 | #import youtube_dl 2 | import yt_dlp as youtube_dl 3 | import os 4 | import time 5 | 6 | import shutil 7 | 8 | import sync_dl.config as cfg 9 | 10 | 11 | class MyLogger: 12 | def debug(self, msg): 13 | cfg.logger.debug(msg) 14 | 15 | def info(self, msg): 16 | cfg.logger.debug(msg) 17 | 18 | def warning(self, msg): 19 | cfg.logger.debug(msg) 20 | 21 | def error(self, msg): 22 | cfg.logger.debug(msg) 23 | 24 | 25 | #ids are the unique part of each videos url 26 | def getIDs(playlistUrl): 27 | try: 28 | params={"extract_flat": True, "quiet": True} 29 | params['logger'] = MyLogger() 30 | with youtube_dl.YoutubeDL(params) as ydl: 31 | result = ydl.extract_info(playlistUrl,download=False) 32 | ids = [] 33 | for videoData in result['entries']: 34 | ids.append(videoData["id"]) 35 | return ids 36 | except: 37 | return [] 38 | 39 | 40 | def getIdsAndTitles(url): 41 | ''' 42 | used to check for corrupted metadata in integration tests 43 | Title will differ from what is on youtube because it is sanitized for use in filenames 44 | ''' 45 | try: 46 | params={"extract_flat": True, "quiet": True, "outtmpl": f'%(title)s'} 47 | params['logger'] = MyLogger() 48 | with youtube_dl.YoutubeDL(params) as ydl: 49 | result = ydl.extract_info(url,download=False) 50 | ids = [] 51 | titles = [] 52 | for videoData in result['entries']: 53 | ids.append(videoData["id"]) 54 | titles.append(ydl.prepare_filename(videoData)) 55 | return ids, titles 56 | except: 57 | return [],[] 58 | 59 | 60 | def getTitle(url): 61 | ''' 62 | used to check for corrupted metadata in integration tests 63 | Title will differ from what is on youtube because it is sanitized for use in filenames 64 | ''' 65 | 66 | params = {} 67 | params['extract_flat'] = True 68 | params['quiet'] = True 69 | params["outtmpl"] = f'%(title)s' 70 | params['logger'] = MyLogger() 71 | 72 | with youtube_dl.YoutubeDL(params) as ydl: 73 | 74 | song = ydl.extract_info(url,download=False) 75 | 76 | title = ydl.prepare_filename(song) 77 | return title 78 | 79 | 80 | def downloadToTmp(videoId,numberStr): 81 | url = f"https://www.youtube.com/watch?v={videoId}" 82 | 83 | cfg.dlParams["outtmpl"] = f'{cfg.songDownloadPath}/{numberStr}_%(title)s.%(ext)s' 84 | cfg.dlParams['logger'] = MyLogger() 85 | 86 | with youtube_dl.YoutubeDL(cfg.dlParams) as ydl: 87 | 88 | cfg.clearTmpSubPath(cfg.songDownloadPath) 89 | 90 | attemptNumber = 1 91 | numAttempts = 2 92 | 93 | while True: 94 | try: 95 | ydl.download([url]) 96 | return True 97 | except Exception as e: 98 | cfg.logger.debug(e) 99 | cfg.logger.info(f"Unable to Download Song at {url}") 100 | if attemptNumber == numAttempts: 101 | cfg.logger.info(f"Max Download Retries Reached! If Song URL is Accessable Try Updating yt-dlp") 102 | return False 103 | cfg.logger.info("Retrying...") 104 | time.sleep(0.5) 105 | attemptNumber += 1 106 | 107 | 108 | def moveFromTmp(path): 109 | tmp = os.listdir(path=cfg.songDownloadPath) 110 | shutil.move(f"{cfg.songDownloadPath}/{tmp[0]}", path) 111 | return tmp[0] 112 | 113 | def getJsonPlData(url): 114 | '''returns list of dicts of data for each video in playlist at url (order is playlist order)''' 115 | params = {} 116 | params['extract_flat'] = True 117 | 118 | params['quiet'] = True 119 | params['logger'] = MyLogger() 120 | with youtube_dl.YoutubeDL(params) as ydl: 121 | try: 122 | entries = ydl.extract_info(url,download=False)['entries'] 123 | except: 124 | cfg.logger.error(f"No Playlist At URL: {url}") 125 | entries = [] 126 | return entries 127 | 128 | 129 | def _getBestThumbnail(thumbnails): 130 | maxWidth = 0 131 | best = None 132 | for thumbnail in thumbnails: 133 | width = thumbnail['width'] 134 | if width > maxWidth: 135 | maxWidth = width 136 | best = thumbnail['url'] 137 | 138 | if best is None: 139 | return None 140 | 141 | return best.split('?')[0] 142 | 143 | def getThumbnailUrls(url:str) -> dict: 144 | ''' 145 | used to check for corrupted metadata in integration tests 146 | Title will differ from what is on youtube because it is sanitized for use in filenames 147 | ''' 148 | 149 | params = { 150 | "extract_flat": True, 151 | "quiet": True, 152 | "simulate": True, 153 | #"list_thumbnails": True, 154 | "logger": MyLogger(), 155 | } 156 | 157 | with youtube_dl.YoutubeDL(params) as ydl: 158 | result = ydl.extract_info(url,download=False) 159 | 160 | try: 161 | entries = result['entries'] 162 | except KeyError: 163 | return {} 164 | 165 | thumbnails = {} 166 | for entry in entries: 167 | try: 168 | thumbnail = _getBestThumbnail(entry['thumbnails']) 169 | if thumbnail is None: 170 | continue 171 | thumbnails[entry['id']] = thumbnail 172 | except KeyError: 173 | continue 174 | 175 | return thumbnails 176 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import logging 3 | 4 | 5 | import argparse 6 | import sys 7 | 8 | import sync_dl.tests.unitTests as unitTests 9 | import sync_dl.tests.integrationTests as integrationTests 10 | 11 | import sync_dl.config as cfg 12 | 13 | 14 | def parseArgs(): 15 | description = ("unit and integration testing for sync-dl") 16 | 17 | 18 | parser = argparse.ArgumentParser(description=description) 19 | 20 | #posistional 21 | parser.add_argument('URL',nargs='?', type=str, help='the name of the directory for the playlist') 22 | 23 | 24 | parser.add_argument('-u','--unit', action='store_true', help='runs all unit tests') 25 | parser.add_argument('-i','--integration',action='store_true', help='tests integration using playlist at URL') 26 | 27 | parser.add_argument('-p','--print',action='store_true', help='prints to terminal in addition to tests/testing.log' ) 28 | 29 | args,other = parser.parse_known_args() 30 | 31 | sys.argv[1:] = other #additional unittest args 32 | 33 | return args 34 | 35 | def setLogging(args): 36 | formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") 37 | fh = logging.FileHandler(f"{cfg.modulePath}/tests/testing.log") 38 | fh.setFormatter(formatter) 39 | 40 | cfg.logger.addHandler(fh) 41 | 42 | if args.print: 43 | sh = logging.StreamHandler() 44 | sh.setFormatter(formatter) 45 | cfg.logger.addHandler(sh) 46 | 47 | cfg.logger.setLevel(level=logging.DEBUG) 48 | 49 | if __name__ == "__main__": 50 | args = parseArgs() 51 | 52 | setLogging(args) 53 | 54 | runall = not (args.unit or args.integration) 55 | 56 | #checks if integration test has to run (whenever -i is used, or if all tests are run) 57 | if args.integration or runall: 58 | # if no playlist was provided all further functions cannot run 59 | if not args.URL: 60 | print("URL Required for Integration Testing, Use -u to Run Only Unit Tests") 61 | exit() 62 | integrationTests.test_integration.PL_URL = args.URL 63 | 64 | if runall: 65 | success = unittest.main(unitTests,exit=False).result.wasSuccessful() 66 | if not success: 67 | sys.exit(1) 68 | unittest.main(integrationTests,failfast=True) 69 | 70 | elif args.unit: 71 | unittest.main(unitTests) 72 | 73 | elif args.integration: 74 | unittest.main(integrationTests,failfast=True) 75 | --------------------------------------------------------------------------------