├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── devrun.py ├── putiosync ├── __init__.py ├── core.py ├── dbmodel.py ├── download_manager.py ├── frontend.py ├── multipart_downloader.py ├── watcher.py └── webif │ ├── __init__.py │ ├── static │ ├── blacktocat.png │ ├── css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap-theme.min.css │ │ ├── bootstrap.css │ │ └── bootstrap.min.css │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff │ └── js │ │ ├── bootstrap.js │ │ ├── bootstrap.min.js │ │ ├── handlebars-v1.3.0.js │ │ └── jquery.flot.js │ ├── templates │ ├── active.html │ ├── base.html │ ├── history.html │ └── macros.html │ ├── transmissionrpc.py │ └── webif.py ├── requirements.txt ├── setup.cfg └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | **/node_modules 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # Editor files 39 | .idea 40 | *~ 41 | \#*\# 42 | 43 | # virtualenv 44 | env 45 | 46 | # application specific 47 | putiosync.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | ADD . /putio-sync 3 | WORKDIR /putio-sync 4 | RUN pip install . 5 | # Set environment variable PUTIO_SYNC_ARGS to pass additional arguments 6 | CMD putiosync $PUTIO_SYNC_ARGS /volumes/putio_download 7 | VOLUME "/volumes/putio_download" 8 | # Default http port 9 | EXPOSE 7001/tcp 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Paul Osborne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include LICENSE 3 | recursive-include putiosync/webif/static *.html *.css *.js *.png 4 | recursive-include putiosync/webif/templates *.html 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | putio-sync 2 | ========== 3 | 4 | Script for automatically downloading files from put.io 5 | 6 | Installation and Usage 7 | ---------------------- 8 | 9 | Installation can be performed via pip:: 10 | 11 | $ pip install putiosync 12 | 13 | This will install a new application 'putiosync' that can be called from the command 14 | line as follows:: 15 | 16 | $ putiosync 17 | 18 | Other options and customizations are available by using the '-h' or '--help' options. 19 | 20 | Authentication 21 | -------------- 22 | 23 | The first time you run the application, a webbrowser will be opened to 24 | put.io asking for permissions. If authorized, you'll get your access 25 | token which you will enter into the application. 26 | 27 | Docker Container 28 | ---------------- 29 | 30 | The script can also be run using the containing Dockerfile. 31 | 32 | You need to map the volume `/volume/putio_download` to a path on your host system. 33 | Additional parameters can be passed by setting the `PUTIO_SYNC_ARGS` environment variable with all the arguments. 34 | 35 | It is recommended to also set the environment variable `PUTIO_SYNC_SETTINGS_DIR` to a path mapped to the host. Otherwise you will loose all the settings after a container update. 36 | In there create a file called `putiosync.json`. The content is then the authentication token from here: 37 | https://app.put.io/authenticate?client_id=1067&response_type=oob 38 | 39 | Json file content: 40 | ``` 41 | {"token": "YOUR_AUTH_TOKEN"} 42 | ``` 43 | 44 | Alternatively you can: 45 | 46 | - run the docker container with an interactive bash to provide the auth token: 47 | ``` 48 | docker run -t -i putio-sync 49 | ``` 50 | - set the environment variable `PUTIO_SYNC_TOKEN`. This is not recommended since it is a security risk (The token is listed in the process list) 51 | 52 | You may need to set a manual port mapping. Putio Sync is listening on the TCP port 7001 53 | 54 | Contributing Back 55 | ----------------- 56 | 57 | * Found a bug? Create an issue on github. 58 | * Fixed a bug or added a feature? Fork the project on github and 59 | submit a pull request 60 | -------------------------------------------------------------------------------- /devrun.py: -------------------------------------------------------------------------------- 1 | from putiosync import frontend 2 | 3 | if __name__ == "__main__": 4 | frontend.main() 5 | -------------------------------------------------------------------------------- /putiosync/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Paul Osborne' 2 | __version__ = '0.2.3' 3 | -------------------------------------------------------------------------------- /putiosync/core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Program for automatically downloading and removing files that are 4 | # successfully downloaded from put.io. 5 | # 6 | import json 7 | import datetime 8 | import logging 9 | import traceback 10 | import progressbar 11 | from putiosync.dbmodel import DBModelBase, DownloadRecord 12 | from putiosync.download_manager import Download 13 | import webbrowser 14 | import time 15 | import os 16 | import sys 17 | from sqlalchemy import create_engine, exists 18 | from sqlalchemy.orm import scoped_session 19 | from sqlalchemy.orm.session import sessionmaker 20 | from os import environ 21 | 22 | logger = logging.getLogger("putiosync") 23 | 24 | 25 | CLIENT_ID = 1067 26 | if environ.get('PUTIO_SYNC_SETTINGS_DIR') is not None: 27 | SETTINGS_DIR = environ.get('PUTIO_SYNC_SETTINGS_DIR') 28 | else: 29 | HOME_DIR = os.path.expanduser("~") 30 | SETTINGS_DIR = os.path.join(HOME_DIR, ".putiosync") 31 | 32 | SYNC_FILE = os.path.join(SETTINGS_DIR, "putiosync.json") 33 | DATABASE_FILE = os.path.join(SETTINGS_DIR, "putiosync.db") 34 | CHECK_PERIOD_SECONDS = 10 35 | 36 | 37 | class DatabaseManager(object): 38 | 39 | def __init__(self): 40 | self._db_engine = None 41 | self._scoped_session = None 42 | self._ensure_database_exists() 43 | 44 | def _ensure_database_exists(self): 45 | if not os.path.exists(SETTINGS_DIR): 46 | os.makedirs(SETTINGS_DIR) 47 | self._db_engine = create_engine("sqlite:///{}".format(DATABASE_FILE)) 48 | self._db_engine.connect() 49 | self._scoped_session = scoped_session(sessionmaker(self._db_engine)) 50 | DBModelBase.metadata.create_all(self._db_engine) 51 | 52 | def get_db_session(self): 53 | return self._scoped_session() 54 | 55 | 56 | class TokenManager(object): 57 | """Object responsible for providing access to API token""" 58 | 59 | def is_valid_token(self, token): 60 | return (token is not None and len(token) > 0) 61 | 62 | def save_token(self, token): 63 | """Save the provided token to disk""" 64 | if not os.path.exists(SETTINGS_DIR): 65 | os.makedirs(SETTINGS_DIR) 66 | with open(SYNC_FILE, "w") as f: 67 | f.write(json.dumps({"token": token})) 68 | 69 | def get_token(self): 70 | """Restore token from disk or return None if not present""" 71 | if environ.get('PUTIO_SYNC_TOKEN') is not None: 72 | return environ.get('PUTIO_SYNC_TOKEN') 73 | try: 74 | with open(SYNC_FILE, "r") as f: 75 | jsondata = f.read() 76 | return json.loads(jsondata)["token"] 77 | except (OSError, IOError): 78 | return None 79 | 80 | def obtain_token(self): 81 | """Obtain token from the user using put.io apptoken URL 82 | """ 83 | apptoken_url = "https://app.put.io/authenticate?client_id={}&response_type=oob".format(CLIENT_ID) 84 | print("Opening {}".format(apptoken_url)) 85 | webbrowser.open(apptoken_url) 86 | if sys.version[0]=="2": 87 | input_sock=raw_input 88 | else: 89 | input_sock=input 90 | token = input_sock("Enter token: ").strip() 91 | return token 92 | 93 | 94 | class PutioSynchronizer(object): 95 | """Object encapsulating core synchronization logic and state""" 96 | 97 | def __init__(self, download_directory, putio_client, db_manager, download_manager, keep_files=False, poll_frequency=60, 98 | download_filter=None, force_keep=None, disable_progress=False): 99 | self._putio_client = putio_client 100 | self._download_directory = download_directory 101 | self._db_manager = db_manager 102 | self._poll_frequency = poll_frequency 103 | self._keep_files = keep_files 104 | self._download_manager = download_manager 105 | # This regex is already compiled 106 | self.download_filter = download_filter 107 | self.force_keep = force_keep 108 | self.disable_progress = disable_progress 109 | 110 | def get_download_directory(self): 111 | return self._download_directory 112 | 113 | def _is_directory(self, putio_file): 114 | return (putio_file.content_type == 'application/x-directory') 115 | 116 | def _already_downloaded(self, putio_file, dest): 117 | filename = putio_file.name 118 | logger.warn("File name check: %r", filename) 119 | 120 | if os.path.exists(os.path.join(dest, filename)): 121 | return True # TODO: check size and/or crc32 checksum? 122 | matching_rec_exists = self._db_manager.get_db_session().query(exists().where(DownloadRecord.file_id == putio_file.id)).scalar() 123 | return matching_rec_exists 124 | 125 | def is_already_downloaded(self, putio_file): 126 | return self._already_downloaded(putio_file, self._download_directory) 127 | 128 | def _record_downloaded(self, putio_file): 129 | filename = putio_file.name 130 | matching_rec_exists = self._db_manager.get_db_session().query(exists().where(DownloadRecord.file_id == putio_file.id)).scalar() 131 | if not matching_rec_exists: 132 | download_record = DownloadRecord( 133 | file_id=putio_file.id, 134 | size=putio_file.size, 135 | timestamp=datetime.datetime.now(), 136 | name=filename) 137 | self._db_manager.get_db_session().add(download_record) 138 | self._db_manager.get_db_session().commit() 139 | else: 140 | logger.warn("File with id %r already marked as downloaded!", putio_file.id) 141 | 142 | def _do_queue_download(self, putio_file, dest, delete_after_download=False): 143 | if dest.endswith("..."): 144 | dest = dest[:-3] 145 | 146 | if not self._already_downloaded(putio_file, dest): 147 | if not os.path.exists(dest): 148 | os.makedirs(dest) 149 | 150 | download = Download(putio_file, dest) 151 | total = putio_file.size 152 | if not self.disable_progress: 153 | widgets = [ 154 | progressbar.Percentage(), ' ', 155 | progressbar.Bar(), ' ', 156 | progressbar.ETA(), ' ', 157 | progressbar.FileTransferSpeed()] 158 | pbar = progressbar.ProgressBar(widgets=widgets, maxval=total) 159 | 160 | def start_callback(_download): 161 | logger.info("Starting download {}".format(putio_file.name)) 162 | if not self.disable_progress: 163 | pbar.start() 164 | 165 | def progress_callback(_download): 166 | try: 167 | pbar.update(download.get_downloaded()) 168 | except AssertionError: 169 | pass # ignore, has happened 170 | 171 | def completion_callback(_download): 172 | # and write a record of the download to the database 173 | self._record_downloaded(putio_file) 174 | logger.info("Download finished: {}".format(putio_file.name)) 175 | if delete_after_download: 176 | try: 177 | putio_file.delete() 178 | except: 179 | logger.error("Error deleting file {}. Assuming all is well but may require manual cleanup".format(putio_file.name)) 180 | traceback.print_exc() 181 | 182 | download.add_start_callback(start_callback) 183 | if self.disable_progress is False: 184 | download.add_progress_callback(progress_callback) 185 | download.add_completion_callback(completion_callback) 186 | self._download_manager.add_download(download) 187 | else: 188 | logger.debug("Already downloaded: '{}'".format(putio_file.name)) 189 | if delete_after_download: 190 | try: 191 | putio_file.delete() 192 | except: 193 | logger.error("Error deleting file... assuming all is well but may require manual cleanup") 194 | traceback.print_exc() 195 | 196 | 197 | 198 | def _queue_download(self, putio_file, relpath="", level=0): 199 | # add this file (or files in this directory) to the queue 200 | 201 | full_path = os.path.sep + os.path.join(relpath, putio_file.name) 202 | full_path = full_path.replace("\\", "/") 203 | if not self._is_directory(putio_file): 204 | if self.download_filter is not None and self.download_filter.match(full_path) is None: 205 | logger.debug("Skipping '{0}' because it does not match the provided filter".format(full_path)) 206 | else: 207 | logger.debug("Adding download to queue: '{0}'".format(full_path)) 208 | target_dir = os.path.join(self._download_directory, relpath) 209 | delete_file = not self._keep_files and (self.force_keep is None or self.force_keep.match(full_path) is None) 210 | self._do_queue_download(putio_file, target_dir, delete_after_download=delete_file) 211 | else: 212 | children = putio_file.dir() 213 | if not children: 214 | # this is a directory with no children, it must be destroyed 215 | if self.force_keep is None or self.force_keep.match(full_path) is None: 216 | putio_file.delete() 217 | else: 218 | for child in children: 219 | self._queue_download(child, os.path.join(relpath, putio_file.name), level + 1) 220 | 221 | def _perform_single_check(self): 222 | try: 223 | # Perform a single check for updated files to download 224 | for putio_file in self._putio_client.File.list(): 225 | self._queue_download(putio_file) 226 | except Exception as ex: 227 | logger.error("Unexpected error while performing check/download: {}".format(ex)) 228 | logger.error("File checked: {}".format(putio_file.name)) 229 | 230 | def _wait_until_downloads_complete(self): 231 | while not self._download_manager.is_empty(): 232 | time.sleep(0.5) 233 | 234 | def run_forever(self): 235 | """Run the synchronizer until killed""" 236 | logger.warn("Starting main application") 237 | while True: 238 | self._perform_single_check() 239 | last_check = datetime.datetime.now() 240 | self._wait_until_downloads_complete() 241 | time_since_last_check = datetime.datetime.now() - last_check 242 | if time_since_last_check < datetime.timedelta(seconds=self._poll_frequency): 243 | time.sleep(self._poll_frequency - time_since_last_check.total_seconds()) 244 | 245 | -------------------------------------------------------------------------------- /putiosync/dbmodel.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Column, String, DateTime 2 | from sqlalchemy.ext.declarative import declarative_base 3 | 4 | DBModelBase = declarative_base() 5 | 6 | 7 | class DownloadRecord(DBModelBase): 8 | __tablename__ = 'download_history' 9 | id = Column(Integer, primary_key=True) 10 | file_id = Column(Integer, unique=True) 11 | size = Column(Integer) 12 | timestamp = DateTime() 13 | name = Column(String) 14 | -------------------------------------------------------------------------------- /putiosync/download_manager.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | import threading 3 | import time 4 | import datetime 5 | import putiopy 6 | import os 7 | from putiosync import multipart_downloader 8 | 9 | 10 | class Download(object): 11 | """Object containing information about a download to be performed""" 12 | 13 | def __init__(self, putio_file, destination_path): 14 | self._putio_file = putio_file 15 | self._destination_directory = destination_path 16 | self._progress_callbacks = set() 17 | self._start_callbacks = set() 18 | self._completion_callbacks = set() 19 | self._downloaded = 0 20 | self._start_datetime = None 21 | self._finish_datetime = None 22 | 23 | def _fire_progress_callbacks(self): 24 | for cb in list(self._progress_callbacks): 25 | cb(self) 26 | 27 | def _fire_start_callbacks(self): 28 | for cb in list(self._start_callbacks): 29 | cb(self) 30 | 31 | def _fire_completion_callbacks(self): 32 | for cb in list(self._completion_callbacks): 33 | cb(self) 34 | 35 | def get_putio_file(self): 36 | return self._putio_file 37 | 38 | def get_destination_directory(self): 39 | return self._destination_directory 40 | 41 | def get_filename(self): 42 | return self.get_putio_file().name.encode('utf-8', 'ignore') 43 | 44 | def get_destination_path(self): 45 | return os.path.join(os.path.abspath(self._destination_directory), 46 | self.get_filename()) 47 | 48 | def get_downloaded(self): 49 | return self._downloaded 50 | 51 | def get_size(self): 52 | return self._putio_file.size 53 | 54 | def get_start_datetime(self): 55 | return self._start_datetime 56 | 57 | def get_finish_datetime(self): 58 | return self._finish_datetime 59 | 60 | def add_start_callback(self, start_callback): 61 | """Add a callback to be called when there is new progress to report on a download 62 | 63 | The callback will be called as follows:: 64 | 65 | progress_callback(download) 66 | 67 | Information about the progress itself will be stored with the download. 68 | 69 | """ 70 | self._start_callbacks.add(start_callback) 71 | 72 | def add_progress_callback(self, progress_callback): 73 | """Add a callback to be called whenever a new download is started 74 | 75 | The callback will be called as follows:: 76 | 77 | start_callback(download) 78 | 79 | """ 80 | self._progress_callbacks.add(progress_callback) 81 | 82 | def add_completion_callback(self, completion_callback): 83 | """Add a callback to be called whenever a download completes 84 | 85 | The callback will be called as follows:: 86 | 87 | completion_callback(download) 88 | 89 | """ 90 | self._completion_callbacks.add(completion_callback) 91 | 92 | def perform_download(self, token): 93 | self._start_datetime = datetime.datetime.now() 94 | self._fire_start_callbacks() 95 | putio_file = self.get_putio_file() 96 | dest = self.get_destination_directory() 97 | filename = self.get_filename() 98 | 99 | final_path = os.path.join(dest, filename.decode('utf-8')) 100 | download_path = "{}.part".format(final_path.encode('utf-8')) 101 | 102 | # ensure the path into which the download is going to be donwloaded exists. We know 103 | # that the 'dest' directory exists but in some cases the filename on put.io may 104 | # have directories within it (for an archive, as an example). In addition, some 105 | # post-processing may delete directories, so let's just recreate the directory 106 | if not os.path.exists(os.path.dirname(download_path)): 107 | os.makedirs(os.path.dirname(download_path)) 108 | 109 | success = False 110 | with open(download_path, 'wb') as f: 111 | def transfer_callback(offset, chunk): 112 | self._downloaded += len(chunk) 113 | f.seek(offset) 114 | f.write(chunk) 115 | f.flush() 116 | self._fire_progress_callbacks() 117 | 118 | success = multipart_downloader.download( 119 | putiopy.BASE_URL + '/files/{}/download'.format(putio_file.id), 120 | self.get_size(), 121 | transfer_callback, 122 | params={'oauth_token': token}) 123 | 124 | # download to part file is complete. Now move to its final destination 125 | if success: 126 | if os.path.exists(final_path): 127 | os.remove(final_path) 128 | os.rename(download_path, download_path[:-5]) # same but without '.part' 129 | self._finish_datetime = datetime.datetime.now() 130 | self._fire_completion_callbacks() 131 | 132 | return success 133 | 134 | 135 | class DownloadManager(threading.Thread): 136 | """Component responsible for managing the queue of things to be downloaded""" 137 | 138 | def __init__(self, token): 139 | threading.Thread.__init__(self, name="DownloadManager") 140 | self.setDaemon(True) 141 | self._token = token 142 | self._download_queue_lock = threading.RLock() # also used for locking calllback lists 143 | self._download_queue = deque() 144 | self._progress_callbacks = set() 145 | self._start_callbacks = set() 146 | self._completion_callbacks = set() 147 | self._has_exit = False 148 | 149 | def _build_callback(self, callbacks): 150 | def callback(*args, **kwargs): 151 | with self._download_queue_lock: 152 | for cb in callbacks: 153 | cb(*args, **kwargs) 154 | return callback 155 | 156 | def start(self): 157 | """Start this donwload manager""" 158 | threading.Thread.start(self) 159 | 160 | def add_download(self, download): 161 | """Add a download to be performed by this download manager""" 162 | if not isinstance(download, Download): 163 | raise TypeError("download must be of type QueuedDownload") 164 | with self._download_queue_lock: 165 | download.add_start_callback(self._build_callback(self._start_callbacks)) 166 | download.add_progress_callback(self._build_callback(self._progress_callbacks)) 167 | download.add_completion_callback(self._build_callback(self._completion_callbacks)) 168 | self._download_queue.append(download) 169 | 170 | def add_download_start_progress(self, start_callback): 171 | """Add a callback to be called whenever a new download is started 172 | 173 | The callback will be called as follows:: 174 | 175 | start_callback(download) 176 | 177 | """ 178 | with self._start_callbacks: 179 | self._start_callbacks.add(start_callback) 180 | 181 | def add_download_progress_callback(self, progress_callback): 182 | """Add a callback to be called when there is new progress to report on a download 183 | 184 | The callback will be called as follows:: 185 | 186 | progress_callback(download) 187 | 188 | Information about the progress itself will be stored with the download. 189 | 190 | """ 191 | with self._download_queue_lock: 192 | self._progress_callbacks.add(progress_callback) 193 | 194 | def add_download_completion_callback(self, completion_callback): 195 | """Add a callback to be called whenever a download completes 196 | 197 | The callback will be called as follows:: 198 | 199 | completion_callback(download) 200 | 201 | """ 202 | with self._download_queue_lock: 203 | self._completion_callbacks.add(completion_callback) 204 | 205 | def get_downloads(self): 206 | """Get a list of the downloads active at this time""" 207 | with self._download_queue_lock: 208 | return list(self._download_queue) 209 | 210 | def is_empty(self): 211 | """Return True if there are no queued downloads""" 212 | with self._download_queue_lock: 213 | return len(self._download_queue) == 0 214 | 215 | def run(self): 216 | """Main loop for the download manager""" 217 | while not self._has_exit: 218 | try: 219 | download = self._download_queue[0] # keep in queue until complete 220 | except IndexError: 221 | time.sleep(0.5) # don't busily spin 222 | else: 223 | success = download.perform_download(self._token) 224 | self._download_queue.popleft() 225 | if not success: 226 | # re-add to the end of the queue for retry but do not keep any state that may have been 227 | # associated with the failed download 228 | self.add_download(Download(download.get_putio_file(), 229 | download.get_destination_path())) 230 | -------------------------------------------------------------------------------- /putiosync/frontend.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import shlex 3 | import sys 4 | import threading 5 | import subprocess 6 | import putiopy 7 | import re 8 | import logging 9 | from pid import PidFile 10 | from putiosync.core import TokenManager, PutioSynchronizer, DatabaseManager 11 | from putiosync.download_manager import DownloadManager 12 | from putiosync.watcher import TorrentWatcher 13 | from putiosync.webif.webif import WebInterface 14 | 15 | __author__ = 'Paul Osborne' 16 | 17 | logger = logging.getLogger("putiosync") 18 | logger.setLevel(logging.ERROR) 19 | 20 | 21 | def parse_arguments(): 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument( 24 | "-k", "--keep", 25 | action="store_true", 26 | default=False, 27 | help="Keep files on put.io; do not automatically delete" 28 | ) 29 | parser.add_argument( 30 | "--force-keep", 31 | default=None, 32 | type=str, 33 | help=( 34 | "Filter for skipping deletion of specific files/folders. " 35 | "If keep parameter is set to false, only files/folders will be deleted which " 36 | "do not match the given regex. " 37 | "Example: putio-sync -force-keep=\"^/Series$\" /path/to/Downloads" 38 | ) 39 | ) 40 | parser.add_argument( 41 | "-q", "--quiet", 42 | action="store_true", 43 | default=False, 44 | help="Prevent browser from launching on start." 45 | ) 46 | parser.add_argument( 47 | "-p", "--poll-frequency", 48 | default=60 * 3, 49 | type=int, 50 | help="Polling frequency in seconds (default: 3 minutes)", 51 | ) 52 | parser.add_argument( 53 | "--pid", 54 | default=None, 55 | type=str, 56 | help="Path where the pid file should be created (default: None)", 57 | ) 58 | parser.add_argument( 59 | "--log", 60 | default=None, 61 | type=str, 62 | help="Path where the log file should be stored (default: None)", 63 | ) 64 | parser.add_argument( 65 | "--log-webif", 66 | default=None, 67 | type=str, 68 | help="Path where the log file for the web interface should be stored (default: None)", 69 | ) 70 | parser.add_argument( 71 | "--log-level", 72 | default="debug", 73 | type=str, 74 | help="Loglevel [debug, info, warning, error, critical] (default: debug)", 75 | ) 76 | parser.add_argument( 77 | "-c", "--post-process-command", 78 | default=None, 79 | type=str, 80 | help=( 81 | "Command to be executed after the completion of every download. " 82 | "The command will be executed with the path to the file that has " 83 | "just been completed as an argument. " 84 | "Example: putio-sync -c 'python /path/to/postprocess.py' /path/to/Downloads" 85 | ), 86 | ) 87 | parser.add_argument( 88 | "-w", "--watch-directory", 89 | default=None, 90 | type=str, 91 | help=( 92 | "Directory to watch for torrent or magnet files. If this option is " 93 | "present and new files are added, they will be added to put.io and " 94 | "automatically downloaded by the daemon when complete." 95 | ) 96 | ) 97 | parser.add_argument( 98 | "--host", 99 | default="0.0.0.0", 100 | type=str, 101 | help="Host where the webserver should listen to. Default: 0.0.0.0" 102 | ) 103 | parser.add_argument( 104 | "--port", 105 | default=7001, 106 | type=int, 107 | help="Port where the webserver should listen to. Default: 7001" 108 | ) 109 | parser.add_argument( 110 | "-f", "--filter", 111 | default=None, 112 | type=str, 113 | help=( 114 | "Filter for excluding or including specific files/folders from downloading. " 115 | "The filter is a regular expression (regex). " 116 | "Example: putio-sync -f '/some/folder/*.avi' /path/to/Downloads" 117 | ) 118 | ) 119 | parser.add_argument( 120 | "download_directory", 121 | help="Directory into which files should be downloaded" 122 | ) 123 | args = parser.parse_args() 124 | return args 125 | 126 | 127 | def build_postprocess_download_completion_callback(postprocess_command): 128 | def download_completed(download): 129 | cmd=postprocess_command.format(download.get_destination_path().encode('utf-8')) 130 | logger.info("Postprocess: {0}".format(cmd)) 131 | subprocess.call(cmd, shell=True) 132 | 133 | return download_completed 134 | 135 | def start_sync(args): 136 | 137 | formatter = logging.Formatter('%(asctime)s | %(name)-12s | %(levelname)-8s | %(message)s') 138 | 139 | log_level = logging.ERROR 140 | if args.log_level is not None: 141 | if args.log_level == "debug": 142 | log_level = logging.DEBUG 143 | elif args.log_level == "info": 144 | log_level = logging.INFO 145 | elif args.log_level == "warning": 146 | log_level = logging.WARNING 147 | elif args.log_level == "error": 148 | log_level = logging.ERROR 149 | elif args.log_level == "critical": 150 | log_level = logging.CRITICAL 151 | else: 152 | print("Invalid log-level argument") 153 | 154 | 155 | 156 | ch = logging.StreamHandler() 157 | ch.setLevel(log_level) 158 | ch.setFormatter(formatter) 159 | 160 | if args.log is not None: 161 | fh = logging.FileHandler(args.log) 162 | fh.setLevel(log_level) 163 | fh.setFormatter(formatter) 164 | logger.addHandler(fh) 165 | else: 166 | logger.addHandler(ch) 167 | 168 | 169 | log_webif = logging.getLogger('werkzeug') 170 | log_webif.setLevel(log_level) 171 | log_webif.disabled = True 172 | 173 | if args.log_webif is not None: 174 | fh = logging.FileHandler(args.log_webif) 175 | fh.setLevel(log_level) 176 | fh.setFormatter(formatter) 177 | log_webif.addHandler(fh) 178 | else: 179 | log_webif.addHandler(ch) 180 | 181 | # Restore or obtain a valid token 182 | token_manager = TokenManager() 183 | token = token_manager.get_token() 184 | while not token_manager.is_valid_token(token): 185 | print("No valid token found! Please provide one.") 186 | token = token_manager.obtain_token() 187 | token_manager.save_token(token) 188 | 189 | # Let's start syncing! 190 | putio_client = putiopy.Client(token) 191 | db_manager = DatabaseManager() 192 | download_manager = DownloadManager(token=token) 193 | if args.post_process_command is not None: 194 | download_manager.add_download_completion_callback( 195 | build_postprocess_download_completion_callback(args.post_process_command)) 196 | 197 | if args.watch_directory is not None: 198 | torrent_watcher = TorrentWatcher(args.watch_directory, putio_client) 199 | torrent_watcher.start() 200 | 201 | filter_compiled = None 202 | if args.filter is not None: 203 | try: 204 | filter_compiled = re.compile(args.filter) 205 | except re.error as e: 206 | print("Invalid filter regex: {0}".format(e)) 207 | exit(1) 208 | 209 | force_keep_compiled = None 210 | if args.force_keep is not None: 211 | try: 212 | force_keep_compiled = re.compile(args.force_keep) 213 | except re.error as e: 214 | print("Invalid force_keep regex: {0}".format(e)) 215 | exit(1) 216 | 217 | download_manager.start() 218 | synchronizer = PutioSynchronizer( 219 | download_directory=args.download_directory, 220 | putio_client=putio_client, 221 | db_manager=db_manager, 222 | download_manager=download_manager, 223 | keep_files=args.keep, 224 | poll_frequency=args.poll_frequency, 225 | download_filter=filter_compiled, 226 | force_keep=force_keep_compiled, 227 | disable_progress=args.log is not None) 228 | t = threading.Thread(target=synchronizer.run_forever) 229 | t.setDaemon(True) 230 | t.start() 231 | web_interface = WebInterface(db_manager, download_manager, putio_client, synchronizer, launch_browser=(not args.quiet), host=args.host, port=args.port) 232 | web_interface.run() 233 | 234 | def main(): 235 | args = parse_arguments() 236 | 237 | if args.pid is not None: 238 | with PidFile(args.pid): 239 | return start_sync(args) 240 | else: 241 | return start_sync(args) 242 | 243 | if __name__ == '__main__': 244 | sys.exit(main()) 245 | -------------------------------------------------------------------------------- /putiosync/multipart_downloader.py: -------------------------------------------------------------------------------- 1 | """Implementation for multi-segment file downloader 2 | 3 | This can sometimes significiantly increase the overall speed of a download 4 | and is a technique commonly used by download managers (like DownThemAll) 5 | to get better download performance. Robustness can also be increased 6 | (although this implementation is pretty simple). 7 | 8 | Also, it should be noted that this has been tested with downloads provided 9 | by put.io, but your mileage may vary for other servers. 10 | 11 | """ 12 | from queue import Queue 13 | import threading 14 | import requests 15 | 16 | __author__ = "Paul Osborne" 17 | 18 | 19 | class _MultiSegmentDownloadWorker(threading.Thread): 20 | """Worker thread responsible for carrying out smaller chunks of work""" 21 | 22 | def __init__(self, url, worker_num, work_queue, completion_queue, request_kwargs): 23 | threading.Thread.__init__(self, name="Worker on {} #{}".format(url, worker_num)) 24 | self.setDaemon(True) 25 | self._url = url 26 | self._worker_num = worker_num 27 | self._told_to_stop = False 28 | self._work_queue = work_queue 29 | self._completion_queue = completion_queue 30 | self._request_kwargs = request_kwargs 31 | 32 | def stop(self): 33 | self._told_to_stop = True 34 | 35 | def _download_segment(self, segment): 36 | kwds = self._request_kwargs.copy() 37 | response = requests.request( 38 | method="GET", 39 | url=self._url, 40 | headers={ 41 | "Range": segment.build_range_header() 42 | }, 43 | stream=True, 44 | **self._request_kwargs) 45 | 46 | offset = segment.offset 47 | for chunk in response.iter_content(chunk_size=2 * 1024): 48 | if chunk: 49 | self._completion_queue.put((offset, chunk)) 50 | offset += len(chunk) 51 | 52 | def run(self): 53 | while not self._told_to_stop: 54 | segment = self._work_queue.get() 55 | if segment is None: 56 | break 57 | else: 58 | self._download_segment(segment) 59 | self._completion_queue.put(None) 60 | 61 | 62 | class _Segment(object): 63 | """Model information about a segment that a worker will need""" 64 | 65 | def __init__(self, offset, size, is_last_segment): 66 | self.offset = offset 67 | self.size = size 68 | self.is_last_segment = is_last_segment 69 | 70 | def build_range_header(self): 71 | """Build an http range header for this segment""" 72 | if self.is_last_segment: 73 | return "bytes={}-{}".format(self.offset, "") 74 | else: 75 | return "bytes={}-{}".format(self.offset, self.offset + self.size - 1) 76 | 77 | 78 | def download(url, size, transfer_callback, num_workers=4, segment_size_bytes=200 * 1024 * 1024, **kwargs): 79 | """Start the download with this downloads settings 80 | 81 | As multi-segment downloads are really only useful for very large 82 | downloads, we provide a callback that will be called whenever we 83 | have finished downloading a chunk of data. The callback will be 84 | called with the following form:: 85 | 86 | transfer_callback(offset, data) 87 | 88 | Where offset is the byte offset into the file being downloaded 89 | and data is the data for that chunk. 90 | 91 | """ 92 | work_queue = Queue() 93 | completion_queue = Queue() 94 | 95 | num_workers = min(num_workers, int(size / segment_size_bytes) + 1) 96 | 97 | # create workers and start them 98 | workers = [_MultiSegmentDownloadWorker(url, i + 1, work_queue, completion_queue, kwargs) 99 | for i in range(num_workers)] 100 | for worker in workers: 101 | worker.start() 102 | 103 | # create each segment and put it in the queue 104 | pos = 0 105 | while pos + segment_size_bytes < size: 106 | # Note that math on pos is exclusive, on segment in inclusive. That means that downloading 107 | # a segment of size 1000 is range 0-999. This detail is accounted for in the _Segment 108 | # implementation itself (build_range_header). 109 | seg = _Segment(offset=pos, size=segment_size_bytes, is_last_segment=False) 110 | work_queue.put(seg) 111 | pos += segment_size_bytes 112 | if pos < size: 113 | seg = _Segment(offset=pos, size=size - pos, is_last_segment=True) 114 | work_queue.put(seg) 115 | 116 | # queue up one None for each worker to let it know that things are complete 117 | for _ in range(num_workers): 118 | work_queue.put(None) 119 | 120 | error_occurred = False 121 | workers_completed = 0 122 | while not error_occurred: 123 | msg = completion_queue.get() 124 | if msg is None: # a worker just finished 125 | workers_completed += 1 126 | if workers_completed == num_workers: 127 | break 128 | else: 129 | offset, chunk = msg 130 | try: 131 | transfer_callback(offset, chunk) 132 | except: 133 | error_occurred = True 134 | 135 | if error_occurred: 136 | for worker in workers: 137 | worker.stop() # halt now 138 | 139 | for worker in workers: 140 | worker.join() 141 | 142 | success = not error_occurred 143 | return success 144 | -------------------------------------------------------------------------------- /putiosync/watcher.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from watchdog.observers import Observer 5 | from watchdog.events import FileSystemEventHandler 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def is_torrent(ext): 11 | return ext in (".torrent", ".magnet") 12 | 13 | 14 | class TorrentWatcherFilesystemEventHandler(FileSystemEventHandler): 15 | """This class handles filesystem changes to monitored directories 16 | 17 | This will filter events and queue up torrents to be downloaded 18 | if a new torrent or magnet file is added to the monitored directories. 19 | 20 | """ 21 | 22 | def __init__(self, putio_client): 23 | FileSystemEventHandler.__init__(self) 24 | self._putio_client = putio_client 25 | 26 | def on_created(self, event): 27 | if not event.is_directory: 28 | basename = os.path.basename(event.src_path) 29 | _name, ext = os.path.splitext(basename) 30 | if is_torrent(ext): 31 | logger.info("Adding torrent from path '%s'", event.src_path) 32 | self._putio_client.Transfer.add_torrent(event.src_path) 33 | 34 | 35 | class TorrentWatcher(object): 36 | 37 | def __init__(self, watch_directory, putio_client): 38 | self._watch_directory = watch_directory 39 | self._putio_client = putio_client 40 | self._observer = Observer() 41 | self._event_handler = TorrentWatcherFilesystemEventHandler(self._putio_client) 42 | 43 | def stop(self): 44 | self._observer.stop() 45 | 46 | def join(self, *args, **kwargs): 47 | self._observer.join(*args, **kwargs) 48 | 49 | def start(self): 50 | # TODO: add recursive option in future 51 | self._observer.schedule(self._event_handler, 52 | self._watch_directory, 53 | recursive=False) 54 | self._observer.start() 55 | -------------------------------------------------------------------------------- /putiosync/webif/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'posborne' 2 | -------------------------------------------------------------------------------- /putiosync/webif/static/blacktocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posborne/putio-sync/fbd86418e4a34275764aa9e6ea3062d88d7011e9/putiosync/webif/static/blacktocat.png -------------------------------------------------------------------------------- /putiosync/webif/static/css/bootstrap-theme.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.0.3 (http://getbootstrap.com) 3 | * Copyright 2013 Twitter, Inc. 4 | * Licensed under http://www.apache.org/licenses/LICENSE-2.0 5 | */ 6 | 7 | .btn-default, 8 | .btn-primary, 9 | .btn-success, 10 | .btn-info, 11 | .btn-warning, 12 | .btn-danger { 13 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2); 14 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); 15 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); 16 | } 17 | 18 | .btn-default:active, 19 | .btn-primary:active, 20 | .btn-success:active, 21 | .btn-info:active, 22 | .btn-warning:active, 23 | .btn-danger:active, 24 | .btn-default.active, 25 | .btn-primary.active, 26 | .btn-success.active, 27 | .btn-info.active, 28 | .btn-warning.active, 29 | .btn-danger.active { 30 | -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 31 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 32 | } 33 | 34 | .btn:active, 35 | .btn.active { 36 | background-image: none; 37 | } 38 | 39 | .btn-default { 40 | text-shadow: 0 1px 0 #fff; 41 | background-image: -webkit-linear-gradient(top, #ffffff 0%, #e0e0e0 100%); 42 | background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%); 43 | background-repeat: repeat-x; 44 | border-color: #dbdbdb; 45 | border-color: #ccc; 46 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); 47 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 48 | } 49 | 50 | .btn-default:hover, 51 | .btn-default:focus { 52 | background-color: #e0e0e0; 53 | background-position: 0 -15px; 54 | } 55 | 56 | .btn-default:active, 57 | .btn-default.active { 58 | background-color: #e0e0e0; 59 | border-color: #dbdbdb; 60 | } 61 | 62 | .btn-primary { 63 | background-image: -webkit-linear-gradient(top, #428bca 0%, #2d6ca2 100%); 64 | background-image: linear-gradient(to bottom, #428bca 0%, #2d6ca2 100%); 65 | background-repeat: repeat-x; 66 | border-color: #2b669a; 67 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0); 68 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 69 | } 70 | 71 | .btn-primary:hover, 72 | .btn-primary:focus { 73 | background-color: #2d6ca2; 74 | background-position: 0 -15px; 75 | } 76 | 77 | .btn-primary:active, 78 | .btn-primary.active { 79 | background-color: #2d6ca2; 80 | border-color: #2b669a; 81 | } 82 | 83 | .btn-success { 84 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); 85 | background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); 86 | background-repeat: repeat-x; 87 | border-color: #3e8f3e; 88 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); 89 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 90 | } 91 | 92 | .btn-success:hover, 93 | .btn-success:focus { 94 | background-color: #419641; 95 | background-position: 0 -15px; 96 | } 97 | 98 | .btn-success:active, 99 | .btn-success.active { 100 | background-color: #419641; 101 | border-color: #3e8f3e; 102 | } 103 | 104 | .btn-warning { 105 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); 106 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); 107 | background-repeat: repeat-x; 108 | border-color: #e38d13; 109 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); 110 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 111 | } 112 | 113 | .btn-warning:hover, 114 | .btn-warning:focus { 115 | background-color: #eb9316; 116 | background-position: 0 -15px; 117 | } 118 | 119 | .btn-warning:active, 120 | .btn-warning.active { 121 | background-color: #eb9316; 122 | border-color: #e38d13; 123 | } 124 | 125 | .btn-danger { 126 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); 127 | background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); 128 | background-repeat: repeat-x; 129 | border-color: #b92c28; 130 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); 131 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 132 | } 133 | 134 | .btn-danger:hover, 135 | .btn-danger:focus { 136 | background-color: #c12e2a; 137 | background-position: 0 -15px; 138 | } 139 | 140 | .btn-danger:active, 141 | .btn-danger.active { 142 | background-color: #c12e2a; 143 | border-color: #b92c28; 144 | } 145 | 146 | .btn-info { 147 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); 148 | background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); 149 | background-repeat: repeat-x; 150 | border-color: #28a4c9; 151 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); 152 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 153 | } 154 | 155 | .btn-info:hover, 156 | .btn-info:focus { 157 | background-color: #2aabd2; 158 | background-position: 0 -15px; 159 | } 160 | 161 | .btn-info:active, 162 | .btn-info.active { 163 | background-color: #2aabd2; 164 | border-color: #28a4c9; 165 | } 166 | 167 | .thumbnail, 168 | .img-thumbnail { 169 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); 170 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); 171 | } 172 | 173 | .dropdown-menu > li > a:hover, 174 | .dropdown-menu > li > a:focus { 175 | background-color: #e8e8e8; 176 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 177 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 178 | background-repeat: repeat-x; 179 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 180 | } 181 | 182 | .dropdown-menu > .active > a, 183 | .dropdown-menu > .active > a:hover, 184 | .dropdown-menu > .active > a:focus { 185 | background-color: #357ebd; 186 | background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%); 187 | background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%); 188 | background-repeat: repeat-x; 189 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0); 190 | } 191 | 192 | .navbar-default { 193 | background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%); 194 | background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%); 195 | background-repeat: repeat-x; 196 | border-radius: 4px; 197 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); 198 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 199 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075); 200 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075); 201 | } 202 | 203 | .navbar-default .navbar-nav > .active > a { 204 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f3f3f3 100%); 205 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f3f3f3 100%); 206 | background-repeat: repeat-x; 207 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0); 208 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075); 209 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075); 210 | } 211 | 212 | .navbar-brand, 213 | .navbar-nav > li > a { 214 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25); 215 | } 216 | 217 | .navbar-inverse { 218 | background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222222 100%); 219 | background-image: linear-gradient(to bottom, #3c3c3c 0%, #222222 100%); 220 | background-repeat: repeat-x; 221 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); 222 | filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); 223 | } 224 | 225 | .navbar-inverse .navbar-nav > .active > a { 226 | background-image: -webkit-linear-gradient(top, #222222 0%, #282828 100%); 227 | background-image: linear-gradient(to bottom, #222222 0%, #282828 100%); 228 | background-repeat: repeat-x; 229 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0); 230 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25); 231 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25); 232 | } 233 | 234 | .navbar-inverse .navbar-brand, 235 | .navbar-inverse .navbar-nav > li > a { 236 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 237 | } 238 | 239 | .navbar-static-top, 240 | .navbar-fixed-top, 241 | .navbar-fixed-bottom { 242 | border-radius: 0; 243 | } 244 | 245 | .alert { 246 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2); 247 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05); 248 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05); 249 | } 250 | 251 | .alert-success { 252 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); 253 | background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); 254 | background-repeat: repeat-x; 255 | border-color: #b2dba1; 256 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); 257 | } 258 | 259 | .alert-info { 260 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); 261 | background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); 262 | background-repeat: repeat-x; 263 | border-color: #9acfea; 264 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); 265 | } 266 | 267 | .alert-warning { 268 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); 269 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); 270 | background-repeat: repeat-x; 271 | border-color: #f5e79e; 272 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); 273 | } 274 | 275 | .alert-danger { 276 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); 277 | background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); 278 | background-repeat: repeat-x; 279 | border-color: #dca7a7; 280 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); 281 | } 282 | 283 | .progress { 284 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); 285 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); 286 | background-repeat: repeat-x; 287 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); 288 | } 289 | 290 | .progress-bar { 291 | background-image: -webkit-linear-gradient(top, #428bca 0%, #3071a9 100%); 292 | background-image: linear-gradient(to bottom, #428bca 0%, #3071a9 100%); 293 | background-repeat: repeat-x; 294 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0); 295 | } 296 | 297 | .progress-bar-success { 298 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); 299 | background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); 300 | background-repeat: repeat-x; 301 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); 302 | } 303 | 304 | .progress-bar-info { 305 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); 306 | background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); 307 | background-repeat: repeat-x; 308 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); 309 | } 310 | 311 | .progress-bar-warning { 312 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); 313 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); 314 | background-repeat: repeat-x; 315 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); 316 | } 317 | 318 | .progress-bar-danger { 319 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); 320 | background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); 321 | background-repeat: repeat-x; 322 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); 323 | } 324 | 325 | .list-group { 326 | border-radius: 4px; 327 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); 328 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); 329 | } 330 | 331 | .list-group-item.active, 332 | .list-group-item.active:hover, 333 | .list-group-item.active:focus { 334 | text-shadow: 0 -1px 0 #3071a9; 335 | background-image: -webkit-linear-gradient(top, #428bca 0%, #3278b3 100%); 336 | background-image: linear-gradient(to bottom, #428bca 0%, #3278b3 100%); 337 | background-repeat: repeat-x; 338 | border-color: #3278b3; 339 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0); 340 | } 341 | 342 | .panel { 343 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 344 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 345 | } 346 | 347 | .panel-default > .panel-heading { 348 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 349 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 350 | background-repeat: repeat-x; 351 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 352 | } 353 | 354 | .panel-primary > .panel-heading { 355 | background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%); 356 | background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%); 357 | background-repeat: repeat-x; 358 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0); 359 | } 360 | 361 | .panel-success > .panel-heading { 362 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); 363 | background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); 364 | background-repeat: repeat-x; 365 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); 366 | } 367 | 368 | .panel-info > .panel-heading { 369 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); 370 | background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); 371 | background-repeat: repeat-x; 372 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); 373 | } 374 | 375 | .panel-warning > .panel-heading { 376 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); 377 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); 378 | background-repeat: repeat-x; 379 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); 380 | } 381 | 382 | .panel-danger > .panel-heading { 383 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); 384 | background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); 385 | background-repeat: repeat-x; 386 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); 387 | } 388 | 389 | .well { 390 | background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); 391 | background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); 392 | background-repeat: repeat-x; 393 | border-color: #dcdcdc; 394 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); 395 | -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1); 396 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1); 397 | } -------------------------------------------------------------------------------- /putiosync/webif/static/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.0.3 (http://getbootstrap.com) 3 | * Copyright 2013 Twitter, Inc. 4 | * Licensed under http://www.apache.org/licenses/LICENSE-2.0 5 | */ 6 | 7 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn:active,.btn.active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff',endColorstr='#ffe0e0e0',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);background-repeat:repeat-x;border-color:#2b669a;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff2d6ca2',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);background-repeat:repeat-x;border-color:#3e8f3e;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c',endColorstr='#ff419641',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);background-repeat:repeat-x;border-color:#e38d13;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e',endColorstr='#ffeb9316',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);background-repeat:repeat-x;border-color:#b92c28;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f',endColorstr='#ffc12e2a',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);background-repeat:repeat-x;border-color:#28a4c9;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de',endColorstr='#ff2aabd2',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0)}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-color:#357ebd;background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff357ebd',GradientType=0)}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;border-radius:4px;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff',endColorstr='#fff8f8f8',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb',endColorstr='#fff3f3f3',GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.075);box-shadow:inset 0 3px 9px rgba(0,0,0,0.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,0.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c',endColorstr='#ff222222',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222',endColorstr='#ff282828',GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.25);box-shadow:inset 0 3px 9px rgba(0,0,0,0.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;border-color:#b2dba1;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8',endColorstr='#ffc8e5bc',GradientType=0)}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;border-color:#9acfea;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7',endColorstr='#ffb9def0',GradientType=0)}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;border-color:#f5e79e;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3',endColorstr='#fff8efc0',GradientType=0)}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;border-color:#dca7a7;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede',endColorstr='#ffe7c3c3',GradientType=0)}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb',endColorstr='#fff5f5f5',GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff3071a9',GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c',endColorstr='#ff449d44',GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de',endColorstr='#ff31b0d5',GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e',endColorstr='#ffec971f',GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f',endColorstr='#ffc9302c',GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;border-color:#3278b3;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff3278b3',GradientType=0)}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff357ebd',GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8',endColorstr='#ffd0e9c6',GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7',endColorstr='#ffc4e3f3',GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3',endColorstr='#fffaf2cc',GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede',endColorstr='#ffebcccc',GradientType=0)}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;border-color:#dcdcdc;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8',endColorstr='#fff5f5f5',GradientType=0);-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1)} -------------------------------------------------------------------------------- /putiosync/webif/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posborne/putio-sync/fbd86418e4a34275764aa9e6ea3062d88d7011e9/putiosync/webif/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /putiosync/webif/static/fonts/glyphicons-halflings-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | -------------------------------------------------------------------------------- /putiosync/webif/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posborne/putio-sync/fbd86418e4a34275764aa9e6ea3062d88d7011e9/putiosync/webif/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /putiosync/webif/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posborne/putio-sync/fbd86418e4a34275764aa9e6ea3062d88d7011e9/putiosync/webif/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /putiosync/webif/static/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.0.3 (http://getbootstrap.com) 3 | * Copyright 2013 Twitter, Inc. 4 | * Licensed under http://www.apache.org/licenses/LICENSE-2.0 5 | */ 6 | 7 | if("undefined"==typeof jQuery)throw new Error("Bootstrap requires jQuery");+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]}}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one(a.support.transition.end,function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b()})}(jQuery),+function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype.close=function(b){function c(){f.trigger("closed.bs.alert").remove()}var d=a(this),e=d.attr("data-target");e||(e=d.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,""));var f=a(e);b&&b.preventDefault(),f.length||(f=d.hasClass("alert")?d:d.parent()),f.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(f.removeClass("in"),a.support.transition&&f.hasClass("fade")?f.one(a.support.transition.end,c).emulateTransitionEnd(150):c())};var d=a.fn.alert;a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("bs.alert");e||d.data("bs.alert",e=new c(this)),"string"==typeof b&&e[b].call(d)})},a.fn.alert.Constructor=c,a.fn.alert.noConflict=function(){return a.fn.alert=d,this},a(document).on("click.bs.alert.data-api",b,c.prototype.close)}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d)};b.DEFAULTS={loadingText:"loading..."},b.prototype.setState=function(a){var b="disabled",c=this.$element,d=c.is("input")?"val":"html",e=c.data();a+="Text",e.resetText||c.data("resetText",c[d]()),c[d](e[a]||this.options[a]),setTimeout(function(){"loadingText"==a?c.addClass(b).attr(b,b):c.removeClass(b).removeAttr(b)},0)},b.prototype.toggle=function(){var a=this.$element.closest('[data-toggle="buttons"]'),b=!0;if(a.length){var c=this.$element.find("input");"radio"===c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?b=!1:a.find(".active").removeClass("active")),b&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}b&&this.$element.toggleClass("active")};var c=a.fn.button;a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof c&&c;e||d.data("bs.button",e=new b(this,f)),"toggle"==c?e.toggle():c&&e.setState(c)})},a.fn.button.Constructor=b,a.fn.button.noConflict=function(){return a.fn.button=c,this},a(document).on("click.bs.button.data-api","[data-toggle^=button]",function(b){var c=a(b.target);c.hasClass("btn")||(c=c.closest(".btn")),c.button("toggle"),b.preventDefault()})}(jQuery),+function(a){"use strict";var b=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,"hover"==this.options.pause&&this.$element.on("mouseenter",a.proxy(this.pause,this)).on("mouseleave",a.proxy(this.cycle,this))};b.DEFAULTS={interval:5e3,pause:"hover",wrap:!0},b.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},b.prototype.getActiveIndex=function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},b.prototype.to=function(b){var c=this,d=this.getActiveIndex();return b>this.$items.length-1||0>b?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){c.to(b)}):d==b?this.pause().cycle():this.slide(b>d?"next":"prev",a(this.$items[b]))},b.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition.end&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},b.prototype.next=function(){return this.sliding?void 0:this.slide("next")},b.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},b.prototype.slide=function(b,c){var d=this.$element.find(".item.active"),e=c||d[b](),f=this.interval,g="next"==b?"left":"right",h="next"==b?"first":"last",i=this;if(!e.length){if(!this.options.wrap)return;e=this.$element.find(".item")[h]()}this.sliding=!0,f&&this.pause();var j=a.Event("slide.bs.carousel",{relatedTarget:e[0],direction:g});if(!e.hasClass("active")){if(this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid.bs.carousel",function(){var b=a(i.$indicators.children()[i.getActiveIndex()]);b&&b.addClass("active")})),a.support.transition&&this.$element.hasClass("slide")){if(this.$element.trigger(j),j.isDefaultPrevented())return;e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),d.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid.bs.carousel")},0)}).emulateTransitionEnd(600)}else{if(this.$element.trigger(j),j.isDefaultPrevented())return;d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid.bs.carousel")}return f&&this.cycle(),this}};var c=a.fn.carousel;a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c),g="string"==typeof c?c:f.slide;e||d.data("bs.carousel",e=new b(this,f)),"number"==typeof c?e.to(c):g?e[g]():f.interval&&e.pause().cycle()})},a.fn.carousel.Constructor=b,a.fn.carousel.noConflict=function(){return a.fn.carousel=c,this},a(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",function(b){var c,d=a(this),e=a(d.attr("data-target")||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"")),f=a.extend({},e.data(),d.data()),g=d.attr("data-slide-to");g&&(f.interval=!1),e.carousel(f),(g=d.attr("data-slide-to"))&&e.data("bs.carousel").to(g),b.preventDefault()}),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var b=a(this);b.carousel(b.data())})})}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.transitioning=null,this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.DEFAULTS={toggle:!0},b.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},b.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b=a.Event("show.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.$parent&&this.$parent.find("> .panel > .in");if(c&&c.length){var d=c.data("bs.collapse");if(d&&d.transitioning)return;c.collapse("hide"),d||c.data("bs.collapse",null)}var e=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[e](0),this.transitioning=1;var f=function(){this.$element.removeClass("collapsing").addClass("in")[e]("auto"),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return f.call(this);var g=a.camelCase(["scroll",e].join("-"));this.$element.one(a.support.transition.end,a.proxy(f,this)).emulateTransitionEnd(350)[e](this.$element[0][g])}}},b.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse").removeClass("in"),this.transitioning=1;var d=function(){this.transitioning=0,this.$element.trigger("hidden.bs.collapse").removeClass("collapsing").addClass("collapse")};return a.support.transition?(this.$element[c](0).one(a.support.transition.end,a.proxy(d,this)).emulateTransitionEnd(350),void 0):d.call(this)}}},b.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()};var c=a.fn.collapse;a.fn.collapse=function(c){return this.each(function(){var d=a(this),e=d.data("bs.collapse"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c);e||d.data("bs.collapse",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.collapse.Constructor=b,a.fn.collapse.noConflict=function(){return a.fn.collapse=c,this},a(document).on("click.bs.collapse.data-api","[data-toggle=collapse]",function(b){var c,d=a(this),e=d.attr("data-target")||b.preventDefault()||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,""),f=a(e),g=f.data("bs.collapse"),h=g?"toggle":d.data(),i=d.attr("data-parent"),j=i&&a(i);g&&g.transitioning||(j&&j.find('[data-toggle=collapse][data-parent="'+i+'"]').not(d).addClass("collapsed"),d[f.hasClass("in")?"addClass":"removeClass"]("collapsed")),f.collapse(h)})}(jQuery),+function(a){"use strict";function b(){a(d).remove(),a(e).each(function(b){var d=c(a(this));d.hasClass("open")&&(d.trigger(b=a.Event("hide.bs.dropdown")),b.isDefaultPrevented()||d.removeClass("open").trigger("hidden.bs.dropdown"))})}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}var d=".dropdown-backdrop",e="[data-toggle=dropdown]",f=function(b){a(b).on("click.bs.dropdown",this.toggle)};f.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){if("ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(''}),b.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),b.prototype.constructor=b,b.prototype.getDefaults=function(){return b.DEFAULTS},b.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content")[this.options.html?"html":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},b.prototype.hasContent=function(){return this.getTitle()||this.getContent()},b.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},b.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")},b.prototype.tip=function(){return this.$tip||(this.$tip=a(this.options.template)),this.$tip};var c=a.fn.popover;a.fn.popover=function(c){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof c&&c;e||d.data("bs.popover",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.popover.Constructor=b,a.fn.popover.noConflict=function(){return a.fn.popover=c,this}}(jQuery),+function(a){"use strict";function b(c,d){var e,f=a.proxy(this.process,this);this.$element=a(c).is("body")?a(window):a(c),this.$body=a("body"),this.$scrollElement=this.$element.on("scroll.bs.scroll-spy.data-api",f),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||(e=a(c).attr("href"))&&e.replace(/.*(?=#[^\s]+$)/,"")||"")+" .nav li > a",this.offsets=a([]),this.targets=a([]),this.activeTarget=null,this.refresh(),this.process()}b.DEFAULTS={offset:10},b.prototype.refresh=function(){var b=this.$element[0]==window?"offset":"position";this.offsets=a([]),this.targets=a([]);var c=this;this.$body.find(this.selector).map(function(){var d=a(this),e=d.data("target")||d.attr("href"),f=/^#\w/.test(e)&&a(e);return f&&f.length&&[[f[b]().top+(!a.isWindow(c.$scrollElement.get(0))&&c.$scrollElement.scrollTop()),e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){c.offsets.push(this[0]),c.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.$scrollElement[0].scrollHeight||this.$body[0].scrollHeight,d=c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(b>=d)return g!=(a=f.last()[0])&&this.activate(a);for(a=e.length;a--;)g!=f[a]&&b>=e[a]&&(!e[a+1]||b<=e[a+1])&&this.activate(f[a])},b.prototype.activate=function(b){this.activeTarget=b,a(this.selector).parents(".active").removeClass("active");var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")};var c=a.fn.scrollspy;a.fn.scrollspy=function(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return a.fn.scrollspy=c,this},a(window).on("load",function(){a('[data-spy="scroll"]').each(function(){var b=a(this);b.scrollspy(b.data())})})}(jQuery),+function(a){"use strict";var b=function(b){this.element=a(b)};b.prototype.show=function(){var b=this.element,c=b.closest("ul:not(.dropdown-menu)"),d=b.data("target");if(d||(d=b.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),!b.parent("li").hasClass("active")){var e=c.find(".active:last a")[0],f=a.Event("show.bs.tab",{relatedTarget:e});if(b.trigger(f),!f.isDefaultPrevented()){var g=a(d);this.activate(b.parent("li"),c),this.activate(g,g.parent(),function(){b.trigger({type:"shown.bs.tab",relatedTarget:e})})}}},b.prototype.activate=function(b,c,d){function e(){f.removeClass("active").find("> .dropdown-menu > .active").removeClass("active"),b.addClass("active"),g?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu")&&b.closest("li.dropdown").addClass("active"),d&&d()}var f=c.find("> .active"),g=d&&a.support.transition&&f.hasClass("fade");g?f.one(a.support.transition.end,e).emulateTransitionEnd(150):e(),f.removeClass("in")};var c=a.fn.tab;a.fn.tab=function(c){return this.each(function(){var d=a(this),e=d.data("bs.tab");e||d.data("bs.tab",e=new b(this)),"string"==typeof c&&e[c]()})},a.fn.tab.Constructor=b,a.fn.tab.noConflict=function(){return a.fn.tab=c,this},a(document).on("click.bs.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"]',function(b){b.preventDefault(),a(this).tab("show")})}(jQuery),+function(a){"use strict";var b=function(c,d){this.options=a.extend({},b.DEFAULTS,d),this.$window=a(window).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(c),this.affixed=this.unpin=null,this.checkPosition()};b.RESET="affix affix-top affix-bottom",b.DEFAULTS={offset:0},b.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},b.prototype.checkPosition=function(){if(this.$element.is(":visible")){var c=a(document).height(),d=this.$window.scrollTop(),e=this.$element.offset(),f=this.options.offset,g=f.top,h=f.bottom;"object"!=typeof f&&(h=g=f),"function"==typeof g&&(g=f.top()),"function"==typeof h&&(h=f.bottom());var i=null!=this.unpin&&d+this.unpin<=e.top?!1:null!=h&&e.top+this.$element.height()>=c-h?"bottom":null!=g&&g>=d?"top":!1;this.affixed!==i&&(this.unpin&&this.$element.css("top",""),this.affixed=i,this.unpin="bottom"==i?e.top-d:null,this.$element.removeClass(b.RESET).addClass("affix"+(i?"-"+i:"")),"bottom"==i&&this.$element.offset({top:document.body.offsetHeight-h-this.$element.height()}))}};var c=a.fn.affix;a.fn.affix=function(c){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof c&&c;e||d.data("bs.affix",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.affix.Constructor=b,a.fn.affix.noConflict=function(){return a.fn.affix=c,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var b=a(this),c=b.data();c.offset=c.offset||{},c.offsetBottom&&(c.offset.bottom=c.offsetBottom),c.offsetTop&&(c.offset.top=c.offsetTop),b.affix(c)})})}(jQuery); -------------------------------------------------------------------------------- /putiosync/webif/templates/active.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block javascript %} 3 | 20 | 21 | 41 | 42 | 64 | 65 | 174 | {% endblock %} 175 | {% block body %} 176 |

Download Queue

177 |
178 |
179 |
180 |

Recently Completed

181 |
182 | {% endblock %} 183 | -------------------------------------------------------------------------------- /putiosync/webif/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | putio-sync 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 28 | 29 | 50 | 51 | 52 |
53 | {% block body %}{% endblock %} 54 |
55 | 56 | 58 | 59 | 60 | 61 | 62 | 63 | {% block javascript %}{% endblock %} 64 | 65 | 66 | -------------------------------------------------------------------------------- /putiosync/webif/templates/history.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import 'macros.html' as macros %} 3 | 4 | {% block body %} 5 |

Download History

6 |

You've downloaded a total of {{ total_downloaded|prettysize }}.

7 | {{ macros.render_downloads(history.items) }} 8 | {{ macros.render_pagination(history, "_view_history") }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /putiosync/webif/templates/macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_pagination(pagination, endpoint) %} 2 | 15 | {% endmacro %} 16 | 17 | {% macro render_downloads(downloads) %} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {%- for download in downloads %} 28 | 29 | 30 | 31 | 32 | 33 | {%- endfor %} 34 | 35 |
IDSizeName
{{ download.id }}{{ download.size|prettysize }}{{ download.name }}
36 | {% endmacro %} -------------------------------------------------------------------------------- /putiosync/webif/transmissionrpc.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import uuid 4 | import flask 5 | import os 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def map_status(status): 12 | return { 13 | "IN_QUEUE": 3, # queued 14 | "DOWNLOADING": 4, # downloading 15 | "COMPLETED": 6, # seeding 16 | }.get(status, 3) # default: queued 17 | 18 | def geteta(eta): 19 | if eta is None: 20 | return 0 21 | else: 22 | if eta < 0: 23 | return 0 24 | else: 25 | return eta 26 | 27 | class TransmissionTransferProxy(object): 28 | """Wrap a put.io transfer and map to Transmission torrent iface 29 | 30 | Here's an example of the information we get from Put.io for a transfer: 31 | 32 | { 33 | "availability": null, 34 | "callback_url": null, 35 | "client_ip": null, 36 | "created_at": "2015-10-13T05:20:22", 37 | "created_torrent": false, 38 | "current_ratio": "0.00", 39 | "down_speed": 0, 40 | "download_id": 17654355, 41 | "downloaded": 0, 42 | "error_message": null, 43 | "estimated_time": null, 44 | "extract": false, 45 | "file_id": 313672617, 46 | "finished_at": "2015-10-13T05:20:24", 47 | "id": 30210267, 48 | "is_private": false, 49 | "magneturi": "magnet:?xt=urn:btih:9ce5e6fc6aa605287c8e2af20c01c5655ff59074&dn=Fargo+S02E01+720p+HDTV+x264+KILLERS", 50 | "name": "Fargo S02E01 720p HDTV x264 KILLERS", 51 | "peers_connected": 0, 52 | "peers_getting_from_us": 0, 53 | "peers_sending_to_us": 0, 54 | "percent_done": 100, 55 | "save_parent_id": 0, 56 | "seconds_seeding": 0, 57 | "size": 935982427, 58 | "source": "magnet:?xt=urn:btih:9CE5E6FC6AA605287C8E2AF20C01C5655FF59074&dn=Fargo+S02E01+720p+HDTV+x264+KILLERS&tr=udp://tracker.coppersurfer.tk:6969/announce&tr=udp://tracker.leechers-paradise.org:6969&tr=udp://open.demonii.com:1337", 59 | "status": "COMPLETED", 60 | "status_message": "Completed 5 days ago.", 61 | "subscription_id": 4895894, 62 | "torrent_link": "/v2/transfers/30210267/torrent", 63 | "tracker_message": null, 64 | "trackers": null, 65 | "type": "TORRENT", 66 | "up_speed": 0, 67 | "uploaded": 0 68 | }, 69 | 70 | """ 71 | 72 | def __init__(self, putio_transfer, synchronizer): 73 | self.synchronizer = synchronizer 74 | self.transfer = putio_transfer 75 | # sonar requests the following: 76 | # - id 77 | # - hashString 78 | # - name 79 | # - downloadDir 80 | # - status 81 | # - totalSize 82 | # - leftUntilDone 83 | # - eta 84 | # - errorString 85 | self._field_providers = { 86 | "id": lambda: self.transfer.id, 87 | "hashString": lambda: "%s" % self.transfer.id, 88 | "name": lambda: self.transfer.name, 89 | "downloadDir": lambda: self.synchronizer.get_download_directory(), 90 | "status": lambda: map_status(self.transfer.status), 91 | "totalSize": lambda: self.transfer.size, 92 | "leftUntilDone": lambda: self.transfer.size - self.transfer.downloaded, 93 | "errorString": lambda : '' if self.transfer.error_message is None else self.transfer.error_message, 94 | "isFinished": lambda : self.synchronizer.is_already_downloaded(self.transfer), 95 | "eta": lambda : geteta(self.transfer.estimated_time) 96 | } 97 | 98 | def render_json(self, fields): 99 | return {f: self._field_providers.get(f, lambda: None)() for f in fields} 100 | 101 | 102 | class TransmissionRPCServer(object): 103 | """Expose a JSON-RPC interface attempting to match Transmission 104 | 105 | This API interface is documented at 106 | https://trac.transmissionbt.com/browser/trunk/extras/rpc-spec.txt. We attempt 107 | to match enough so that we can get integration from clients of the transmission 108 | API (e.g. Sonarr) without having to modify those pieces of software directly. 109 | 110 | The implementation is (and probably always will be) partial and your results 111 | may vary from client to client depending on how much, how little they expect 112 | to have implemented. 113 | """ 114 | 115 | def __init__(self, putio_client, synchronizer): 116 | self._synchronizer = synchronizer 117 | self._putio_client = putio_client 118 | self._session_id = str(uuid.uuid1()) 119 | self.methods = { 120 | "session-get": self._session_get, 121 | "session-stats": self._session_stats, 122 | "torrent-get": self._torrent_get, 123 | "torrent-add": self._torrent_add, 124 | "torrent-set": self._torrent_set, 125 | "torrent-remove": self._torrent_remove, 126 | } 127 | 128 | def _session_get(self, **arguments): 129 | # Many more are supported by real client, this is enough for Sonarr 130 | return { 131 | "rpc-version": 15, 132 | "version": "2.84 (putiosync)", 133 | "download-dir": self._synchronizer.get_download_directory() 134 | } 135 | 136 | def _session_stats(self, **arguments): 137 | return {} 138 | 139 | def _torrent_add(self, filename, **arguments): 140 | if os.path.isfile(filename): 141 | self._putio_client.Transfer.add_torrent(filename) 142 | else: 143 | self._putio_client.Transfer.add_url(filename) 144 | return {} 145 | 146 | def _torrent_remove(self, ids, **arguments): 147 | for id in ids: 148 | file = self._putio_client.File.get(id) 149 | file.delete() 150 | return {} 151 | 152 | def _torrent_set(self, **arguments): 153 | return {} 154 | 155 | def _torrent_get(self, fields, **arguments): 156 | transfers = self._putio_client.Transfer.list() 157 | transmission_transfers = [TransmissionTransferProxy(t, self._synchronizer) for t in transfers] 158 | return {"torrents": [t.render_json(fields) for t in transmission_transfers]} 159 | 160 | def handle_request(self): 161 | # If GET, just provide X-Transmission-Session-Id with HTTP 409 162 | if flask.request.method == "GET": 163 | res = flask.make_response("Session ID: %s" % self._session_id) 164 | res.headers['X-Transmission-Session-Id'] = self._session_id 165 | return res, 409 166 | else: 167 | data = json.loads(flask.request.data) 168 | method = data['method'] 169 | arguments = data.get('arguments', {}) 170 | tag = data.get('tag') 171 | logger.info("Method: %r, Arguments: %r", method, arguments) 172 | logger.info("%r", flask.request.headers) 173 | try: 174 | result = self.methods[method](**arguments) 175 | except Exception as e: 176 | response = { 177 | "result": "error", 178 | "error_description": "%s" % e, 179 | } 180 | else: 181 | response = { 182 | "result": "success", 183 | "arguments": result, 184 | } 185 | 186 | if tag: 187 | response["tag"] = tag 188 | 189 | res = flask.make_response(json.dumps(response)) 190 | res.headers['X-Transmission-Session-Id'] = self._session_id 191 | return res 192 | -------------------------------------------------------------------------------- /putiosync/webif/webif.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from math import ceil 3 | import datetime 4 | 5 | import flask 6 | from flask_restless import APIManager 7 | from putiosync.dbmodel import DownloadRecord 8 | from flask import render_template 9 | from putiosync.webif.transmissionrpc import TransmissionRPCServer 10 | from sqlalchemy import desc, func 11 | 12 | class Pagination(object): 13 | # NOTE: pagination is a feature that is included with flask-sqlalchemy, but after 14 | # working with it initially, it was far too hacky to use this in combination 15 | # with a model that wasn't declared with the flask-sqlalchemy meta base. Since 16 | # I did not and do not want to do that, this exists. 17 | 18 | def __init__(self, query, page, per_page): 19 | self.query = query 20 | self.page = page 21 | self.per_page = per_page 22 | self.total_count = query.count() 23 | 24 | @property 25 | def items(self): 26 | return self.query.offset((self.page - 1) * self.per_page).limit(self.per_page).all() 27 | 28 | @property 29 | def pages(self): 30 | return int(ceil(self.total_count / float(self.per_page))) 31 | 32 | @property 33 | def has_prev(self): 34 | return self.page > 1 35 | 36 | @property 37 | def has_next(self): 38 | return self.page < self.pages 39 | 40 | def iter_pages(self, left_edge=2, left_current=2, 41 | right_current=5, right_edge=2): 42 | last = 0 43 | for num in range(1, self.pages + 1): 44 | if (num <= left_edge or 45 | (self.page - left_current - 1 < num < self.page + right_current) or 46 | num > self.pages - right_edge): 47 | if last + 1 != num: 48 | yield None 49 | yield num 50 | last = num 51 | 52 | 53 | class DownloadRateTracker(object): 54 | def __init__(self): 55 | self._current_download = None 56 | self._current_download_last_downloaded = 0 57 | self._last_sample_datetime = None 58 | self._bps_this_sample = 0 59 | 60 | def get_bps(self): 61 | return self._bps_this_sample 62 | 63 | def update_progress(self, download): 64 | current_sample_datetime = datetime.datetime.now() 65 | bytes_this_sample = 0 66 | if download is None: 67 | self._current_download = None 68 | self._bps_this_sample = 0 69 | self._last_sample_datetime = current_sample_datetime 70 | return 71 | 72 | if self._current_download != download: 73 | if self._current_download is not None: 74 | # record remaininng progress from the previous download 75 | bytes_this_sample += self._current_download.get_size() - self._current_download_last_downloaded 76 | self._current_download = download 77 | self._current_download_last_downloaded = 0 78 | self._last_sample_datetime = current_sample_datetime 79 | 80 | bytes_this_sample += download.get_downloaded() - self._current_download_last_downloaded 81 | time_delta = current_sample_datetime - self._last_sample_datetime 82 | if bytes_this_sample == 0 or time_delta <= datetime.timedelta(seconds=0): 83 | self._bps_this_sample = 0 84 | else: 85 | self._bps_this_sample = float(bytes_this_sample) / time_delta.total_seconds() 86 | self._current_download = download 87 | self._current_download_last_downloaded = download.get_downloaded() 88 | self._last_sample_datetime = current_sample_datetime 89 | 90 | 91 | class WebInterface(object): 92 | def __init__(self, db_manager, download_manager, putio_client, synchronizer, launch_browser=False, host="0.0.0.0", 93 | port=7001): 94 | self.app = flask.Flask(__name__) 95 | self.synchronizer = synchronizer 96 | self.db_manager = db_manager 97 | self.api_manager = APIManager(self.app, session=self.db_manager.get_db_session()) 98 | self.download_manager = download_manager 99 | self.putio_client = putio_client 100 | self.transmission_rpc_server = TransmissionRPCServer(putio_client, self.synchronizer) 101 | self.launch_browser = launch_browser 102 | self._host = host 103 | self._port = port 104 | self._rate_tracker = DownloadRateTracker() 105 | 106 | self.app.logger.setLevel(logging.WARNING) 107 | 108 | def include_datetime(result): 109 | print(result) 110 | 111 | self.download_record_blueprint = self.api_manager.create_api( 112 | DownloadRecord, 113 | methods=['GET'], 114 | postprocessors={ 115 | "GET_MANY": [include_datetime] 116 | }) 117 | 118 | # filters 119 | self.app.jinja_env.filters["prettysize"] = self._pretty_size 120 | 121 | # urls 122 | self.app.add_url_rule("/", view_func=self._view_active) 123 | self.app.add_url_rule("/active", view_func=self._view_active) 124 | self.app.add_url_rule("/history", view_func=self._view_history) 125 | self.app.add_url_rule("/download_queue", view_func=self._view_download_queue) 126 | self.app.add_url_rule("/history/page/", view_func=self._view_history) 127 | self.app.add_url_rule("/transmission/rpc", methods=['POST', 'GET', ], 128 | view_func=self.transmission_rpc_server.handle_request) 129 | 130 | def _pretty_size(self, size): 131 | if size > 1024 * 1024 * 1024: 132 | return "%0.2f GB" % (size / 1024. / 1024 / 1024) 133 | elif size > 1024 * 1024: 134 | return "%0.2f MB" % (size / 1024. / 1024) 135 | elif size > 1024: 136 | return "%0.2f KB" % (size / 1024.) 137 | else: 138 | return "%s B" % size 139 | 140 | def _view_active(self): 141 | return render_template("active.html") 142 | 143 | def _view_download_queue(self): 144 | downloads = self.download_manager.get_downloads() 145 | try: 146 | if downloads[0].get_downloaded() > 0: 147 | self._rate_tracker.update_progress(downloads[0]) 148 | except IndexError: 149 | self._rate_tracker.update_progress(None) 150 | 151 | queued_downloads = [] 152 | for download in downloads: 153 | queued_downloads.append( 154 | { 155 | "name": download.get_putio_file().name, 156 | "size": download.get_size(), 157 | "downloaded": download.get_downloaded(), 158 | "start_datetime": download.get_start_datetime(), 159 | "end_datetime": download.get_finish_datetime(), 160 | } 161 | ) 162 | 163 | recent_completed = [] 164 | for record in self.db_manager.get_db_session().query(DownloadRecord).order_by(desc(DownloadRecord.id)).limit( 165 | 20): 166 | recent_completed.append( 167 | { 168 | "id": record.id, 169 | "name": record.name, 170 | "size": record.size, 171 | } 172 | ) 173 | 174 | download_queue = { 175 | "current_datetime": datetime.datetime.now(), # use as basis for other calculations 176 | "bps": self._rate_tracker.get_bps(), 177 | "downloads": queued_downloads, 178 | "recent": recent_completed 179 | } 180 | return flask.jsonify(download_queue) 181 | 182 | def _view_history(self, page=1): 183 | session = self.db_manager.get_db_session() 184 | downloads = session.query(DownloadRecord).order_by(desc(DownloadRecord.id)) 185 | total_downloaded = session.query(func.sum(DownloadRecord.size)).scalar() 186 | return render_template("history.html", 187 | total_downloaded=total_downloaded, 188 | history=Pagination(downloads, page, per_page=100)) 189 | 190 | def run(self): 191 | if self.launch_browser: 192 | import webbrowser 193 | webbrowser.open("http://localhost:{}/".format(self._port)) 194 | self.app.run(self._host, self._port) 195 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Can be install by doing 2 | # pip install -r requirements.txt 3 | requests==2.31.0 4 | putio.py==8.4.0 5 | progressbar==2.5 6 | sqlalchemy==1.3.4 7 | flask==1.0.3 8 | flask-restless==0.17.0 9 | flask-restful==0.3.7 10 | future==0.17.1 11 | watchdog==0.9.0 12 | pid==2.2.3 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import putiosync 3 | 4 | __author__ = 'Paul Osborne' 5 | 6 | 7 | def get_requirements(): 8 | reqs = [] 9 | for line in open('requirements.txt').readlines(): 10 | if line and not line.startswith('#'): 11 | reqs.append(line) 12 | return reqs 13 | 14 | 15 | setup( 16 | name='putiosync', 17 | version=putiosync.__version__, 18 | description='Automatically download content from put.io', 19 | long_description=open('README.rst').read(), 20 | author=putiosync.__author__, 21 | author_email='osbpau@gmail.com', 22 | url="http://posborne.github.io/putio-sync/", 23 | license='MIT', 24 | packages=find_packages(), 25 | entry_points={'console_scripts': ['putiosync=putiosync.frontend:main']}, 26 | install_requires=get_requirements(), 27 | include_package_data=True, 28 | classifiers=[ 29 | 'Development Status :: 4 - Beta', 30 | 'Environment :: Console', 31 | 'Intended Audience :: End Users/Desktop', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Natural Language :: English', 34 | 'Operating System :: MacOS :: MacOS X', 35 | 'Operating System :: POSIX', 36 | 'Operating System :: Microsoft :: Windows', 37 | 'Programming Language :: Python :: 2.6', 38 | 'Programming Language :: Python :: 2.7', 39 | 'Topic :: Utilities' 40 | ] 41 | ) 42 | --------------------------------------------------------------------------------