├── setup.cfg
├── .dockerignore
├── putiosync
├── webif
│ ├── __init__.py
│ ├── static
│ │ ├── blacktocat.png
│ │ ├── fonts
│ │ │ ├── glyphicons-halflings-regular.eot
│ │ │ ├── glyphicons-halflings-regular.ttf
│ │ │ ├── glyphicons-halflings-regular.woff
│ │ │ └── glyphicons-halflings-regular.svg
│ │ ├── css
│ │ │ ├── bootstrap-theme.min.css
│ │ │ └── bootstrap-theme.css
│ │ └── js
│ │ │ └── bootstrap.min.js
│ ├── templates
│ │ ├── history.html
│ │ ├── macros.html
│ │ ├── base.html
│ │ └── active.html
│ ├── transmissionrpc.py
│ └── webif.py
├── __init__.py
├── dbmodel.py
├── watcher.py
├── multipart_downloader.py
├── frontend.py
├── download_manager.py
└── core.py
├── devrun.py
├── MANIFEST.in
├── requirements.txt
├── Dockerfile
├── .gitignore
├── LICENSE
├── setup.py
└── README.rst
/setup.cfg:
--------------------------------------------------------------------------------
1 | [wheel]
2 | universal = 1
3 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.git
2 | **/node_modules
3 |
--------------------------------------------------------------------------------
/putiosync/webif/__init__.py:
--------------------------------------------------------------------------------
1 | __author__ = 'posborne'
2 |
--------------------------------------------------------------------------------
/putiosync/__init__.py:
--------------------------------------------------------------------------------
1 | __author__ = 'Paul Osborne'
2 | __version__ = '0.2.3'
3 |
--------------------------------------------------------------------------------
/devrun.py:
--------------------------------------------------------------------------------
1 | from putiosync import frontend
2 |
3 | if __name__ == "__main__":
4 | frontend.main()
5 |
--------------------------------------------------------------------------------
/putiosync/webif/static/blacktocat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/posborne/putio-sync/HEAD/putiosync/webif/static/blacktocat.png
--------------------------------------------------------------------------------
/putiosync/webif/static/fonts/glyphicons-halflings-regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/posborne/putio-sync/HEAD/putiosync/webif/static/fonts/glyphicons-halflings-regular.eot
--------------------------------------------------------------------------------
/putiosync/webif/static/fonts/glyphicons-halflings-regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/posborne/putio-sync/HEAD/putiosync/webif/static/fonts/glyphicons-halflings-regular.ttf
--------------------------------------------------------------------------------
/putiosync/webif/static/fonts/glyphicons-halflings-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/posborne/putio-sync/HEAD/putiosync/webif/static/fonts/glyphicons-halflings-regular.woff
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/putiosync/webif/templates/macros.html:
--------------------------------------------------------------------------------
1 | {% macro render_pagination(pagination, endpoint) %}
2 |
15 | {% endmacro %}
16 |
17 | {% macro render_downloads(downloads) %}
18 |
19 |
20 |
21 | ID
22 | Size
23 | Name
24 |
25 |
26 |
27 | {%- for download in downloads %}
28 |
29 | {{ download.id }}
30 | {{ download.size|prettysize }}
31 | {{ download.name }}
32 |
33 | {%- endfor %}
34 |
35 |
36 | {% endmacro %}
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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('
').insertAfter(a(this)).on("click",b),f.trigger(d=a.Event("show.bs.dropdown")),d.isDefaultPrevented())return;f.toggleClass("open").trigger("shown.bs.dropdown"),e.focus()}return!1}},f.prototype.keydown=function(b){if(/(38|40|27)/.test(b.keyCode)){var d=a(this);if(b.preventDefault(),b.stopPropagation(),!d.is(".disabled, :disabled")){var f=c(d),g=f.hasClass("open");if(!g||g&&27==b.keyCode)return 27==b.which&&f.find(e).focus(),d.click();var h=a("[role=menu] li:not(.divider):visible a",f);if(h.length){var i=h.index(h.filter(":focus"));38==b.keyCode&&i>0&&i--,40==b.keyCode&&i ').appendTo(document.body),this.$element.on("click.dismiss.modal",a.proxy(function(a){a.target===a.currentTarget&&("static"==this.options.backdrop?this.$element[0].focus.call(this.$element[0]):this.hide.call(this))},this)),d&&this.$backdrop[0].offsetWidth,this.$backdrop.addClass("in"),!b)return;d?this.$backdrop.one(a.support.transition.end,b).emulateTransitionEnd(150):b()}else!this.isShown&&this.$backdrop?(this.$backdrop.removeClass("in"),a.support.transition&&this.$element.hasClass("fade")?this.$backdrop.one(a.support.transition.end,b).emulateTransitionEnd(150):b()):b&&b()};var c=a.fn.modal;a.fn.modal=function(c,d){return this.each(function(){var e=a(this),f=e.data("bs.modal"),g=a.extend({},b.DEFAULTS,e.data(),"object"==typeof c&&c);f||e.data("bs.modal",f=new b(this,g)),"string"==typeof c?f[c](d):g.show&&f.show(d)})},a.fn.modal.Constructor=b,a.fn.modal.noConflict=function(){return a.fn.modal=c,this},a(document).on("click.bs.modal.data-api",'[data-toggle="modal"]',function(b){var c=a(this),d=c.attr("href"),e=a(c.attr("data-target")||d&&d.replace(/.*(?=#[^\s]+$)/,"")),f=e.data("modal")?"toggle":a.extend({remote:!/#/.test(d)&&d},e.data(),c.data());b.preventDefault(),e.modal(f,this).one("hide",function(){c.is(":visible")&&c.focus()})}),a(document).on("show.bs.modal",".modal",function(){a(document.body).addClass("modal-open")}).on("hidden.bs.modal",".modal",function(){a(document.body).removeClass("modal-open")})}(jQuery),+function(a){"use strict";var b=function(a,b){this.type=this.options=this.enabled=this.timeout=this.hoverState=this.$element=null,this.init("tooltip",a,b)};b.DEFAULTS={animation:!0,placement:"top",selector:!1,template:'',trigger:"hover focus",title:"",delay:0,html:!1,container:!1},b.prototype.init=function(b,c,d){this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d);for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focus",i="hover"==g?"mouseleave":"blur";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},b.prototype.getDefaults=function(){return b.DEFAULTS},b.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},b.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},b.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget)[this.type](this.getDelegateOptions()).data("bs."+this.type);return clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show),void 0):c.show()},b.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget)[this.type](this.getDelegateOptions()).data("bs."+this.type);return clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide),void 0):c.hide()},b.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){if(this.$element.trigger(b),b.isDefaultPrevented())return;var c=this.tip();this.setContent(),this.options.animation&&c.addClass("fade");var d="function"==typeof this.options.placement?this.options.placement.call(this,c[0],this.$element[0]):this.options.placement,e=/\s?auto?\s?/i,f=e.test(d);f&&(d=d.replace(e,"")||"top"),c.detach().css({top:0,left:0,display:"block"}).addClass(d),this.options.container?c.appendTo(this.options.container):c.insertAfter(this.$element);var g=this.getPosition(),h=c[0].offsetWidth,i=c[0].offsetHeight;if(f){var j=this.$element.parent(),k=d,l=document.documentElement.scrollTop||document.body.scrollTop,m="body"==this.options.container?window.innerWidth:j.outerWidth(),n="body"==this.options.container?window.innerHeight:j.outerHeight(),o="body"==this.options.container?0:j.offset().left;d="bottom"==d&&g.top+g.height+i-l>n?"top":"top"==d&&g.top-l-i<0?"bottom":"right"==d&&g.right+h>m?"left":"left"==d&&g.left-h
'}),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/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 |
--------------------------------------------------------------------------------