From b511b1c647eb4f089d64190b0e1091a241aff425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 6 Sep 2020 14:55:48 +0200 Subject: [PATCH 001/237] Errors don't set the HTTP status code Some (if not all) just ignore the response if it's not a 200 and just consider the server borked. Closes #192 --- supysonic/api/exceptions.py | 6 +++--- supysonic/api/unsupported.py | 4 ++-- tests/api/test_api_setup.py | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/supysonic/api/exceptions.py b/supysonic/api/exceptions.py index 46a2d28c..401726b4 100644 --- a/supysonic/api/exceptions.py +++ b/supysonic/api/exceptions.py @@ -3,7 +3,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2018-2019 Alban 'spl0k' Féron +# Copyright (C) 2018-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -18,7 +18,7 @@ class SubsonicAPIException(HTTPException): def get_response(self, environ=None): rv = request.formatter.error(self.api_code, self.message) - rv.status_code = self.code + # rv.status_code = self.code return rv def __str__(self): @@ -122,5 +122,5 @@ def get_response(self, environ=None): rv = request.formatter( "error", dict(code=list(codes)[0] if len(codes) == 1 else 0, error=errors) ) - rv.status_code = self.code + # rv.status_code = self.code return rv diff --git a/supysonic/api/unsupported.py b/supysonic/api/unsupported.py index dd46dfcc..0a76c1f2 100644 --- a/supysonic/api/unsupported.py +++ b/supysonic/api/unsupported.py @@ -3,7 +3,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2018 Alban 'spl0k' Féron +# Copyright (C) 2018-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -21,7 +21,7 @@ def unsupported(): - return GenericError("Not supported by Supysonic"), 501 + return GenericError("Not supported by Supysonic") for m in methods: diff --git a/tests/api/test_api_setup.py b/tests/api/test_api_setup.py index 55bc01a1..9a91f158 100644 --- a/tests/api/test_api_setup.py +++ b/tests/api/test_api_setup.py @@ -51,13 +51,13 @@ def __form_auth_enc_post(self, username, password): def __test_auth(self, method): # non-existent user rv = method("null", "null") - self.assertEqual(rv.status_code, 401) + self.assertEqual(rv.status_code, 200) self.assertIn('status="failed"', rv.data) self.assertIn('code="40"', rv.data) # user request with bad password rv = method("alice", "wrong password") - self.assertEqual(rv.status_code, 401) + self.assertEqual(rv.status_code, 200) self.assertIn('status="failed"', rv.data) self.assertIn('code="40"', rv.data) @@ -69,7 +69,7 @@ def __test_auth(self, method): def test_auth_basic(self): # No auth info rv = self.client.get("/rest/ping.view?c=tests") - self.assertEqual(rv.status_code, 400) + self.assertEqual(rv.status_code, 200) self.assertIn('status="failed"', rv.data) self.assertIn('code="10"', rv.data) @@ -77,7 +77,7 @@ def test_auth_basic(self): # Shouldn't accept 'enc:' passwords rv = self.__basic_auth_get("alice", "enc:" + hexlify("Alic3")) - self.assertEqual(rv.status_code, 401) + self.assertEqual(rv.status_code, 200) self.assertIn('status="failed"', rv.data) self.assertIn('code="40"', rv.data) @@ -158,14 +158,14 @@ def test_not_implemented(self): "/rest/getVideos.view", query_string={"u": "alice", "p": "Alic3", "c": "tests"}, ) - self.assertEqual(rv.status_code, 501) + self.assertEqual(rv.status_code, 200) self.assertIn('status="failed"', rv.data) self.assertIn('code="0"', rv.data) rv = self.client.post( "/rest/getVideos.view", data={"u": "alice", "p": "Alic3", "c": "tests"} ) - self.assertEqual(rv.status_code, 501) + self.assertEqual(rv.status_code, 200) self.assertIn('status="failed"', rv.data) self.assertIn('code="0"', rv.data) From 593666ae48d02c8a421fc5642b2cd1124ff3041f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 6 Sep 2020 15:51:29 +0200 Subject: [PATCH 002/237] Travis s/.org/.com/g --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 61b9df80..18f224ff 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ _Supysonic_ is a Python implementation of the [Subsonic][] server API. -[![Build Status](https://travis-ci.org/spl0k/supysonic.svg?branch=master)](https://travis-ci.org/spl0k/supysonic) +[![Build Status](https://travis-ci.com/spl0k/supysonic.svg?branch=master)](https://travis-ci.com/spl0k/supysonic) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) ![Python](https://img.shields.io/badge/python-3.5%2C%203.6%2C%203.7%2C%203.8-blue.svg) From b07babb4ff961b3095541286282da57a7d6fb8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 27 Sep 2020 15:26:42 +0200 Subject: [PATCH 003/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 7bf8a2fc..9b19fbbf 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -9,7 +9,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "supysonic" -VERSION = "0.5.0" +VERSION = "0.6.0" DESCRIPTION = "Python implementation of the Subsonic server API." KEYWORDS = "subsonic music api" AUTHOR_NAME = "Alban Féron" From 0183bcb69846471d2ba0c3ae7277e1a504e8b003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 24 Oct 2020 17:55:21 +0200 Subject: [PATCH 004/237] Use mediafile rather than mutagen directly --- README.md | 17 +++-------- setup.py | 2 +- supysonic/api/media.py | 15 +++++----- supysonic/api/radio.py | 13 +++++---- supysonic/covers.py | 50 -------------------------------- supysonic/scanner.py | 66 ++++++++++++++---------------------------- 6 files changed, 41 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 18f224ff..91b4c644 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,10 @@ but not both. ### Prerequisites -You'll need these to run _Supysonic_: - -* Python >= 3.5 -* [Flask](http://flask.pocoo.org/) -* [PonyORM](https://ponyorm.com/) -* [Python Imaging Library](https://github.com/python-pillow/Pillow) -* [requests](http://docs.python-requests.org/) -* [mutagen](https://mutagen.readthedocs.io/en/latest/) -* [watchdog](https://github.com/gorakhargosh/watchdog) -* [zipstream](https://github.com/allanlei/python-zipstream) - -All the dependencies will automatically be installed by the -installation command above. +You'll need Python 3.5 or later to run _Supysonic_. + +All the dependencies will automatically be installed by the installation +command above. You may also need a database specific package if you don't want to use SQLite (the default): diff --git a/setup.py b/setup.py index 6e58aedf..738b92d1 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ "pony>=0.7.6", "Pillow", "requests>=1.0.0", - "mutagen>=1.33", + "mediafile", "watchdog>=0.8.0", "zipstream", ] diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 7f8b6735..cc29aabd 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -3,21 +3,22 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # 2018-2019 Carey 'pR0Ps' Metcalfe # # Distributed under terms of the GNU AGPLv3 license. +import hashlib +import io +import json import logging +import mediafile import mimetypes import os.path import requests import shlex import subprocess import uuid -import io -import hashlib -import json import zlib from flask import request, Response, send_file @@ -30,7 +31,6 @@ from .. import scanner from ..cache import CacheMiss -from ..covers import get_embedded_cover from ..db import Track, Album, Artist, Folder, User, ClientPrefs, now from . import api, get_entity, get_entity_id @@ -267,8 +267,9 @@ def cover_art(): cover_path = cache.get(cache_key) except CacheMiss: res = get_entity(Track) - art = get_embedded_cover(res.path) - if not art: + try: + art = mediafile.MediaFile(res.path).art + except mediafile.UnreadableFileError: raise NotFound("Cover art") cover_path = cache.set(cache_key, art) else: diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index c8525d2a..65e7fc36 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -17,9 +17,7 @@ @api.route("/getInternetRadioStations.view", methods=["GET", "POST"]) def get_radio_stations(): - query = RadioStation.select().sort_by( - RadioStation.name - ) + query = RadioStation.select().sort_by(RadioStation.name) return request.formatter( "internetRadioStations", dict(internetRadioStation=[p.as_subsonic_station() for p in query]), @@ -31,7 +29,9 @@ def create_radio_station(): if not request.user.admin: raise Forbidden() - stream_url, name, homepage_url = map(request.values.get, ["streamUrl", "name", "homepageUrl"]) + stream_url, name, homepage_url = map( + request.values.get, ["streamUrl", "name", "homepageUrl"] + ) if stream_url and name: RadioStation(stream_url=stream_url, name=name, homepage_url=homepage_url) @@ -48,7 +48,9 @@ def update_radio_station(): res = get_entity(RadioStation) - stream_url, name, homepage_url = map(request.values.get, ["streamUrl", "name", "homepageUrl"]) + stream_url, name, homepage_url = map( + request.values.get, ["streamUrl", "name", "homepageUrl"] + ) if stream_url and name: res.stream_url = stream_url res.name = name @@ -70,4 +72,3 @@ def delete_radio_station(): res.delete() return request.formatter.empty - diff --git a/supysonic/covers.py b/supysonic/covers.py index e749f9d7..3c1ed384 100644 --- a/supysonic/covers.py +++ b/supysonic/covers.py @@ -11,11 +11,6 @@ import re import warnings -from base64 import b64decode -from mutagen import File, FileType -from mutagen.easyid3 import EasyID3 -from mutagen.flac import FLAC, Picture -from mutagen._vorbis import VCommentDict from PIL import Image from os import scandir @@ -90,48 +85,3 @@ def find_cover_in_folder(path, album_name=None): return candidates[0] return sorted(candidates, key=lambda c: c.score, reverse=True)[0] - - -def get_embedded_cover(path): - if not isinstance(path, str): # pragma: nocover - raise TypeError("Expecting string, got " + str(type(path))) - - if not os.path.exists(path): - return None - - metadata = File(path, easy=True) - if not metadata: - return None - - if isinstance(metadata.tags, EasyID3): - picture = metadata["pictures"][0] - elif isinstance(metadata, FLAC): - picture = metadata.pictures[0] - elif isinstance(metadata.tags, VCommentDict): - picture = Picture(b64decode(metadata.tags["METADATA_BLOCK_PICTURE"][0])) - else: - return None - - return picture.data - - -def has_embedded_cover(metadata): - if not isinstance(metadata, FileType): # pragma: nocover - raise TypeError("Expecting mutagen.FileType, got " + str(type(metadata))) - - pictures = [] - if isinstance(metadata.tags, EasyID3): - pictures = metadata.get("pictures", []) - elif isinstance(metadata, FLAC): - pictures = metadata.pictures - elif isinstance(metadata.tags, VCommentDict): - pictures = metadata.tags.get("METADATA_BLOCK_PICTURE", []) - - return len(pictures) > 0 - - -def _get_id3_apic(id3, key): - return id3.getall("APIC") - - -EasyID3.RegisterKey("pictures", _get_id3_apic) diff --git a/supysonic/scanner.py b/supysonic/scanner.py index d8cb7d0c..c3f6a04f 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -3,13 +3,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import logging import os, os.path -import mutagen +import mediafile import time from datetime import datetime @@ -17,7 +17,7 @@ from queue import Queue, Empty as QueueEmpty from threading import Thread, Event -from .covers import find_cover_in_folder, has_embedded_cover, CoverFile +from .covers import find_cover_in_folder, CoverFile from .db import Folder, Artist, Album, Track, User from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from .db import RatingFolder, RatingTrack @@ -233,32 +233,19 @@ def scan_file(self, path_or_direntry): trdict = {"path": path} - artist = self.__try_read_tag(tag, "artist", "[unknown]")[:255] - album = self.__try_read_tag(tag, "album", "[non-album tracks]")[:255] - albumartist = self.__try_read_tag(tag, "albumartist", artist)[:255] + artist = (self.__sanitize_str(tag.artist) or "[unknown]")[:255] + album = (self.__sanitize_str(tag.album) or "[non-album tracks]")[:255] + albumartist = (self.__sanitize_str(tag.albumartist) or artist)[:255] - trdict["disc"] = self.__try_read_tag( - tag, "discnumber", 1, lambda x: int(x.split("/")[0]) - ) - trdict["number"] = self.__try_read_tag( - tag, "tracknumber", 1, lambda x: int(x.split("/")[0]) - ) - trdict["title"] = self.__try_read_tag(tag, "title", basename)[:255] - trdict["year"] = self.__try_read_tag( - tag, "date", None, lambda x: int(x.split("-")[0]) - ) - trdict["genre"] = self.__try_read_tag(tag, "genre") - trdict["duration"] = int(tag.info.length) - trdict["has_art"] = has_embedded_cover(tag) - - trdict["bitrate"] = ( - int( - tag.info.bitrate - if hasattr(tag.info, "bitrate") - else size * 8 / tag.info.length - ) - // 1000 - ) + trdict["disc"] = tag.disc or 1 + trdict["number"] = tag.track or 1 + trdict["title"] = (self.__sanitize_str(tag.title) or basename)[:255] + trdict["year"] = tag.year + trdict["genre"] = tag.genre + trdict["duration"] = int(tag.length) + trdict["has_art"] = bool(tag.images) + + trdict["bitrate"] = tag.bitrate trdict["last_modification"] = mtime tralbum = self.__find_album(albumartist, album) @@ -431,25 +418,14 @@ def __find_folder(self, path): def __try_load_tag(self, path): try: - return mutagen.File(path, easy=True) - except mutagen.MutagenError: + return mediafile.MediaFile(path) + except mediafile.UnreadableFileError: return None - def __try_read_tag(self, metadata, field, default=None, transform=None): - try: - value = metadata[field][0] - value = value.replace("\x00", "").strip() - - if not value: - return default - if transform: - value = transform(value) - return value if value else default - # KeyError: missing tag - # IndexError: tag is present but doesn't have any value - # ValueError: tag can't be transformed to correct type - except (KeyError, IndexError, ValueError): - return default + def __sanitize_str(self, value): + if value is None: + return None + return value.replace("\x00", "").strip() def stats(self): return self.__stats From 194bf5e277c7069b0e8eb36d26017e2b65789738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 1 Nov 2020 15:44:56 +0100 Subject: [PATCH 005/237] Add test for M4A embedded art --- tests/api/test_media.py | 7 +++---- tests/assets/formats/silence.m4a | Bin 0 -> 13072 bytes 2 files changed, 3 insertions(+), 4 deletions(-) create mode 100755 tests/assets/formats/silence.m4a diff --git a/tests/api/test_media.py b/tests/api/test_media.py index b24f6ade..66c4fc16 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -52,7 +51,7 @@ def setUp(self): ) self.trackid = track.id - self.formats = [("mp3", "mpeg"), ("flac", "flac"), ("ogg", "ogg")] + self.formats = ["mp3", "flac", "ogg", "m4a"] for i in range(len(self.formats)): track_embeded_art = Track( title="[silence]", @@ -61,7 +60,7 @@ def setUp(self): artist=artist, album=album, path=os.path.abspath( - "tests/assets/formats/silence.{0}".format(self.formats[i][0]) + "tests/assets/formats/silence.{0}".format(self.formats[i]) ), root_folder=folder, folder=folder, diff --git a/tests/assets/formats/silence.m4a b/tests/assets/formats/silence.m4a new file mode 100755 index 0000000000000000000000000000000000000000..9e9675cb3a6f75f7a35e8ba9b80f3e1a1ce18725 GIT binary patch literal 13072 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 literal 0 HcmV?d00001 From 7d1825151e2ee9bc6667971d9d9b8f49145bc14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 7 Nov 2020 15:44:09 +0100 Subject: [PATCH 006/237] Deduplicate on getAlbumList Fixes #199 --- supysonic/api/albums_songs.py | 4 +-- tests/api/test_album_songs.py | 51 ++++++++++++++++++++++------------- tests/testbase.py | 4 +-- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 719998c3..774988da 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -85,7 +85,7 @@ def album_list(): dict( album=[ a.as_subsonic_child(request.user) - for a in query.without_distinct().random(size) + for a in query.distinct().random(size) ] ), ) @@ -106,7 +106,7 @@ def album_list(): if s.user.id == request.user.id and count(s.starred.tracks) > 0 ) elif ltype == "alphabeticalByName": - query = query.order_by(Folder.name) + query = query.order_by(Folder.name).distinct() elif ltype == "alphabeticalByArtist": query = query.order_by(lambda f: f.parent.name + f.name) else: diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index 3d86c810..257834a1 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -29,13 +29,26 @@ def setUp(self): artist = Artist(name="Artist") album = Album(name="Album", artist=artist) - track = Track( - title="Track", + Track( + title="Track 1", album=album, artist=artist, disc=1, number=1, - path="tests/assets/empty", + path="tests/assets/folder/1", + folder=folder, + root_folder=folder, + duration=2, + bitrate=320, + last_modification=0, + ) + Track( + title="Track 2", + album=album, + artist=artist, + disc=1, + number=1, + path="tests/assets/folder/2", folder=folder, root_folder=folder, duration=2, @@ -51,24 +64,24 @@ def test_get_album_list(self): "getAlbumList", {"type": "newest", "offset": "minus one"}, error=0 ) - types = [ - "random", - "newest", - "highest", - "frequent", - "recent", - "alphabeticalByName", - "alphabeticalByArtist", - "starred", + types_and_count = [ + ("random", 1), + ("newest", 1), + ("highest", 1), + ("frequent", 1), + ("recent", 0), # never played + ("alphabeticalByName", 1), + ( + "alphabeticalByArtist", + 0, # somehow expected due to funky "album" definition on this endpoint + ), + ("starred", 0), # nothing's starred ] - for t in types: - self._make_request( + for t, c in types_and_count: + rv, child = self._make_request( "getAlbumList", {"type": t}, tag="albumList", skip_post=True ) - - rv, child = self._make_request( - "getAlbumList", {"type": "random"}, tag="albumList", skip_post=True - ) + self.assertEqual(len(child), c) with db_session: Folder.get().delete() @@ -106,7 +119,7 @@ def test_get_album_list2(self): ) with db_session: - Track.get().delete() + Track.select().delete() Album.get().delete() rv, child = self._make_request( "getAlbumList2", {"type": "random"}, tag="albumList2" diff --git a/tests/testbase.py b/tests/testbase.py index 9acbc373..bae3b6a8 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -82,10 +82,9 @@ class TestBase(unittest.TestCase): __with_api__ = False def setUp(self): - self.__dbfile = tempfile.mkstemp()[1] self.__dir = tempfile.mkdtemp() config = TestConfig(self.__with_webui__, self.__with_api__) - config.BASE["database_uri"] = "sqlite:///" + self.__dbfile + config.BASE["database_uri"] = "sqlite:" config.WEBAPP["cache_dir"] = self.__dir init_database(config.BASE["database_uri"]) @@ -108,4 +107,3 @@ def request_context(self, *args, **kwargs): def tearDown(self): release_database() shutil.rmtree(self.__dir) - os.remove(self.__dbfile) From 5c46c96b53a37516481b55f5134dabebaeb4b011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Nov 2020 15:39:09 +0100 Subject: [PATCH 007/237] Some fixes for Windows support (especially for tests) The main motive here isn't full Windows support per, but being able to run tests on Windows, as this is my main platform. Booting a VM just to run tests is cumbersome. --- supysonic/cache.py | 37 +++++------ supysonic/config.py | 7 +- tests/api/test_radio.py | 107 ++++++++++++++++++------------- tests/api/test_transcoding.py | 12 +++- tests/assets/formats/silence.mp3 | Bin 21 -> 85812 bytes tests/base/test_cli.py | 49 ++++++-------- tests/base/test_db.py | 2 +- tests/base/test_scanner.py | 26 +++++--- tests/base/test_secret.py | 5 +- tests/base/test_watcher.py | 12 ++-- tests/issue148.py | 6 +- tests/issue85.py | 4 ++ tests/testbase.py | 11 ++-- tests/with_net.py | 1 + 14 files changed, 156 insertions(+), 123 deletions(-) mode change 120000 => 100644 tests/assets/formats/silence.mp3 diff --git a/supysonic/cache.py b/supysonic/cache.py index 3b18727d..7584a0de 100644 --- a/supysonic/cache.py +++ b/supysonic/cache.py @@ -143,25 +143,26 @@ def set_fileobj(self, key): >>> with cache.set_fileobj(key) as fp: ... json.dump(some_data, fp) """ + f = tempfile.NamedTemporaryFile( + dir=self._cache_dir, suffix=".part", delete=False + ) try: - with tempfile.NamedTemporaryFile( - dir=self._cache_dir, suffix=".part", delete=True - ) as f: - yield f - - # seek to end and get position to get filesize - f.seek(0, 2) - size = f.tell() - - with self._lock: - if self._auto_prune: - self._make_space(size, key=key) - os.replace(f.name, self._filepath(key)) - self._record_file(key, size) - except OSError as e: - # Ignore error from trying to delete the renamed temp file - if e.errno != errno.ENOENT: - raise + yield f + + # seek to end and get position to get filesize + f.seek(0, 2) + size = f.tell() + f.close() + + with self._lock: + if self._auto_prune: + self._make_space(size, key=key) + os.replace(f.name, self._filepath(key)) + self._record_file(key, size) + except: + f.close() + os.remove(f.name) + raise def set(self, key, value): """Set a literal value into the cache and return its path""" diff --git a/supysonic/config.py b/supysonic/config.py index 203cf293..56c9acba 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -3,12 +3,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. import os +import sys import tempfile from configparser import RawConfigParser @@ -39,7 +40,9 @@ class DefaultConfig(object): "mount_api": True, } DAEMON = { - "socket": os.path.join(tempdir, "supysonic.sock"), + "socket": r"\\.\pipe\supysonic" + if sys.platform == "win32" + else os.path.join(tempdir, "supysonic.sock"), "run_watcher": True, "wait_delay": 5, "jukebox_command": None, diff --git a/tests/api/test_radio.py b/tests/api/test_radio.py index 65bbc6ca..f01ef29d 100644 --- a/tests/api/test_radio.py +++ b/tests/api/test_radio.py @@ -35,22 +35,25 @@ def test_create_radio_station(self): self._make_request( "createInternetRadioStation", {"u": "bob", "p": "B0b", "username": "alice"}, - error=50 + error=50, ) # check params self._make_request("createInternetRadioStation", error=10) - self._make_request("createInternetRadioStation", {"streamUrl": "missingName"}, error=10) - self._make_request("createInternetRadioStation", {"name": "missing stream"}, error=10) + self._make_request( + "createInternetRadioStation", {"streamUrl": "missingName"}, error=10 + ) + self._make_request( + "createInternetRadioStation", {"name": "missing stream"}, error=10 + ) # create w/ required fields stream_url = "http://example.com/radio/create" name = "radio station" - self._make_request("createInternetRadioStation", { - "streamUrl": stream_url, - "name": name, - }) + self._make_request( + "createInternetRadioStation", {"streamUrl": stream_url, "name": name} + ) # the correct value is 2 because _make_request uses GET then POST self.assertRadioStationCountEqual(2) @@ -66,11 +69,10 @@ def test_create_radio_station(self): name = "radio station1" homepage_url = "http://example.com/home" - self._make_request("createInternetRadioStation", { - "streamUrl": stream_url, - "name": name, - "homepageUrl": homepage_url, - }) + self._make_request( + "createInternetRadioStation", + {"streamUrl": stream_url, "name": name, "homepageUrl": homepage_url}, + ) # the correct value is 2 because _make_request uses GET then POST self.assertRadioStationCountEqual(2) @@ -83,7 +85,7 @@ def test_update_radio_station(self): self._make_request( "updateInternetRadioStation", {"u": "bob", "p": "B0b", "username": "alice"}, - error=50 + error=50, ) # test data @@ -107,67 +109,86 @@ def test_update_radio_station(self): ) # check params - self._make_request("updateInternetRadioStation", { - "id": station.id, "homepageUrl": "missing required params", - }, error=10) - self._make_request("updateInternetRadioStation", { - "id": station.id, "name": "missing streamUrl", - }, error=10) - self._make_request("updateInternetRadioStation", { - "id": station.id, "streamUrl": "missing name", - }, error=10) + self._make_request( + "updateInternetRadioStation", + {"id": station.id, "homepageUrl": "missing required params"}, + error=10, + ) + self._make_request( + "updateInternetRadioStation", + {"id": station.id, "name": "missing streamUrl"}, + error=10, + ) + self._make_request( + "updateInternetRadioStation", + {"id": station.id, "streamUrl": "missing name"}, + error=10, + ) # update the record w/ required fields - self._make_request("updateInternetRadioStation", { - "id": station.id, - "streamUrl": update["stream_url"], - "name": update["name"], - }) + self._make_request( + "updateInternetRadioStation", + { + "id": station.id, + "streamUrl": update["stream_url"], + "name": update["name"], + }, + ) with db_session: rs_update = RadioStation[station.id] - self.assertRadioStationEquals(rs_update, update["stream_url"], update["name"], test["homepage_url"]) + self.assertRadioStationEquals( + rs_update, update["stream_url"], update["name"], test["homepage_url"] + ) # update the record w/ all fields - self._make_request("updateInternetRadioStation", { - "id": station.id, - "streamUrl": update["stream_url"], - "name": update["name"], - "homepageUrl": update["homepage_url"], - }) + self._make_request( + "updateInternetRadioStation", + { + "id": station.id, + "streamUrl": update["stream_url"], + "name": update["name"], + "homepageUrl": update["homepage_url"], + }, + ) with db_session: rs_update = RadioStation[station.id] - self.assertRadioStationEquals(rs_update, update["stream_url"], update["name"], update["homepage_url"]) + self.assertRadioStationEquals( + rs_update, update["stream_url"], update["name"], update["homepage_url"] + ) def test_delete_radio_station(self): # test for non-admin access self._make_request( "deleteInternetRadioStation", {"u": "bob", "p": "B0b", "username": "alice"}, - error=50 + error=50, ) # check params self._make_request("deleteInternetRadioStation", error=10) self._make_request("deleteInternetRadioStation", {"id": 1}, error=0) - self._make_request("deleteInternetRadioStation", {"id": str(uuid.uuid4())}, error=70) + self._make_request( + "deleteInternetRadioStation", {"id": str(uuid.uuid4())}, error=70 + ) # delete with db_session: station = RadioStation( stream_url="http://example.com/radio/delete", name="Radio Delete", - homepage_url="http://example.com/update" + homepage_url="http://example.com/update", ) - self._make_request("deleteInternetRadioStation", {"id": station.id}, skip_post=True) + self._make_request( + "deleteInternetRadioStation", {"id": station.id}, skip_post=True + ) self.assertRadioStationCountEqual(0) - def test_get_radio_stations(self): test_range = 3 with db_session: @@ -180,7 +201,9 @@ def test_get_radio_stations(self): # verify happy path is clean self.assertRadioStationCountEqual(test_range) - rv, child = self._make_request("getInternetRadioStations", tag="internetRadioStations") + rv, child = self._make_request( + "getInternetRadioStations", tag="internetRadioStations" + ) self.assertEqual(len(child), test_range) # This order is guaranteed to work because the api returns in order by name. # Test data is sequential by design. @@ -190,7 +213,6 @@ def test_get_radio_stations(self): self.assertTrue(station.get("name").endswith("Radio {}".format(x))) self.assertTrue(station.get("homePageUrl").endswith("update-{}".format(x))) - # test for non-admin access rv, child = self._make_request( "getInternetRadioStations", @@ -199,4 +221,3 @@ def test_get_radio_stations(self): ) self.assertEqual(len(child), test_range) - diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index afd8d2c0..ad9fb005 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -1,14 +1,14 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import unittest +import sys from pony.orm import db_session @@ -46,11 +46,19 @@ def _stream(self, **kwargs): def test_no_transcoding_available(self): self._make_request("stream", {"id": self.trackid, "format": "wat"}, error=0) + @unittest.skipIf( + sys.platform == "win32", + "Can't test transcoding on Windows because of a lack of simple commandline tools", + ) def test_direct_transcode(self): rv = self._stream(maxBitRate=96, estimateContentLength="true") self.assertIn("tests/assets/folder/silence.mp3", rv.data) self.assertTrue(rv.data.endswith("96")) + @unittest.skipIf( + sys.platform == "win32", + "Can't test transcoding on Windows because of a lack of simple commandline tools", + ) def test_decode_encode(self): rv = self._stream(format="cat") self.assertEqual(rv.data, "Pushing out some mp3 data...") diff --git a/tests/assets/formats/silence.mp3 b/tests/assets/formats/silence.mp3 deleted file mode 120000 index fa81f24f..00000000 --- a/tests/assets/formats/silence.mp3 +++ /dev/null @@ -1 +0,0 @@ -../folder/silence.mp3 \ No newline at end of file diff --git a/tests/assets/formats/silence.mp3 b/tests/assets/formats/silence.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..02c2c8b5f482a68199251bad6352e17d128746fd GIT binary patch literal 85812 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 Date: Sun, 8 Nov 2020 15:40:37 +0100 Subject: [PATCH 008/237] Prevent tests from speing some ResourceWarnings --- tests/api/test_media.py | 119 ++++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 52 deletions(-) diff --git a/tests/api/test_media.py b/tests/api/test_media.py index 66c4fc16..e8847e0d 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -10,6 +10,7 @@ import os.path import uuid +from contextlib import closing from io import BytesIO from PIL import Image from pony.orm import db_session @@ -85,17 +86,19 @@ def test_stream(self): "stream", {"id": str(self.trackid), "size": "640x480"}, error=0 ) - rv = self.client.get( - "/rest/stream.view", - query_string={ - "u": "alice", - "p": "Alic3", - "c": "tests", - "id": str(self.trackid), - }, - ) - self.assertEqual(rv.status_code, 200) - self.assertEqual(len(rv.data), 23) + with closing( + self.client.get( + "/rest/stream.view", + query_string={ + "u": "alice", + "p": "Alic3", + "c": "tests", + "id": str(self.trackid), + }, + ) + ) as rv: + self.assertEqual(rv.status_code, 200) + self.assertEqual(len(rv.data), 23) with db_session: self.assertEqual(Track[self.trackid].play_count, 1) @@ -105,17 +108,19 @@ def test_download(self): self._make_request("download", {"id": str(uuid.uuid4())}, error=70) # download single file - rv = self.client.get( - "/rest/download.view", - query_string={ - "u": "alice", - "p": "Alic3", - "c": "tests", - "id": str(self.trackid), - }, - ) - self.assertEqual(rv.status_code, 200) - self.assertEqual(len(rv.data), 23) + with closing( + self.client.get( + "/rest/download.view", + query_string={ + "u": "alice", + "p": "Alic3", + "c": "tests", + "id": str(self.trackid), + }, + ) + ) as rv: + self.assertEqual(rv.status_code, 200) + self.assertEqual(len(rv.data), 23) with db_session: self.assertEqual(Track[self.trackid].play_count, 0) @@ -142,47 +147,57 @@ def test_get_cover_art(self): ) args = {"u": "alice", "p": "Alic3", "c": "tests", "id": str(self.folderid)} - rv = self.client.get("/rest/getCoverArt.view", query_string=args) - self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (420, 420)) + with closing( + self.client.get("/rest/getCoverArt.view", query_string=args) + ) as rv: + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, "image/jpeg") + im = Image.open(BytesIO(rv.data)) + self.assertEqual(im.format, "JPEG") + self.assertEqual(im.size, (420, 420)) args["size"] = 600 - rv = self.client.get("/rest/getCoverArt.view", query_string=args) - self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (420, 420)) + with closing( + self.client.get("/rest/getCoverArt.view", query_string=args) + ) as rv: + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, "image/jpeg") + im = Image.open(BytesIO(rv.data)) + self.assertEqual(im.format, "JPEG") + self.assertEqual(im.size, (420, 420)) args["size"] = 120 - rv = self.client.get("/rest/getCoverArt.view", query_string=args) - self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (120, 120)) + with closing( + self.client.get("/rest/getCoverArt.view", query_string=args) + ) as rv: + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, "image/jpeg") + im = Image.open(BytesIO(rv.data)) + self.assertEqual(im.format, "JPEG") + self.assertEqual(im.size, (120, 120)) # rerequest, just in case - rv = self.client.get("/rest/getCoverArt.view", query_string=args) - self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (120, 120)) + with closing( + self.client.get("/rest/getCoverArt.view", query_string=args) + ) as rv: + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, "image/jpeg") + im = Image.open(BytesIO(rv.data)) + self.assertEqual(im.format, "JPEG") + self.assertEqual(im.size, (120, 120)) # TODO test non square covers # Test extracting cover art from embeded media for args["id"] in self.formats: - rv = self.client.get("/rest/getCoverArt.view", query_string=args) - self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.mimetype, "image/png") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "PNG") - self.assertEqual(im.size, (120, 120)) + with closing( + self.client.get("/rest/getCoverArt.view", query_string=args) + ) as rv: + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, "image/png") + im = Image.open(BytesIO(rv.data)) + self.assertEqual(im.format, "PNG") + self.assertEqual(im.size, (120, 120)) def test_get_avatar(self): self._make_request("getAvatar", error=0) From 1be526b8d2a73612a91b28d481239d4689eac7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Nov 2020 18:00:36 +0100 Subject: [PATCH 009/237] Subsonic API 1.10.2 Except for changes required to comply to the XSD specification, this does not include any feature this version brings Closes #194 --- docs/api.md | 2 +- supysonic/api/__init__.py | 6 ++---- supysonic/api/browse.py | 17 +++++++++++----- tests/api/apitestbase.py | 2 +- tests/api/test_browse.py | 11 ++++++++-- ...1.9.0.xsd => subsonic-rest-api-1.10.2.xsd} | 20 +++++++++++++++---- 6 files changed, 41 insertions(+), 17 deletions(-) rename tests/assets/{subsonic-rest-api-1.9.0.xsd => subsonic-rest-api-1.10.2.xsd} (93%) diff --git a/docs/api.md b/docs/api.md index 0aa269d5..96143fa5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ This page lists all the API methods and their parameters up to the version 1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features _Supysonic_ support, plan on supporting, or won't. -At the moment, the current target API version is 1.9.0. +At the moment, the current target API version is 1.10.2. The following information was gathered by _diff_-ing various snapshots of the [Subsonic API page](http://www.subsonic.org/pages/api.jsp). diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index c85831f0..ac74d960 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -1,13 +1,11 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. -API_VERSION = "1.9.0" +API_VERSION = "1.10.2" import binascii import uuid diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index 37978842..a3cdebd2 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -3,7 +3,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -11,7 +11,7 @@ import uuid from flask import request -from pony.orm import ObjectNotFound, select +from pony.orm import ObjectNotFound, select, count from ..db import Folder, Artist, Album, Track @@ -50,7 +50,9 @@ def list_indexes(): last_modif = max(map(lambda f: f.last_scan, folders)) if ifModifiedSince is not None and last_modif < ifModifiedSince: - return request.formatter("indexes", dict(lastModified=last_modif * 1000)) + return request.formatter( + "indexes", dict(lastModified=last_modif * 1000, ignoredArticles="") + ) # The XSD lies, we don't return artists but a directory structure artists = [] @@ -76,6 +78,7 @@ def list_indexes(): "indexes", dict( lastModified=last_modif * 1000, + ignoredArticles="", index=[ dict( name=k, @@ -121,7 +124,10 @@ def list_genres(): "genres", dict( genre=[ - dict(value=genre) for genre in select(t.genre for t in Track if t.genre) + dict(value=genre, songCount=sc, albumCount=ac) + for genre, sc, ac in select( + (t.genre, count(), count(t.album)) for t in Track if t.genre + ) ] ), ) @@ -146,6 +152,7 @@ def list_artists(): return request.formatter( "artists", dict( + ignoredArticles="", index=[ dict( name=k, @@ -155,7 +162,7 @@ def list_artists(): ], ) for k, v in sorted(indexes.items()) - ] + ], ), ) diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index 2269f18e..fe0dd629 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -26,7 +26,7 @@ class ApiTestBase(TestBase): def setUp(self): super(ApiTestBase, self).setUp() - xsd = etree.parse("tests/assets/subsonic-rest-api-1.9.0.xsd") + xsd = etree.parse("tests/assets/subsonic-rest-api-1.10.2.xsd") self.schema = etree.XMLSchema(xsd) def _find(self, xml, path): diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index 395ead11..62d36488 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -53,6 +52,7 @@ def setUp(self): duration=2, album=album, artist=artist, + genre="Music!", bitrate=320, path="tests/assets/{0}rtist/{0}{1}lbum/{2}".format( letter, lether, song @@ -201,6 +201,13 @@ def test_get_song(self): def test_get_videos(self): self._make_request("getVideos", error=0) + def test_genres(self): + rv, child = self._make_request("getGenres", tag="genres") + self.assertEqual(len(child), 1) + self.assertEqual(child[0].text, "Music!") + self.assertEqual(child[0].get("songCount"), "18") + self.assertEqual(child[0].get("albumCount"), "6") + if __name__ == "__main__": unittest.main() diff --git a/tests/assets/subsonic-rest-api-1.9.0.xsd b/tests/assets/subsonic-rest-api-1.10.2.xsd similarity index 93% rename from tests/assets/subsonic-rest-api-1.9.0.xsd rename to tests/assets/subsonic-rest-api-1.10.2.xsd index 1a80d968..a409c45a 100644 --- a/tests/assets/subsonic-rest-api-1.9.0.xsd +++ b/tests/assets/subsonic-rest-api-1.10.2.xsd @@ -4,7 +4,7 @@ targetNamespace="http://subsonic.org/restapi" attributeFormDefault="unqualified" elementFormDefault="qualified" - version="1.9.0"> + version="1.10.2"> @@ -68,8 +68,8 @@ - - + + @@ -79,6 +79,7 @@ + @@ -91,18 +92,25 @@ + - + + + + + + + @@ -140,6 +148,8 @@ + + @@ -165,6 +175,7 @@ + @@ -195,6 +206,7 @@ + From e0946c0e3268e035e567d5e41a8a2b5487a44e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 9 Nov 2020 12:03:05 +0100 Subject: [PATCH 010/237] Remove the ability to skip XSD validation in tests We have int folder ids for long now, this isn't needed anymore --- tests/api/apitestbase.py | 12 +++--------- tests/api/test_annotation.py | 15 +++++++-------- tests/api/test_browse.py | 6 +----- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index fe0dd629..ea75c2a1 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -1,9 +1,7 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -45,9 +43,7 @@ def _xpath(self, elem, path): path = path_replace_regexp.sub(r"/sub:\1", path) return elem.xpath(path, namespaces=NSMAP) - def _make_request( - self, endpoint, args={}, tag=None, error=None, skip_post=False, skip_xsd=False - ): + def _make_request(self, endpoint, args={}, tag=None, error=None, skip_post=False): """ Makes both a GET and POST requests against the API, assert both get the same response. If the user isn't provided with the 'u' and 'p' in 'args', the default 'alice' is used. @@ -58,7 +54,6 @@ def _make_request( :param tag: topmost expected element, right beneath 'subsonic-response' :param error: if the given 'args' should produce an error at 'endpoint', this is the expected error code :param skip_post: don't do the POST request - :param skip_xsd: skip XSD validation :return: a 2-tuple (resp, child) if no error, where 'resp' is the full response object, 'child' a 'lxml.etree.Element' mathching 'tag' (if any). If there's an error (when expected), only returns @@ -81,8 +76,7 @@ def _make_request( self.assertEqual(rg.data, rp.data) xml = etree.fromstring(rg.data) - if not skip_xsd: - self.schema.assert_(xml) + self.schema.assert_(xml) if xml.get("status") == "ok": self.assertIsNone(error) diff --git a/tests/api/test_annotation.py b/tests/api/test_annotation.py index da4bc741..3a2120cc 100644 --- a/tests/api/test_annotation.py +++ b/tests/api/test_annotation.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -53,15 +52,15 @@ def setUp(self): def test_star(self): self._make_request("star", error=10) - self._make_request("star", {"id": "unknown"}, error=0, skip_xsd=True) + self._make_request("star", {"id": "unknown"}, error=0) self._make_request("star", {"albumId": "unknown"}, error=0) self._make_request("star", {"artistId": "unknown"}, error=0) - self._make_request("star", {"id": str(uuid.uuid4())}, error=70, skip_xsd=True) + self._make_request("star", {"id": str(uuid.uuid4())}, error=70) self._make_request("star", {"albumId": str(uuid.uuid4())}, error=70) self._make_request("star", {"artistId": str(uuid.uuid4())}, error=70) - self._make_request("star", {"id": str(self.artistid)}, error=70, skip_xsd=True) - self._make_request("star", {"id": str(self.albumid)}, error=70, skip_xsd=True) + self._make_request("star", {"id": str(self.artistid)}, error=70) + self._make_request("star", {"id": str(self.albumid)}, error=70) self._make_request("star", {"id": str(self.trackid)}, skip_post=True) with db_session: prefs = ClientPrefs.get( @@ -70,12 +69,12 @@ def test_star(self): self.assertIn( "starred", Track[self.trackid].as_subsonic_child(self.user, prefs) ) - self._make_request("star", {"id": str(self.trackid)}, error=0, skip_xsd=True) + self._make_request("star", {"id": str(self.trackid)}, error=0) self._make_request("star", {"id": str(self.folderid)}, skip_post=True) with db_session: self.assertIn("starred", Folder[self.folderid].as_subsonic_child(self.user)) - self._make_request("star", {"id": str(self.folderid)}, error=0, skip_xsd=True) + self._make_request("star", {"id": str(self.folderid)}, error=0) self._make_request("star", {"albumId": str(self.folderid)}, error=0) self._make_request("star", {"albumId": str(self.artistid)}, error=70) @@ -107,7 +106,7 @@ def test_unstar(self): ) self._make_request("unstar", error=10) - self._make_request("unstar", {"id": "unknown"}, error=0, skip_xsd=True) + self._make_request("unstar", {"id": "unknown"}, error=0) self._make_request("unstar", {"albumId": "unknown"}, error=0) self._make_request("unstar", {"artistId": "unknown"}, error=0) diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index 62d36488..273eab50 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -69,11 +69,7 @@ def setUp(self): self.assertEqual(Track.select().count(), 18) def test_get_music_folders(self): - # Do not validate against the XSD here, this is the only place where the API should return ids as ints - # all our ids are uuids :/ - rv, child = self._make_request( - "getMusicFolders", tag="musicFolders", skip_xsd=True - ) + rv, child = self._make_request("getMusicFolders", tag="musicFolders") self.assertEqual(len(child), 2) self.assertSequenceEqual( sorted(self._xpath(child, "./musicFolder/@name")), From 1f3d697b9ccc29155af5f3b5408553abae022e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 9 Nov 2020 15:03:13 +0100 Subject: [PATCH 011/237] Adding new attributes brought by API upgrade --- supysonic/api/albums_songs.py | 6 ++--- supysonic/api/browse.py | 21 +++--------------- supysonic/api/search.py | 6 ++--- supysonic/db.py | 42 +++++++++++++++++++++++++++++++---- 4 files changed, 45 insertions(+), 30 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 774988da..086e736c 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -1,9 +1,7 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -205,7 +203,7 @@ def get_starred(): "starred", dict( artist=[ - dict(id=str(sf.id), name=sf.name) + sf.as_subsonic_artist(request.user) for sf in folders.filter(lambda f: count(f.tracks) == 0) ], album=[ diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index a3cdebd2..f0d976be 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -83,7 +81,7 @@ def list_indexes(): dict( name=k, artist=[ - dict(id=str(a.id), name=a.name) + a.as_subsonic_artist(request.user) for a in sorted(v, key=lambda a: a.name.lower()) ], ) @@ -100,22 +98,9 @@ def list_indexes(): @api.route("/getMusicDirectory.view", methods=["GET", "POST"]) def show_directory(): res = get_entity(Folder) - directory = dict( - id=str(res.id), - name=res.name, - child=[ - f.as_subsonic_child(request.user) - for f in res.children.order_by(lambda c: c.name.lower()) - ] - + [ - t.as_subsonic_child(request.user, request.client) - for t in sorted(res.tracks, key=lambda t: t.sort_key()) - ], + return request.formatter( + "directory", res.as_subsonic_directory(request.user, request.client) ) - if not res.root: - directory["parent"] = str(res.parent.id) - - return request.formatter("directory", directory) @api.route("/getGenres.view", methods=["GET", "POST"]) diff --git a/supysonic/api/search.py b/supysonic/api/search.py index a1d75245..1e67524e 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -1,9 +1,7 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -119,7 +117,7 @@ def new_search(): "searchResult2", OrderedDict( ( - ("artist", [dict(id=str(a.id), name=a.name) for a in artists]), + ("artist", [a.as_subsonic_artist(request.user) for a in artists]), ("album", [f.as_subsonic_child(request.user) for f in albums]), ( "song", diff --git a/supysonic/db.py b/supysonic/db.py index a2712a87..77e1c730 100755 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -18,7 +16,7 @@ from pony.orm import Database, Required, Optional, Set, PrimaryKey, LongStr from pony.orm import ObjectNotFound, DatabaseError from pony.orm import buffer -from pony.orm import min, max, avg, sum, exists +from pony.orm import min, max, avg, sum, count, exists from pony.orm import db_session from urllib.parse import urlparse, parse_qsl from uuid import UUID, uuid4 @@ -130,6 +128,35 @@ def as_subsonic_child(self, user): return info + def as_subsonic_artist(self, user): # "Artist" type in XSD + info = dict(id=str(self.id), name=self.name) + + try: + starred = StarredFolder[user.id, self.id] + info["starred"] = starred.date.isoformat() + except ObjectNotFound: + pass + + return info + + def as_subsonic_directory(self, user, client): # "Directory" type in XSD + info = dict( + id=str(self.id), + name=self.name, + child=[ + f.as_subsonic_child(user) + for f in self.children.order_by(lambda c: c.name.lower()) + ] + + [ + t.as_subsonic_child(user, client) + for t in sorted(self.tracks, key=lambda t: t.sort_key()) + ], + ) + if not self.root: + info["parent"] = str(self.parent.id) + + return info + @classmethod def prune(cls): query = cls.select( @@ -189,7 +216,7 @@ class Album(db.Entity): stars = Set(lambda: StarredAlbum) - def as_subsonic_album(self, user): + def as_subsonic_album(self, user): # "AlbumID3" type in XSD info = dict( id=str(self.id), name=self.name, @@ -210,6 +237,13 @@ def as_subsonic_album(self, user): if track_with_cover is not None: info["coverArt"] = str(track_with_cover.id) + if count(self.tracks.year) > 0: + info["year"] = min(self.tracks.year) + + genre = ", ".join(self.tracks.genre) + if genre: + info["genre"] = genre + try: starred = StarredAlbum[user.id, self.id] info["starred"] = starred.date.isoformat() From 62ae4a996703bb72fa9251afc6a655ae11f140fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 9 Nov 2020 15:22:15 +0100 Subject: [PATCH 012/237] Update README about API version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 91b4c644..9105bbe8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Current supported features are: * [Last.FM][lastfm] scrobbling * Jukebox mode -_Supysonic_ currently targets the version 1.9.0 of the _Subsonic_ API. For more +_Supysonic_ currently targets the version 1.10.2 of the _Subsonic_ API. For more details, go check the [API implementation status][docs-api]. [subsonic]: http://www.subsonic.org/ From cec216684dbd3ddab0cf8eda357bec80abe925c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 9 Nov 2020 17:31:04 +0100 Subject: [PATCH 013/237] Add ignored articles support Closes #200 --- config.sample | 7 +++++- docs/configuration.md | 10 ++++++++ supysonic/api/browse.py | 52 +++++++++++++++++++++++++++++++++-------- supysonic/config.py | 1 + 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/config.sample b/config.sample index 12325859..d7788b9b 100644 --- a/config.sample +++ b/config.sample @@ -1,5 +1,6 @@ [base] -; A database URI. See the 'schema' folder for schema creation scripts +; A database URI. See the 'schema' folder for schema creation scripts. Note that +; you don't have to run these scripts yourself. ; Default: sqlite:////tmp/supysonic/supysonic.db ;database_uri = sqlite:////var/supysonic/supysonic.db ;database_uri = mysql://supysonic:supysonic@localhost/supysonic @@ -35,6 +36,10 @@ log_level = WARNING ; Enable the administrative web interface. Default: on ;mount_webui = on +; Space separated list of prefixes that should be ignored on index endpoints +; Default: El La Le Las Les Los The +index_ignored_prefixes = El La Le Las Les Los The + [daemon] ; Socket file the daemon will listen on for incoming management commands ; Default: /tmp/supysonic/supysonic.sock diff --git a/docs/configuration.md b/docs/configuration.md index ea9308af..ecdbe601 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,6 +106,12 @@ purposes. Defaults to `on`. Note that setting this off will prevent users from defining a preferred transcoding format. Defaults to `on`. +`index_ignored_prefixes`: space separated list of prefixes that should be +ignored from artist names when returning their index. Example: if the word _The_ +is in this list, artist _The Rolling Stones_ will be listed under the letter _R_. +The match is case insensitive. +Defaults to `El La Le Las Les Los The`. + ```ini [webapp] ; Optional cache directory. Default: /tmp/supysonic @@ -130,6 +136,10 @@ log_level = WARNING ; Enable the administrative web interface. Default: on ;mount_webui = on + +; Space separated list of prefixes that should be ignored on index endpoints +; Default: El La Le Las Les Los The +index_ignored_prefixes = El La Le Las Les Los The ``` ## `[daemon]` section diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index f0d976be..bb47abfd 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -5,10 +5,11 @@ # # Distributed under terms of the GNU AGPLv3 license. +import re import string import uuid -from flask import request +from flask import current_app, request from pony.orm import ObjectNotFound, select, count from ..db import Folder, Artist, Album, Track @@ -29,6 +30,26 @@ def list_folders(): ) +def build_ignored_articles_pattern(): + articles = current_app.config["WEBAPP"]["index_ignored_prefixes"] + if articles is None: + return None + + articles = articles.split() + if not articles: + return None + + return r"^(" + r" |".join(re.escape(a) for a in articles) + r" )" + + +def ignored_articles_str(): + articles = current_app.config["WEBAPP"]["index_ignored_prefixes"] + if articles is None: + return "" + + return " ".join(articles.split()) + + @api.route("/getIndexes.view", methods=["GET", "POST"]) def list_indexes(): musicFolderId = request.values.get("musicFolderId") @@ -49,7 +70,10 @@ def list_indexes(): last_modif = max(map(lambda f: f.last_scan, folders)) if ifModifiedSince is not None and last_modif < ifModifiedSince: return request.formatter( - "indexes", dict(lastModified=last_modif * 1000, ignoredArticles="") + "indexes", + dict( + lastModified=last_modif * 1000, ignoredArticles=ignored_articles_str() + ), ) # The XSD lies, we don't return artists but a directory structure @@ -60,8 +84,12 @@ def list_indexes(): children += f.tracks.select()[:] indexes = dict() + pattern = build_ignored_articles_pattern() for artist in artists: - index = artist.name[0].upper() + name = artist.name + if pattern: + name = re.sub(pattern, "", name, flags=re.I) + index = name[0].upper() if index in string.digits: index = "#" elif index not in string.ascii_letters: @@ -70,19 +98,19 @@ def list_indexes(): if index not in indexes: indexes[index] = [] - indexes[index].append(artist) + indexes[index].append((artist, name)) return request.formatter( "indexes", dict( lastModified=last_modif * 1000, - ignoredArticles="", + ignoredArticles=ignored_articles_str(), index=[ dict( name=k, artist=[ a.as_subsonic_artist(request.user) - for a in sorted(v, key=lambda a: a.name.lower()) + for a, _ in sorted(v, key=lambda t: t[1].lower()) ], ) for k, v in sorted(indexes.items()) @@ -122,8 +150,12 @@ def list_genres(): def list_artists(): # According to the API page, there are no parameters? indexes = dict() + pattern = build_ignored_articles_pattern() for artist in Artist.select(): - index = artist.name[0].upper() if artist.name else "?" + name = artist.name or "?" + if pattern: + name = re.sub(pattern, "", name, flags=re.I) + index = name[0].upper() if index in string.digits: index = "#" elif index not in string.ascii_letters: @@ -132,18 +164,18 @@ def list_artists(): if index not in indexes: indexes[index] = [] - indexes[index].append(artist) + indexes[index].append((artist, name)) return request.formatter( "artists", dict( - ignoredArticles="", + ignoredArticles=ignored_articles_str(), index=[ dict( name=k, artist=[ a.as_subsonic_artist(request.user) - for a in sorted(v, key=lambda a: a.name.lower()) + for a, _ in sorted(v, key=lambda t: t[1].lower()) ], ) for k, v in sorted(indexes.items()) diff --git a/supysonic/config.py b/supysonic/config.py index 56c9acba..d86d09db 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -38,6 +38,7 @@ class DefaultConfig(object): "log_level": "WARNING", "mount_webui": True, "mount_api": True, + "index_ignored_prefixes": "El La Le Las Les Los The", } DAEMON = { "socket": r"\\.\pipe\supysonic" From 883623c5580816d65ab32189a5fd57f1ae2f11f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 10 Nov 2020 11:36:44 +0100 Subject: [PATCH 014/237] Potential fix for hypothetical Pony version 0.8 --- supysonic/api/albums_songs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 086e736c..d9cd80e4 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -88,15 +88,15 @@ def album_list(): ), ) elif ltype == "newest": - query = query.order_by(desc(Folder.created)).distinct() + query = query.sort_by(desc(Folder.created)).distinct() elif ltype == "highest": - query = query.order_by(lambda f: desc(avg(f.ratings.rating))) + query = query.sort_by(lambda f: desc(avg(f.ratings.rating))) elif ltype == "frequent": - query = query.order_by(lambda f: desc(avg(f.tracks.play_count))) + query = query.sort_by(lambda f: desc(avg(f.tracks.play_count))) elif ltype == "recent": query = select( t.folder for t in Track if max(t.folder.tracks.last_play) is not None - ).order_by(lambda f: desc(max(f.tracks.last_play))) + ).sort_by(lambda f: desc(max(f.tracks.last_play))) elif ltype == "starred": query = select( s.starred @@ -104,9 +104,9 @@ def album_list(): if s.user.id == request.user.id and count(s.starred.tracks) > 0 ) elif ltype == "alphabeticalByName": - query = query.order_by(Folder.name).distinct() + query = query.sort_by(Folder.name).distinct() elif ltype == "alphabeticalByArtist": - query = query.order_by(lambda f: f.parent.name + f.name) + query = query.sort_by(lambda f: f.parent.name + f.name) else: raise GenericError("Unknown search type") From c2f5ec43b9986d6ff51ba2e7fb4a03a0b8519e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 10 Nov 2020 14:21:51 +0100 Subject: [PATCH 015/237] Album listing filtered by year or genre Closes #47 --- docs/api.md | 12 ++--- supysonic/api/albums_songs.py | 30 +++++++++++- tests/api/test_album_songs.py | 86 +++++++++++++++++++++++++++++++++-- 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/docs/api.md b/docs/api.md index 96143fa5..d77855a6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -274,9 +274,9 @@ No parameter | `type` | | ✔️ | | `size` | | ✔️ | | `offset` | | ✔️ | -| `fromYear` | 1.10.1 | 📅 | -| `toYear` | 1.10.1 | 📅 | -| `genre` | 1.10.1 | 📅 | +| `fromYear` | | ✔️ | +| `toYear` | | ✔️ | +| `genre` | | ✔️ | | `musicFolderId` | 1.12.0 | 📅 | On 1.10.1, `byYear` and `byGenre` were added to `type` @@ -289,9 +289,9 @@ On 1.10.1, `byYear` and `byGenre` were added to `type` | `type` | | ✔️ | | `size` | | ✔️ | | `offset` | | ✔️ | -| `fromYear` | 1.10.1 | 📅 | -| `toYear` | 1.10.1 | 📅 | -| `genre` | 1.10.1 | 📅 | +| `fromYear` | | ✔️ | +| `toYear` | | ✔️ | +| `genre` | | ✔️ | | `musicFolderId` | 1.12.0 | 📅 | On 1.10.1, `byYear` and `byGenre` were added to `type` diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index d9cd80e4..822d249a 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -7,7 +7,7 @@ from datetime import timedelta from flask import request -from pony.orm import select, desc, avg, max, min, count +from pony.orm import select, desc, avg, max, min, count, between from ..db import ( Folder, @@ -107,6 +107,19 @@ def album_list(): query = query.sort_by(Folder.name).distinct() elif ltype == "alphabeticalByArtist": query = query.sort_by(lambda f: f.parent.name + f.name) + elif ltype == "byYear": + startyear = int(request.values["fromYear"]) + endyear = int(request.values["toYear"]) + query = query.where( + lambda t: between(t.year, min(startyear, endyear), max(startyear, endyear)) + ) + if endyear < startyear: + query = query.sort_by(lambda f: desc(min(f.tracks.year))) + else: + query = query.sort_by(lambda f: min(f.tracks.year)) + elif ltype == "byGenre": + genre = request.values["genre"] + query = query.where(lambda t: t.genre == genre) else: raise GenericError("Unknown search type") @@ -146,6 +159,21 @@ def album_list_id3(): query = query.order_by(Album.name) elif ltype == "alphabeticalByArtist": query = query.order_by(lambda a: a.artist.name + a.name) + elif ltype == "byYear": + startyear = int(request.values["fromYear"]) + endyear = int(request.values["toYear"]) + query = query.where( + lambda a: between( + min(a.tracks.year), min(startyear, endyear), max(startyear, endyear) + ) + ) + if endyear < startyear: + query = query.order_by(lambda a: desc(min(a.tracks.year))) + else: + query = query.order_by(lambda a: min(a.tracks.year)) + elif ltype == "byGenre": + genre = request.values["genre"] + query = query.where(lambda a: genre in a.tracks.genre) else: raise GenericError("Unknown search type") diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index 257834a1..c69a897a 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -35,6 +34,7 @@ def setUp(self): artist=artist, disc=1, number=1, + year=123, path="tests/assets/folder/1", folder=folder, root_folder=folder, @@ -48,6 +48,8 @@ def setUp(self): artist=artist, disc=1, number=1, + year=124, + genre="Lampshade", path="tests/assets/folder/2", folder=folder, root_folder=folder, @@ -63,6 +65,13 @@ def test_get_album_list(self): self._make_request( "getAlbumList", {"type": "newest", "offset": "minus one"}, error=0 ) + self._make_request("getAlbumList", {"type": "byYear"}, error=10) + self._make_request( + "getAlbumList", + {"type": "byYear", "fromYear": "Epoch", "toYear": "EOL"}, + error=0, + ) + self._make_request("getAlbumList", {"type": "byGenre"}, error=10) types_and_count = [ ("random", 1), @@ -79,10 +88,40 @@ def test_get_album_list(self): ] for t, c in types_and_count: rv, child = self._make_request( - "getAlbumList", {"type": t}, tag="albumList", skip_post=True + "getAlbumList", {"type": t}, tag="albumList", skip_post=t == "random" ) self.assertEqual(len(child), c) + rv, child = self._make_request( + "getAlbumList", + {"type": "byYear", "fromYear": 100, "toYear": 200}, + tag="albumList", + ) + self.assertEqual(len(child), 1) + rv, child = self._make_request( + "getAlbumList", + {"type": "byYear", "fromYear": 200, "toYear": 300}, + tag="albumList", + ) + self.assertEqual(len(child), 0) + # Need more data to properly test ordering + rv, child = self._make_request( + "getAlbumList", + {"type": "byYear", "fromYear": 200, "toYear": 100}, + tag="albumList", + ) + self.assertEqual(len(child), 1) + + rv, child = self._make_request( + "getAlbumList", {"type": "byGenre", "genre": "FARTS"}, tag="albumList" + ) + self.assertEqual(len(child), 0) + + rv, child = self._make_request( + "getAlbumList", {"type": "byGenre", "genre": "Lampshade"}, tag="albumList" + ) + self.assertEqual(len(child), 1) + with db_session: Folder.get().delete() rv, child = self._make_request( @@ -99,6 +138,13 @@ def test_get_album_list2(self): self._make_request( "getAlbumList2", {"type": "newest", "offset": "&v + 2"}, error=0 ) + self._make_request("getAlbumList2", {"type": "byYear"}, error=10) + self._make_request( + "getAlbumList2", + {"type": "byYear", "fromYear": "Epoch", "toYear": "EOL"}, + error=0, + ) + self._make_request("getAlbumList2", {"type": "byGenre"}, error=10) types = [ "random", @@ -111,13 +157,43 @@ def test_get_album_list2(self): ] for t in types: self._make_request( - "getAlbumList2", {"type": t}, tag="albumList2", skip_post=True + "getAlbumList2", {"type": t}, tag="albumList2", skip_post=t == "random" ) - rv, child = self._make_request( + self._make_request( "getAlbumList2", {"type": "random"}, tag="albumList2", skip_post=True ) + rv, child = self._make_request( + "getAlbumList2", + {"type": "byYear", "fromYear": 100, "toYear": 200}, + tag="albumList2", + ) + self.assertEqual(len(child), 1) + rv, child = self._make_request( + "getAlbumList2", + {"type": "byYear", "fromYear": 200, "toYear": 300}, + tag="albumList2", + ) + self.assertEqual(len(child), 0) + # Need more data to properly test ordering + rv, child = self._make_request( + "getAlbumList2", + {"type": "byYear", "fromYear": 200, "toYear": 100}, + tag="albumList2", + ) + self.assertEqual(len(child), 1) + + rv, child = self._make_request( + "getAlbumList2", {"type": "byGenre", "genre": "FARTS"}, tag="albumList2" + ) + self.assertEqual(len(child), 0) + + rv, child = self._make_request( + "getAlbumList2", {"type": "byGenre", "genre": "Lampshade"}, tag="albumList2" + ) + self.assertEqual(len(child), 1) + with db_session: Track.select().delete() Album.get().delete() From debb396b0e0c9fd91d3de3e8a1f55ded7c9f393f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 10 Nov 2020 15:38:24 +0100 Subject: [PATCH 016/237] Ability to grant jukebox right when creating an user from the API --- docs/api.md | 2 +- supysonic/api/user.py | 19 +++++++++++++------ supysonic/cli.py | 4 +--- supysonic/frontend/user.py | 13 ++++--------- supysonic/managers/user.py | 11 +++-------- tests/api/test_user.py | 25 +++++++++++++++++++++---- tests/managers/test_manager_user.py | 23 ++++++++++------------- tests/testbase.py | 4 ++-- 8 files changed, 55 insertions(+), 46 deletions(-) diff --git a/docs/api.md b/docs/api.md index d77855a6..95603e86 100644 --- a/docs/api.md +++ b/docs/api.md @@ -691,7 +691,7 @@ No parameter | `adminRole` | | ✔️ | | `settingsRole` | | | | `streamRole` | | | -| `jukeboxRole` | | 📅 | +| `jukeboxRole` | | ✔️ | | `downloadRole` | | | | `uploadRole` | | | | `playlistRole` | | | diff --git a/supysonic/api/user.py b/supysonic/api/user.py index 498348a0..fc84a55a 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -1,9 +1,7 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -49,17 +47,26 @@ def users_info(): ) +def get_roles_dict(): + roles = {} + for role in ("admin", "jukebox"): + value = request.values.get(role + "Role") + value = value in (True, "True", "true", 1, "1") + roles[role] = value + + return roles + + @api.route("/createUser.view", methods=["GET", "POST"]) @admin_only def user_add(): username = request.values["username"] password = request.values["password"] email = request.values["email"] - admin = request.values.get("adminRole") - admin = True if admin in (True, "True", "true", 1, "1") else False + roles = get_roles_dict() password = decode_password(password) - UserManager.add(username, password, email, admin) + UserManager.add(username, password, mail=email, **roles) return request.formatter.empty diff --git a/supysonic/cli.py b/supysonic/cli.py index 09e2792c..52eb4696 100755 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -375,7 +373,7 @@ def user_add(self, name, password, email): try: if not password: password = self._ask_password() # pragma: nocover - UserManager.add(name, password, email, False) + UserManager.add(name, password, mail=email) except ValueError as e: self.write_error_line(str(e)) diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index 46d25821..8e57a753 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -231,8 +229,9 @@ def add_user_form(): @admin_only def add_user_post(): error = False - (name, passwd, passwd_confirm, mail, admin) = map( - request.form.get, ["user", "passwd", "passwd_confirm", "mail", "admin"] + args = request.form.copy() + (name, passwd, passwd_confirm) = map( + args.pop, ["user", "passwd", "passwd_confirm"], [None] * 3 ) if not name: flash("The name is required.") @@ -244,13 +243,9 @@ def add_user_post(): flash("The passwords don't match.") error = True - admin = admin is not None - if mail is None: - mail = "" - if not error: try: - UserManager.add(name, passwd, mail, admin) + UserManager.add(name, passwd, **args) flash("User '%s' successfully added" % name) return redirect(url_for("frontend.user_index")) except ValueError as e: diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index be7fda3a..c1978b43 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -1,9 +1,7 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -31,15 +29,12 @@ def get(uid): return User[uid] @staticmethod - def add(name, password, mail, admin): + def add(name, password, **kwargs): if User.exists(name=name): raise ValueError("User '{}' exists".format(name)) crypt, salt = UserManager.__encrypt_password(password) - - user = User(name=name, mail=mail, password=crypt, salt=salt, admin=admin) - - return user + return User(name=name, password=crypt, salt=salt, **kwargs) @staticmethod def delete(uid): diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 5e76727b..d23b9684 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2020 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -98,19 +97,37 @@ def test_create_user(self): self.assertEqual(child.get("username"), "charlie") self.assertEqual(child.get("email"), "unicorn@example.com") self.assertEqual(child.get("adminRole"), "true") + self.assertEqual(child.get("jukeboxRole"), "true") # admin gives full control self._make_request( "createUser", - {"username": "dave", "password": "Dav3", "email": "dave@example.com"}, + { + "username": "dave", + "password": "Dav3", + "email": "dave@example.com", + "jukeboxRole": True, + }, skip_post=True, ) rv, child = self._make_request("getUser", {"username": "dave"}, tag="user") self.assertEqual(child.get("username"), "dave") self.assertEqual(child.get("email"), "dave@example.com") self.assertEqual(child.get("adminRole"), "false") + self.assertEqual(child.get("jukeboxRole"), "true") + + self._make_request( + "createUser", + {"username": "eve", "password": "3ve", "email": "eve@example.com"}, + skip_post=True, + ) + rv, child = self._make_request("getUser", {"username": "eve"}, tag="user") + self.assertEqual(child.get("username"), "eve") + self.assertEqual(child.get("email"), "eve@example.com") + self.assertEqual(child.get("adminRole"), "false") + self.assertEqual(child.get("jukeboxRole"), "false") rv, child = self._make_request("getUsers", tag="users") - self.assertEqual(len(child), 4) + self.assertEqual(len(child), 5) def test_delete_user(self): # non admin diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index 7b642139..ecb17081 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -31,15 +30,15 @@ def tearDown(self): @db_session def create_data(self): # Create some users - self.assertIsInstance( - UserManager.add("alice", "ALICE", "test@example.com", True), db.User - ) - self.assertIsInstance( - UserManager.add("bob", "BOB", "bob@example.com", False), db.User - ) - self.assertIsInstance( - UserManager.add("charlie", "CHARLIE", "charlie@example.com", False), db.User - ) + alice = UserManager.add("alice", "ALICE", admin=True) + self.assertIsInstance(alice, db.User) + self.assertTrue(alice.admin) + + bob = UserManager.add("bob", "BOB") + self.assertIsInstance(bob, db.User) + self.assertFalse(bob.admin) + + self.assertIsInstance(UserManager.add("charlie", "CHARLIE"), db.User) folder = db.Folder(name="Root", path="tests/assets", root=True) artist = db.Artist(name="Artist") @@ -97,9 +96,7 @@ def test_add_user(self): self.assertEqual(db.User.select().count(), 3) # Create duplicate - self.assertRaises( - ValueError, UserManager.add, "alice", "Alic3", "alice@example.com", True - ) + self.assertRaises(ValueError, UserManager.add, "alice", "Alic3", admin=True) @db_session def test_delete_user(self): diff --git a/tests/testbase.py b/tests/testbase.py index 08640505..6e4ceef3 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -93,8 +93,8 @@ def setUp(self): self.client = self.__app.test_client() with db_session: - UserManager.add("alice", "Alic3", "test@example.com", True) - UserManager.add("bob", "B0b", "bob@example.com", False) + UserManager.add("alice", "Alic3", admin=True) + UserManager.add("bob", "B0b") def _patch_client(self): self.client.get = patch_method(self.client.get) From 52fb367c44a2bb8348d6167851e25fb5a75d04d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 10 Nov 2020 16:56:49 +0100 Subject: [PATCH 017/237] Implement updateUser --- docs/api.md | 14 +++++------ supysonic/api/user.py | 29 ++++++++++++++++++++++ supysonic/managers/user.py | 15 ++++++++---- tests/api/test_user.py | 37 ++++++++++++++++++++++++++++- tests/managers/test_manager_user.py | 10 ++++++-- 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/docs/api.md b/docs/api.md index 95603e86..810e481c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -95,7 +95,7 @@ or with version 1.8.0. | [`getUser`](#getuser) | | ✔️ | | [`getUsers`](#getusers) | 1.9.0 | ✔️ | | [`createUser`](#createuser) | | ✔️ | -| [`updateUser`](#updateuser) | 1.10.2 | 📅 | +| [`updateUser`](#updateuser) | 1.10.2 | ✔️ | | [`deleteUser`](#deleteuser) | | ✔️ | | [`changePassword`](#changepassword) | | ✔️ | | [`getBookmarks`](#getbookmarks) | 1.9.0 | ❔ | @@ -703,18 +703,18 @@ No parameter | `musicFolderId` | 1.12.0 | 📅 | #### `updateUser` -📅 1.10.2 +✔️ 1.10.2 | Parameter | Vers. | | |-----------------------|--------|---| -| `username` | 1.10.2 | 📅 | -| `password` | 1.10.2 | 📅 | -| `email` | 1.10.2 | 📅 | +| `username` | 1.10.2 | ✔️ | +| `password` | 1.10.2 | ✔️ | +| `email` | 1.10.2 | ✔️ | | `ldapAuthenticated` | 1.10.2 | | -| `adminRole` | 1.10.2 | 📅 | +| `adminRole` | 1.10.2 | ✔️ | | `settingsRole` | 1.10.2 | | | `streamRole` | 1.10.2 | | -| `jukeboxRole` | 1.10.2 | 📅 | +| `jukeboxRole` | 1.10.2 | ✔️ | | `downloadRole` | 1.10.2 | | | `uploadRole` | 1.10.2 | | | `coverArtRole` | 1.10.2 | | diff --git a/supysonic/api/user.py b/supysonic/api/user.py index fc84a55a..9c744a9c 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -92,3 +92,32 @@ def user_changepass(): UserManager.change_password2(username, password) return request.formatter.empty + + +@api.route("/updateUser.view", methods=["GET", "POST"]) +@admin_only +def user_edit(): + username = request.values["username"] + user = User.get(name=username) + if user is None: + raise NotFound("User") + + if "password" in request.values: + password = decode_password(request.values["password"]) + UserManager.change_password2(user, password) + + email, admin, jukebox = map( + request.values.get, ["email", "adminRole", "jukeboxRole"] + ) + if email is not None: + user.mail = email + + if admin is not None: + admin = admin in (True, "True", "true", 1, "1") + user.admin = admin + + if jukebox is not None: + jukebox = jukebox in (True, "True", "true", 1, "1") + user.jukebox = jukebox + + return request.formatter.empty diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index c1978b43..dceb237a 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -24,7 +24,7 @@ def get(uid): elif isinstance(uid, str): uid = uuid.UUID(uid) else: - raise ValueError("Invalid user id") + raise TypeError("Invalid user id") return User[uid] @@ -67,10 +67,15 @@ def change_password(uid, old_pass, new_pass): user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] @staticmethod - def change_password2(name, new_pass): - user = User.get(name=name) - if user is None: - raise ObjectNotFound(User) + def change_password2(name_or_user, new_pass): + if isinstance(name_or_user, User): + user = name_or_user + elif isinstance(name_or_user, str): + user = User.get(name=name_or_user) + if user is None: + raise ObjectNotFound(User) + else: + raise TypeError("Requires a User instance or a user name (string)") user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] diff --git a/tests/api/test_user.py b/tests/api/test_user.py index d23b9684..b3043ca0 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -222,7 +222,7 @@ def test_change_password(self): # non ASCII in hex encoded password self._make_request( "changePassword", - {"username": "alice", "password": "enc:" + hexlify(u"новыйпароль")}, + {"username": "alice", "password": "enc:" + hexlify("новыйпароль")}, skip_post=True, ) self._make_request("ping", {"u": "alice", "p": "новыйпароль"}) @@ -240,6 +240,41 @@ def test_change_password(self): ) self._make_request("ping", {"u": "alice", "p": "enc:randomstring"}) + def test_update_user(self): + # non admin + self._make_request( + "updateUser", {"u": "bob", "p": "B0b", "username": "alice"}, error=50 + ) + + # missing param + self._make_request("updateUser", error=10) + + # non existing + self._make_request("updateUser", {"username": "charlie"}, error=70) + + self._make_request( + "updateUser", + {"username": "bob", "email": "email@email.em", "jukeboxRole": True}, + ) + rv, child = self._make_request("getUser", {"username": "bob"}, tag="user") + self.assertEqual(child.get("email"), "email@email.em") + self.assertEqual(child.get("adminRole"), "false") + self.assertEqual(child.get("jukeboxRole"), "true") + + self._make_request( + "updateUser", {"username": "bob", "email": "example@email.com"} + ) + rv, child = self._make_request("getUser", {"username": "bob"}, tag="user") + self.assertEqual(child.get("email"), "example@email.com") + self.assertEqual(child.get("adminRole"), "false") + self.assertEqual(child.get("jukeboxRole"), "true") + + self._make_request("updateUser", {"username": "bob", "adminRole": True}) + rv, child = self._make_request("getUser", {"username": "bob"}, tag="user") + self.assertEqual(child.get("email"), "example@email.com") + self.assertEqual(child.get("adminRole"), "true") + self.assertEqual(child.get("jukeboxRole"), "true") + if __name__ == "__main__": unittest.main() diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index ecb17081..f9a3b63f 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -85,7 +85,7 @@ def test_get_user(self): # Get with invalid UUID self.assertRaises(ValueError, UserManager.get, "invalid-uuid") - self.assertRaises(ValueError, UserManager.get, 0xFEE1BAD) + self.assertRaises(TypeError, UserManager.get, 0xFEE1BAD) # Non-existent user self.assertRaises(ObjectNotFound, UserManager.get, uuid.uuid4()) @@ -104,7 +104,7 @@ def test_delete_user(self): # Delete invalid UUID self.assertRaises(ValueError, UserManager.delete, "invalid-uuid") - self.assertRaises(ValueError, UserManager.delete, 0xFEE1B4D) + self.assertRaises(TypeError, UserManager.delete, 0xFEE1B4D) self.assertEqual(db.User.select().count(), 3) # Delete non-existent user @@ -190,6 +190,8 @@ def test_change_password(self): def test_change_password2(self): self.create_data() + self.assertRaises(TypeError, UserManager.change_password2, uuid.uuid4(), "pass") + # With existing users for name in ["alice", "bob", "charlie"]: UserManager.change_password2(name, "newpass") @@ -197,6 +199,10 @@ def test_change_password2(self): self.assertEqual(UserManager.try_auth(name, "newpass"), user) self.assertEqual(UserManager.try_auth(name, name.upper()), None) + # test passing the user directly + UserManager.change_password2(user, "NEWPASS") + self.assertEqual(UserManager.try_auth(name, "NEWPASS"), user) + # Non-existent user self.assertRaises( ObjectNotFound, UserManager.change_password2, "null", "newpass" From d6c00e0f3dd798937d05baebd5d8e2aca131f586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Wed, 11 Nov 2020 18:30:50 +0100 Subject: [PATCH 018/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 9b19fbbf..5317c5dc 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -9,7 +9,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "supysonic" -VERSION = "0.6.0" +VERSION = "0.6.1" DESCRIPTION = "Python implementation of the Subsonic server API." KEYWORDS = "subsonic music api" AUTHOR_NAME = "Alban Féron" From bc6e7686275d6d331b4d66b882b75fd5d8888400 Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Fri, 13 Nov 2020 14:33:36 -0500 Subject: [PATCH 019/237] Fix exception handling Bare excepts will catch `GeneratorExit` exceptions which are raised whenever a generator stops. This was causing issues when transcoding and caching the results. All instances of bare excepts have been replaced with scoped versions. --- supysonic/api/__init__.py | 2 +- supysonic/api/media.py | 3 ++- supysonic/cache.py | 5 +++-- supysonic/jukebox.py | 4 ++-- supysonic/schema/migration/postgres/20180317.py | 2 +- supysonic/schema/migration/sqlite/20180317.py | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index ac74d960..175e69ee 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -41,7 +41,7 @@ def decode_password(password): try: return binascii.unhexlify(password[4:].encode("utf-8")).decode("utf-8") - except: + except ValueError: return password diff --git a/supysonic/api/media.py b/supysonic/api/media.py index cc29aabd..6e5c5c21 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -164,7 +164,8 @@ def transcode(): if not data: break yield data - except: # pragma: nocover + except BaseException: + # Make sure child processes are always killed if dec_proc != None: dec_proc.kill() proc.kill() diff --git a/supysonic/cache.py b/supysonic/cache.py index 7584a0de..8bcef436 100644 --- a/supysonic/cache.py +++ b/supysonic/cache.py @@ -159,9 +159,10 @@ def set_fileobj(self, key): self._make_space(size, key=key) os.replace(f.name, self._filepath(key)) self._record_file(key, size) - except: + except Exception: f.close() - os.remove(f.name) + with contextlib.suppress(OSError): + os.remove(f.name) raise def set(self, key, value): diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 0d4f0d2d..2b8c49c1 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -157,6 +157,6 @@ def __play_file(self): logger.debug("Start playing with command %s", args) try: return Popen(args, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL) - except: - logger.exception("Failed running play command") + except Exception: + logger.exception("Failed to run play command") return None diff --git a/supysonic/schema/migration/postgres/20180317.py b/supysonic/schema/migration/postgres/20180317.py index a9c3dfec..416aa976 100644 --- a/supysonic/schema/migration/postgres/20180317.py +++ b/supysonic/schema/migration/postgres/20180317.py @@ -5,7 +5,7 @@ try: bytes = buffer -except: +except NameError: pass parser = argparse.ArgumentParser() diff --git a/supysonic/schema/migration/sqlite/20180317.py b/supysonic/schema/migration/sqlite/20180317.py index 08c09ee0..df97ac22 100644 --- a/supysonic/schema/migration/sqlite/20180317.py +++ b/supysonic/schema/migration/sqlite/20180317.py @@ -5,7 +5,7 @@ try: bytes = buffer -except: +except NameError: pass parser = argparse.ArgumentParser() From e29a09e2fa58aeafea2a7cbb92edaa8917a0a30b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 14 Nov 2020 19:01:39 +0100 Subject: [PATCH 020/237] Test for issue #202 --- tests/api/test_transcoding.py | 50 +++++++++++++++++++++++++++++++---- tests/testbase.py | 4 +++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index ad9fb005..b13c3372 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -10,6 +10,7 @@ import unittest import sys +from flask import current_app from pony.orm import db_session from supysonic.db import Folder, Track @@ -22,7 +23,6 @@ class TranscodingTestCase(ApiTestBase): def setUp(self): super(TranscodingTestCase, self).setUp() - self._patch_client() with db_session: folder = FolderManager.add("Folder", "tests/assets/folder") @@ -52,8 +52,10 @@ def test_no_transcoding_available(self): ) def test_direct_transcode(self): rv = self._stream(maxBitRate=96, estimateContentLength="true") - self.assertIn("tests/assets/folder/silence.mp3", rv.data) - self.assertTrue(rv.data.endswith("96")) + self.assertIn(b"tests/assets/folder/silence.mp3", rv.data) + self.assertTrue(rv.data.endswith(b"96")) + self.assertIn("Content-Length", rv.headers) + self.assertEqual(rv.content_length, 48000) # 4s at 96kbps @unittest.skipIf( sys.platform == "win32", @@ -61,10 +63,48 @@ def test_direct_transcode(self): ) def test_decode_encode(self): rv = self._stream(format="cat") - self.assertEqual(rv.data, "Pushing out some mp3 data...") + self.assertEqual(rv.data, b"Pushing out some mp3 data...") rv = self._stream(format="md5") - self.assertTrue(rv.data.startswith("dbb16c0847e5d8c3b1867604828cb50b")) + self.assertTrue(rv.data.startswith(b"dbb16c0847e5d8c3b1867604828cb50b")) + + @unittest.skipIf( + sys.platform == "win32", + "Can't test transcoding on Windows because of a lack of simple commandline tools", + ) + def test_mostly_transcoded_cached(self): + # See https://github.com/spl0k/supysonic/issues/202 + + rv = self._stream(maxBitRate=96, estimateContentLength="true", format="rnd") + + read = 0 + it = iter(rv.response) + while read < 48000: + read += len(next(it)) + rv.response.close() + rv.close() + + key = "{}-96.rnd".format(self.trackid) + with self.app_context(): + self.assertTrue(current_app.transcode_cache.has(key)) + self.assertEqual(current_app.transcode_cache.size, 52000) + + @unittest.skipIf( + sys.platform == "win32", + "Can't test transcoding on Windows because of a lack of simple commandline tools", + ) + def test_partly_transcoded_cached(self): + rv = self._stream(maxBitRate=96, estimateContentLength="true", format="rnd") + + # read one check of data then close the connection + next(iter(rv.response)) + rv.response.close() + rv.close() + + key = "{}-96.rnd".format(self.trackid) + with self.app_context(): + self.assertFalse(current_app.transcode_cache.has(key)) + self.assertEqual(current_app.transcode_cache.size, 0) if __name__ == "__main__": diff --git a/tests/testbase.py b/tests/testbase.py index 6e4ceef3..4879ed18 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -25,6 +25,7 @@ class TestConfig(DefaultConfig): MIMETYPES = {"mp3": "audio/mpeg", "weirdextension": "application/octet-stream"} TRANSCODING = { "transcoder_mp3_mp3": "echo -n %srcpath %outrate", + "transcoder_mp3_rnd": "dd if=/dev/urandom bs=1kB count=52 status=none", "decoder_mp3": "echo -n Pushing out some mp3 data...", "encoder_cat": "cat -", "encoder_md5": "md5sum", @@ -100,6 +101,9 @@ def _patch_client(self): self.client.get = patch_method(self.client.get) self.client.post = patch_method(self.client.post) + def app_context(self, *args, **kwargs): + return self.__app.app_context(*args, **kwargs) + def request_context(self, *args, **kwargs): return self.__app.test_request_context(*args, **kwargs) From dc5084ce47bd8b985466dcf28661885b0ce736b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 15 Nov 2020 16:22:24 +0100 Subject: [PATCH 021/237] Finish transcoding and cache if close to the end Ref #202 --- supysonic/api/media.py | 49 +++++++++++++++++++++++++---------- supysonic/cache.py | 20 +++++++++++--- tests/api/test_transcoding.py | 21 +++++++++++++++ 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 6e5c5c21..50a7bc61 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -157,25 +157,50 @@ def stream_media(): except OSError: raise ServerError("Error while running the transcoding process") + if estimateContentLength == "true": + estimate = dst_bitrate * 1000 * res.duration // 8 + else: + estimate = None + def transcode(): + while True: + data = proc.stdout.read(8192) + if not data: + break + yield data + + def kill_processes(): + if dec_proc != None: + dec_proc.kill() + proc.kill() + + def handle_transcoding(): try: - while True: - data = proc.stdout.read(8192) - if not data: - break + sent = 0 + for data in transcode(): + sent += len(data) yield data - except BaseException: + except (Exception, SystemExit, KeyboardInterrupt): # Make sure child processes are always killed - if dec_proc != None: - dec_proc.kill() - proc.kill() + kill_processes() raise + except GeneratorExit: + # Try to transcode/send more data if we're close to the end. + # The calling code have to support this as yielding more data + # after a GeneratorExit would normally raise a RuntimeError. + # Hopefully this generator is only used by the cache which + # handles this. + if estimate and sent >= estimate * 0.95: + yield from transcode() + else: + kill_processes() + raise finally: if dec_proc != None: dec_proc.wait() proc.wait() - resp_content = cache.set_generated(cache_key, transcode) + resp_content = cache.set_generated(cache_key, handle_transcoding) logger.info( "Transcoding track {0.id} for user {1.id}. Source: {2} at {0.bitrate}kbps. Dest: {3} at {4}kbps".format( @@ -183,10 +208,8 @@ def transcode(): ) ) response = Response(resp_content, mimetype=dst_mimetype) - if estimateContentLength == "true": - response.headers.add( - "Content-Length", dst_bitrate * 1000 * res.duration // 8 - ) + if estimate is not None: + response.headers.add("Content-Length", estimate) else: response = send_file(res.path, mimetype=dst_mimetype, conditional=True) diff --git a/supysonic/cache.py b/supysonic/cache.py index 8bcef436..88e620e5 100644 --- a/supysonic/cache.py +++ b/supysonic/cache.py @@ -159,7 +159,7 @@ def set_fileobj(self, key): self._make_space(size, key=key) os.replace(f.name, self._filepath(key)) self._record_file(key, size) - except Exception: + except BaseException: f.close() with contextlib.suppress(OSError): os.remove(f.name) @@ -183,9 +183,21 @@ def set_generated(self, key, gen_function): ... print(x) """ with self.set_fileobj(key) as f: - for data in gen_function(): - f.write(data) - yield data + gen = gen_function() + try: + for data in gen: + f.write(data) + yield data + except GeneratorExit: + # Try to stop the generator but check it still wants to yield data. + # If it does allow caching of this data without forwarding it + try: + f.write(gen.throw(GeneratorExit)) + for data in gen: + f.write(data) + except StopIteration: + # We stopped just at the end of the generator + pass def get(self, key): """Return the path to the file where the cached data is stored""" diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index b13c3372..a79057e3 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -79,6 +79,7 @@ def test_mostly_transcoded_cached(self): read = 0 it = iter(rv.response) + # Read up to the estimated length while read < 48000: read += len(next(it)) rv.response.close() @@ -106,6 +107,26 @@ def test_partly_transcoded_cached(self): self.assertFalse(current_app.transcode_cache.has(key)) self.assertEqual(current_app.transcode_cache.size, 0) + @unittest.skipIf( + sys.platform == "win32", + "Can't test transcoding on Windows because of a lack of simple commandline tools", + ) + def test_last_chunk_close_transcoded_cached(self): + rv = self._stream(maxBitRate=96, estimateContentLength="true", format="rnd") + + read = 0 + it = iter(rv.response) + # Read up to the last chunk of data but keep the generator "alive" + while read < 52000: + read += len(next(it)) + rv.response.close() + rv.close() + + key = "{}-96.rnd".format(self.trackid) + with self.app_context(): + self.assertTrue(current_app.transcode_cache.has(key)) + self.assertEqual(current_app.transcode_cache.size, 52000) + if __name__ == "__main__": unittest.main() From bca91041471734247cf1fe6406c9fb06609deb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 21 Nov 2020 17:33:38 +0100 Subject: [PATCH 022/237] Trying to replace Travis CI with GitHub Actions --- .github/workflows/package.yaml | 35 +++++++++++++++++++ .travis.yml | 13 ------- README.md | 2 +- ...is-requirements.txt => ci-requirements.txt | 0 4 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/package.yaml delete mode 100644 .travis.yml rename travis-requirements.txt => ci-requirements.txt (100%) diff --git a/.github/workflows/package.yaml b/.github/workflows/package.yaml new file mode 100644 index 00000000..3ef655b8 --- /dev/null +++ b/.github/workflows/package.yaml @@ -0,0 +1,35 @@ +# Inspired by python-package + +name: Package +on: + - push + - pull_request +jobs: + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - 3.5 + - 3.6 + - 3.7 + - 3.8 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ci-requirements.txt + - name: Run tests + run: | + coverage run setup.py test + coverage run -a setup.py test --test-suite tests.with_net + - name: Upload coverage + uses: codecov/codecov-action@v1.0.15 + if: ${{ always() }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f04b096b..00000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -dist: xenial -language: python -python: - - 3.5 - - 3.6 - - 3.7 - - 3.8 -install: - - pip install -r travis-requirements.txt -script: - - coverage run setup.py test - - coverage run -a setup.py test --test-suite tests.with_net -after_script: codecov diff --git a/README.md b/README.md index 9105bbe8..77886cc5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ _Supysonic_ is a Python implementation of the [Subsonic][] server API. -[![Build Status](https://travis-ci.com/spl0k/supysonic.svg?branch=master)](https://travis-ci.com/spl0k/supysonic) +![Build Status](https://github.com/spl0k/supysonic/workflows/Package/badge.svg) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) ![Python](https://img.shields.io/badge/python-3.5%2C%203.6%2C%203.7%2C%203.8-blue.svg) diff --git a/travis-requirements.txt b/ci-requirements.txt similarity index 100% rename from travis-requirements.txt rename to ci-requirements.txt From 0103e60e32376dd685435f838e13d3749ef9499f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 22 Nov 2020 15:17:05 +0100 Subject: [PATCH 023/237] Tweaking test workflow --- .github/workflows/{package.yaml => tests.yaml} | 5 +++-- README.md | 2 +- ci-requirements.txt | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) rename .github/workflows/{package.yaml => tests.yaml} (92%) diff --git a/.github/workflows/package.yaml b/.github/workflows/tests.yaml similarity index 92% rename from .github/workflows/package.yaml rename to .github/workflows/tests.yaml index 3ef655b8..d3e58e94 100644 --- a/.github/workflows/package.yaml +++ b/.github/workflows/tests.yaml @@ -1,6 +1,6 @@ # Inspired by python-package -name: Package +name: Tests on: - push - pull_request @@ -15,6 +15,7 @@ jobs: - 3.6 - 3.7 - 3.8 + fail-fast: false steps: - name: Checkout uses: actions/checkout@v2 @@ -32,4 +33,4 @@ jobs: coverage run -a setup.py test --test-suite tests.with_net - name: Upload coverage uses: codecov/codecov-action@v1.0.15 - if: ${{ always() }} + if: ${{ !cancelled() }} diff --git a/README.md b/README.md index 77886cc5..f11d7650 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ _Supysonic_ is a Python implementation of the [Subsonic][] server API. -![Build Status](https://github.com/spl0k/supysonic/workflows/Package/badge.svg) +![Build Status](https://github.com/spl0k/supysonic/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) ![Python](https://img.shields.io/badge/python-3.5%2C%203.6%2C%203.7%2C%203.8-blue.svg) diff --git a/ci-requirements.txt b/ci-requirements.txt index e8a0d74e..161c59d8 100644 --- a/ci-requirements.txt +++ b/ci-requirements.txt @@ -2,4 +2,3 @@ lxml coverage -codecov From 81d141e540bf4bb440d8615aa40db80005bf8b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 22 Nov 2020 16:12:14 +0100 Subject: [PATCH 024/237] pyupgrade --- cgi-bin/server.py | 14 +++++++++----- setup.py | 1 - supysonic/__init__.py | 2 -- supysonic/api/annotation.py | 6 ++---- supysonic/api/chat.py | 2 -- supysonic/api/errors.py | 2 -- supysonic/api/exceptions.py | 14 ++++++-------- supysonic/api/formatters.py | 16 +++++++--------- supysonic/api/jukebox.py | 2 -- supysonic/api/media.py | 4 +--- supysonic/api/playlists.py | 2 -- supysonic/api/radio.py | 2 -- supysonic/api/search.py | 18 ++++++++++++++++-- supysonic/api/system.py | 1 - supysonic/api/unsupported.py | 2 -- supysonic/cache.py | 4 +--- supysonic/cli.py | 14 +++++++------- supysonic/config.py | 6 ++---- supysonic/covers.py | 6 ++---- supysonic/daemon/__init__.py | 2 -- supysonic/daemon/__main__.py | 1 - supysonic/daemon/client.py | 10 ++++------ supysonic/daemon/exceptions.py | 2 -- supysonic/daemon/server.py | 4 +--- supysonic/db.py | 4 ++-- supysonic/frontend/__init__.py | 2 -- supysonic/frontend/folder.py | 2 -- supysonic/frontend/playlist.py | 2 -- supysonic/jukebox.py | 4 +--- supysonic/lastfm.py | 2 -- supysonic/managers/__init__.py | 2 -- supysonic/managers/folder.py | 2 -- supysonic/scanner.py | 8 +++----- supysonic/schema/migration/mysql/20171230.py | 7 ++----- .../schema/migration/postgres/20180317.py | 6 +++--- supysonic/schema/migration/sqlite/20171230.py | 3 --- supysonic/schema/migration/sqlite/20180317.py | 6 +++--- supysonic/utils.py | 2 -- supysonic/watcher.py | 14 +++++--------- supysonic/web.py | 2 -- tests/__init__.py | 2 -- tests/api/__init__.py | 2 -- tests/api/apitestbase.py | 2 +- tests/api/test_album_songs.py | 2 +- tests/api/test_annotation.py | 2 +- tests/api/test_api_setup.py | 3 +-- tests/api/test_browse.py | 2 +- tests/api/test_chat.py | 1 - tests/api/test_lyrics.py | 3 +-- tests/api/test_media.py | 4 ++-- tests/api/test_playlist.py | 3 +-- tests/api/test_radio.py | 3 +-- tests/api/test_response_helper.py | 11 +++++------ tests/api/test_search.py | 3 +-- tests/api/test_system.py | 1 - tests/api/test_transcoding.py | 2 +- tests/base/__init__.py | 2 -- tests/base/test_cache.py | 10 +++------- tests/base/test_cli.py | 2 +- tests/base/test_config.py | 1 - tests/base/test_db.py | 1 - tests/base/test_lastfm.py | 3 +-- tests/base/test_scanner.py | 2 +- tests/base/test_secret.py | 1 - tests/base/test_watcher.py | 6 +++--- tests/frontend/__init__.py | 1 - tests/frontend/frontendtestbase.py | 4 +--- tests/frontend/test_folder.py | 1 - tests/frontend/test_login.py | 1 - tests/frontend/test_playlist.py | 3 +-- tests/frontend/test_user.py | 3 +-- tests/issue101.py | 2 -- tests/issue129.py | 4 +--- tests/issue133.py | 2 -- tests/issue139.py | 2 -- tests/managers/__init__.py | 2 -- tests/managers/test_manager_folder.py | 1 - tests/managers/test_manager_user.py | 2 +- tests/testbase.py | 4 ++-- tests/utils.py | 2 -- tests/with_net.py | 2 -- 81 files changed, 110 insertions(+), 205 deletions(-) diff --git a/cgi-bin/server.py b/cgi-bin/server.py index 701d1dd9..4c7a4431 100755 --- a/cgi-bin/server.py +++ b/cgi-bin/server.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -9,10 +8,15 @@ # Distributed under terms of the GNU AGPLv3 license. from supysonic.web import create_application + app = create_application() -if __name__ == '__main__': - if app: - import sys - app.run(host = sys.argv[1] if len(sys.argv) > 1 else None, port = int(sys.argv[2]) if len(sys.argv) > 2 else 5000, debug = True) +if __name__ == "__main__": + if app: + import sys + app.run( + host=sys.argv[1] if len(sys.argv) > 1 else None, + port=int(sys.argv[2]) if len(sys.argv) > 2 else 5000, + debug=True, + ) diff --git a/setup.py b/setup.py index 738b92d1..0bdfc075 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 5317c5dc..d9bb7bcf 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index 3a8e7368..dae6fd88 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -25,7 +23,7 @@ def star_single(cls, eid): - """ Stars an entity + """Stars an entity :param cls: entity class, Folder, Artist, Album or Track :param eid: id of the entity to star @@ -47,7 +45,7 @@ def star_single(cls, eid): def unstar_single(cls, eid): - """ Unstars an entity + """Unstars an entity :param cls: entity class, Folder, Artist, Album or Track :param eid: id of the entity to unstar diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index b40f0e86..f5faa7ad 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/api/errors.py b/supysonic/api/errors.py index f04973ec..7842524d 100644 --- a/supysonic/api/errors.py +++ b/supysonic/api/errors.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/api/exceptions.py b/supysonic/api/exceptions.py index 401726b4..6a1a80e2 100644 --- a/supysonic/api/exceptions.py +++ b/supysonic/api/exceptions.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -30,7 +28,7 @@ class GenericError(SubsonicAPIException): api_code = 0 def __init__(self, message, *args, **kwargs): - super(GenericError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.message = message @@ -41,14 +39,14 @@ class ServerError(GenericError): class UnsupportedParameter(GenericError): def __init__(self, parameter, *args, **kwargs): message = "Unsupported parameter '{}'".format(parameter) - super(UnsupportedParameter, self).__init__(message, *args, **kwargs) + super().__init__(message, *args, **kwargs) class MissingParameter(SubsonicAPIException): api_code = 10 def __init__(self, *args, **kwargs): - super(MissingParameter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.message = "A required parameter is missing." @@ -90,13 +88,13 @@ class NotFound(SubsonicAPIException): api_code = 70 def __init__(self, entity, *args, **kwargs): - super(NotFound, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.message = "{} not found".format(entity) class AggregateException(SubsonicAPIException): def __init__(self, exceptions, *args, **kwargs): - super(AggregateException, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.exceptions = [] for exc in exceptions: @@ -114,7 +112,7 @@ def get_response(self, environ=None): if len(self.exceptions) == 1: return self.exceptions[0].get_response() - codes = set(exc.api_code for exc in self.exceptions) + codes = {exc.api_code for exc in self.exceptions} errors = [ dict(code=exc.api_code, message=exc.message) for exc in self.exceptions ] diff --git a/supysonic/api/formatters.py b/supysonic/api/formatters.py index de010994..2321b961 100644 --- a/supysonic/api/formatters.py +++ b/supysonic/api/formatters.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -13,7 +11,7 @@ from . import API_VERSION -class BaseFormatter(object): +class BaseFormatter: def make_response(self, elem, data): raise NotImplementedError() @@ -93,12 +91,12 @@ def make_response(self, elem, data): class XMLFormatter(BaseFormatter): def __dict2xml(self, elem, dictionary): """Convert a dict structure to xml. The game is trivial. Nesting uses the [] parenthesis. - ex. { 'musicFolder': {'id': 1234, 'name': "sss" } } - ex. { 'musicFolder': [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }]} - ex. { 'musicFolders': {'musicFolder' : [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }] } } - ex. { 'index': [{'name': 'A', 'artist': [{'id': '517674445', 'name': 'Antonello Venditti'}] }] } - ex. {"subsonic-response": { "musicFolders": {"musicFolder": [{ "id": 0,"name": "Music"}]}, - "status": "ok","version": "1.7.0","xmlns": "http://subsonic.org/restapi"}} + ex. { 'musicFolder': {'id': 1234, 'name': "sss" } } + ex. { 'musicFolder': [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }]} + ex. { 'musicFolders': {'musicFolder' : [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }] } } + ex. { 'index': [{'name': 'A', 'artist': [{'id': '517674445', 'name': 'Antonello Venditti'}] }] } + ex. {"subsonic-response": { "musicFolders": {"musicFolder": [{ "id": 0,"name": "Music"}]}, + "status": "ok","version": "1.7.0","xmlns": "http://subsonic.org/restapi"}} """ if not isinstance(dictionary, dict): raise TypeError("Expecting a dict") diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 02fcef48..63276d6e 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 50a7bc61..28e7477f 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -332,7 +330,7 @@ def lyrics(): logger.debug("Found lyrics file: " + lyrics_path) try: - with open(lyrics_path, "rt") as f: + with open(lyrics_path) as f: lyrics = f.read() except UnicodeError: # Lyrics file couldn't be decoded. Rather than displaying an error, try with the potential next files or diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index 056a2def..bb0b60f6 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index 65e7fc36..e035d759 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 1e67524e..a15a6b6f 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -86,7 +86,14 @@ def old_search(): @api.route("/search2.view", methods=["GET", "POST"]) def new_search(): query = request.values["query"] - artist_count, artist_offset, album_count, album_offset, song_count, song_offset = map( + ( + artist_count, + artist_offset, + album_count, + album_offset, + song_count, + song_offset, + ) = map( request.values.get, [ "artistCount", @@ -131,7 +138,14 @@ def new_search(): @api.route("/search3.view", methods=["GET", "POST"]) def search_id3(): query = request.values["query"] - artist_count, artist_offset, album_count, album_offset, song_count, song_offset = map( + ( + artist_count, + artist_offset, + album_count, + album_offset, + song_count, + song_offset, + ) = map( request.values.get, [ "artistCount", diff --git a/supysonic/api/system.py b/supysonic/api/system.py index 95a90271..b8c724a8 100644 --- a/supysonic/api/system.py +++ b/supysonic/api/system.py @@ -1,4 +1,3 @@ -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/supysonic/api/unsupported.py b/supysonic/api/unsupported.py index 0a76c1f2..99c01746 100644 --- a/supysonic/api/unsupported.py +++ b/supysonic/api/unsupported.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/cache.py b/supysonic/cache.py index 88e620e5..939ac790 100644 --- a/supysonic/cache.py +++ b/supysonic/cache.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -38,7 +36,7 @@ class ProtectedError(Exception): NULL_ENTRY = CacheEntry(0, 0) -class Cache(object): +class Cache: """Provides a common interface for caching files to disk""" # Modeled after werkzeug.contrib.cache.FileSystemCache diff --git a/supysonic/cli.py b/supysonic/cli.py index 52eb4696..e40db65e 100755 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -33,7 +33,7 @@ def __init__(self, stdout, interval=5): def __call__(self, name, scanned): if time.time() - self.__last_display > self.__interval: - progress = "Scanning '{0}': {1} files scanned".format(name, scanned) + progress = "Scanning '{}': {} files scanned".format(name, scanned) self.__stdout.write("\b" * self.__last_len) self.__stdout.write(progress) self.__stdout.flush() @@ -188,7 +188,7 @@ def folder_list(self): self.write_line("Name\t\tPath\n----\t\t----") self.write_line( "\n".join( - "{0: <16}{1}".format(f.name, f.path) + "{: <16}{}".format(f.name, f.path) for f in Folder.select(lambda f: f.root) ) ) @@ -354,7 +354,7 @@ def user_list(self): self.write_line("----\t\t-----\t-------\t-----") self.write_line( "\n".join( - "{0: <16}{1}\t{2}\t{3}".format( + "{: <16}{}\t{}\t{}".format( u.name, "*" if u.admin else "", "*" if u.jukebox else "", u.mail ) for u in User.select() @@ -393,16 +393,16 @@ def user_setroles(self, name, admin, noadmin, jukebox, nojukebox): else: if admin: user.admin = True - self.write_line("Granted '{0}' admin rights".format(name)) + self.write_line("Granted '{}' admin rights".format(name)) elif noadmin: user.admin = False - self.write_line("Revoked '{0}' admin rights".format(name)) + self.write_line("Revoked '{}' admin rights".format(name)) if jukebox: user.jukebox = True - self.write_line("Granted '{0}' jukebox rights".format(name)) + self.write_line("Granted '{}' jukebox rights".format(name)) elif nojukebox: user.jukebox = False - self.write_line("Revoked '{0}' jukebox rights".format(name)) + self.write_line("Revoked '{}' jukebox rights".format(name)) @db_session def user_changepass(self, name, password): diff --git a/supysonic/config.py b/supysonic/config.py index d86d09db..3d913b24 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -21,7 +19,7 @@ def get_current_config(): return current_config or DefaultConfig() -class DefaultConfig(object): +class DefaultConfig: DEBUG = False tempdir = os.path.join(tempfile.gettempdir(), "supysonic") @@ -67,7 +65,7 @@ class IniConfig(DefaultConfig): ] def __init__(self, paths): - super(IniConfig, self).__init__() + super().__init__() parser = RawConfigParser() parser.read(paths) diff --git a/supysonic/covers.py b/supysonic/covers.py index 3c1ed384..2b06ed68 100644 --- a/supysonic/covers.py +++ b/supysonic/covers.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -27,7 +25,7 @@ ) -class CoverFile(object): +class CoverFile: __clean_regex = re.compile(r"[^a-z]") @staticmethod @@ -63,7 +61,7 @@ def is_valid_cover(path): warnings.simplefilter("ignore") with Image.open(path): return True - except IOError: + except OSError: return False diff --git a/supysonic/daemon/__init__.py b/supysonic/daemon/__init__.py index 98337324..d8a67e55 100644 --- a/supysonic/daemon/__init__.py +++ b/supysonic/daemon/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 - # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/daemon/__main__.py b/supysonic/daemon/__main__.py index fd3fc9b1..b15b5b2a 100755 --- a/supysonic/daemon/__main__.py +++ b/supysonic/daemon/__main__.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index 9f3b0961..46540b82 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -16,7 +14,7 @@ __all__ = ["DaemonClient"] -class DaemonCommand(object): +class DaemonCommand: def apply(self, connection, daemon): raise NotImplementedError() @@ -102,7 +100,7 @@ def apply(self, connection, daemon): connection.send(rv) -class DaemonCommandResult(object): +class DaemonCommandResult: pass @@ -128,7 +126,7 @@ def __init__(self, jukebox): self.playlist = () -class DaemonClient(object): +class DaemonClient: def __init__(self, address=None): self.__address = address or get_current_config().DAEMON["socket"] self.__key = get_secret_key("daemon_key") @@ -138,7 +136,7 @@ def __get_connection(self): raise DaemonUnavailableError("No daemon address set") try: return Client(address=self.__address, authkey=self.__key) - except IOError: + except OSError: raise DaemonUnavailableError( "Couldn't connect to daemon at {}".format(self.__address) ) diff --git a/supysonic/daemon/exceptions.py b/supysonic/daemon/exceptions.py index 3be86df1..608e5c45 100644 --- a/supysonic/daemon/exceptions.py +++ b/supysonic/daemon/exceptions.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index fa2d07b6..989825c0 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -26,7 +24,7 @@ logger = logging.getLogger(__name__) -class Daemon(object): +class Daemon: def __init__(self, config): self.__config = config self.__listener = None diff --git a/supysonic/db.py b/supysonic/db.py index 77e1c730..2f817974 100755 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -46,7 +46,7 @@ def sqlite_case_insensitive_like(db, connection): cursor.execute("PRAGMA case_sensitive_like = OFF") -class PathMixin(object): +class PathMixin: @classmethod def get(cls, *args, **kwargs): if kwargs: @@ -529,7 +529,7 @@ def as_subsonic_playlist(self, user): id=str(self.id), name=self.name if self.user.id == user.id - else "[%s] %s" % (self.user.name, self.name), + else "[{}] {}".format(self.user.name, self.name), owner=self.user.name, public=self.public, songCount=len(tracks), diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index 92c05bb7..1378c4cc 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/frontend/folder.py b/supysonic/frontend/folder.py index 3bef1cbb..1ab340ec 100644 --- a/supysonic/frontend/folder.py +++ b/supysonic/frontend/folder.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/frontend/playlist.py b/supysonic/frontend/playlist.py index eaef8fed..ff6920bc 100644 --- a/supysonic/frontend/playlist.py +++ b/supysonic/frontend/playlist.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 2b8c49c1..7de6f548 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -22,7 +20,7 @@ logger = logging.getLogger(__name__) -class Jukebox(object): +class Jukebox: def __init__(self, cmd): self.__cmd = shlex.split(cmd) self.__playlist = [] diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index 8f260b52..acdf83bb 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/managers/__init__.py b/supysonic/managers/__init__.py index f87fa985..3ad5e49d 100644 --- a/supysonic/managers/__init__.py +++ b/supysonic/managers/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index 36862e96..84573db0 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/scanner.py b/supysonic/scanner.py index c3f6a04f..f56ff885 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -26,14 +24,14 @@ logger = logging.getLogger(__name__) -class StatsDetails(object): +class StatsDetails: def __init__(self): self.artists = 0 self.albums = 0 self.tracks = 0 -class Stats(object): +class Stats: def __init__(self): self.scanned = 0 self.added = StatsDetails() @@ -66,7 +64,7 @@ def __init__( on_folder_end=None, on_done=None, ): - super(Scanner, self).__init__() + super().__init__() if extensions is not None and not isinstance(extensions, list): raise TypeError("Invalid extensions type") diff --git a/supysonic/schema/migration/mysql/20171230.py b/supysonic/schema/migration/mysql/20171230.py index 9b467a08..aece82b5 100644 --- a/supysonic/schema/migration/mysql/20171230.py +++ b/supysonic/schema/migration/mysql/20171230.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -# vim:fenc=utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -45,10 +42,10 @@ def process_table(connection, table, fields, nullable_fields=()): sql = "UPDATE {0} SET {1}=%s WHERE {1}=%s".format(table, field) c.executemany(sql, map(lambda v: (UUID(v).bytes, v), values)) for field in fields: - sql = "ALTER TABLE {0} MODIFY {1} BINARY(16) NOT NULL".format(table, field) + sql = "ALTER TABLE {} MODIFY {} BINARY(16) NOT NULL".format(table, field) c.execute(sql) for field in nullable_fields: - sql = "ALTER TABLE {0} MODIFY {1} BINARY(16)".format(table, field) + sql = "ALTER TABLE {} MODIFY {} BINARY(16)".format(table, field) c.execute(sql) connection.commit() diff --git a/supysonic/schema/migration/postgres/20180317.py b/supysonic/schema/migration/postgres/20180317.py index 416aa976..5f9216fe 100644 --- a/supysonic/schema/migration/postgres/20180317.py +++ b/supysonic/schema/migration/postgres/20180317.py @@ -20,17 +20,17 @@ def process_table(connection, table): c = connection.cursor() c.execute( - r"ALTER TABLE {0} ADD COLUMN path_hash BYTEA NOT NULL DEFAULT E'\\0000'".format( + r"ALTER TABLE {} ADD COLUMN path_hash BYTEA NOT NULL DEFAULT E'\\0000'".format( table ) ) hashes = dict() - c.execute("SELECT path FROM {0}".format(table)) + c.execute("SELECT path FROM {}".format(table)) for row in c.fetchall(): hashes[row[0]] = hashlib.sha1(row[0].encode("utf-8")).digest() c.executemany( - "UPDATE {0} SET path_hash=%s WHERE path=%s".format(table), + "UPDATE {} SET path_hash=%s WHERE path=%s".format(table), [(bytes(h), p) for p, h in hashes.items()], ) diff --git a/supysonic/schema/migration/sqlite/20171230.py b/supysonic/schema/migration/sqlite/20171230.py index 78716147..6f48054f 100644 --- a/supysonic/schema/migration/sqlite/20171230.py +++ b/supysonic/schema/migration/sqlite/20171230.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -# vim:fenc=utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/schema/migration/sqlite/20180317.py b/supysonic/schema/migration/sqlite/20180317.py index df97ac22..5888cc97 100644 --- a/supysonic/schema/migration/sqlite/20180317.py +++ b/supysonic/schema/migration/sqlite/20180317.py @@ -17,14 +17,14 @@ def process_table(connection, table): c = connection.cursor() c.execute( - "ALTER TABLE {0} ADD COLUMN path_hash BLOB NOT NULL DEFAULT ROWID".format(table) + "ALTER TABLE {} ADD COLUMN path_hash BLOB NOT NULL DEFAULT ROWID".format(table) ) hashes = dict() - for row in c.execute("SELECT path FROM {0}".format(table)): + for row in c.execute("SELECT path FROM {}".format(table)): hashes[row[0]] = hashlib.sha1(row[0].encode("utf-8")).digest() c.executemany( - "UPDATE {0} SET path_hash=? WHERE path=?".format(table), + "UPDATE {} SET path_hash=? WHERE path=?".format(table), [(bytes(h), p) for p, h in hashes.items()], ) diff --git a/supysonic/utils.py b/supysonic/utils.py index 09dec7ba..266da2b8 100644 --- a/supysonic/utils.py +++ b/supysonic/utils.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 9b8c1940..956b0881 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -36,13 +34,11 @@ def __init__(self, extensions): patterns = list(map(lambda e: "*." + e.lower(), extensions.split())) + list( map(lambda e: "*" + e, covers.EXTENSIONS) ) - super(SupysonicWatcherEventHandler, self).__init__( - patterns=patterns, ignore_directories=True - ) + super().__init__(patterns=patterns, ignore_directories=True) def dispatch(self, event): try: - super(SupysonicWatcherEventHandler, self).dispatch(event) + super().dispatch(event) except Exception as e: # pragma: nocover logger.critical(e) @@ -85,7 +81,7 @@ def on_moved(self, event): self.queue.put(event.dest_path, op, src_path=event.src_path) -class Event(object): +class Event: def __init__(self, path, operation, **kwargs): if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE): raise Exception("Flags SCAN and REMOVE both set") # pragma: nocover @@ -131,7 +127,7 @@ def src_path(self): class ScannerProcessingQueue(Thread): def __init__(self, delay): - super(ScannerProcessingQueue, self).__init__() + super().__init__() self.__timeout = delay self.__cond = Condition() @@ -254,7 +250,7 @@ def __next_item(self): return None -class SupysonicWatcher(object): +class SupysonicWatcher: def __init__(self, config): self.__delay = config.DAEMON["wait_delay"] self.__handler = SupysonicWatcherEventHandler(config.BASE["scanner_extensions"]) diff --git a/supysonic/web.py b/supysonic/web.py index 7bc72825..2d051490 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/__init__.py b/tests/__init__.py index 7ef23e22..36ba1519 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/__init__.py b/tests/api/__init__.py index 3713050f..b67211d1 100644 --- a/tests/api/__init__.py +++ b/tests/api/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index ea75c2a1..bad1c685 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -22,7 +22,7 @@ class ApiTestBase(TestBase): __with_api__ = True def setUp(self): - super(ApiTestBase, self).setUp() + super().setUp() xsd = etree.parse("tests/assets/subsonic-rest-api-1.10.2.xsd") self.schema = etree.XMLSchema(xsd) diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index c69a897a..56598c0f 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -21,7 +21,7 @@ class AlbumSongsTestCase(ApiTestBase): # Let's just check paramter validation and ensure coverage def setUp(self): - super(AlbumSongsTestCase, self).setUp() + super().setUp() with db_session: folder = Folder(name="Root", root=True, path="tests/assets") diff --git a/tests/api/test_annotation.py b/tests/api/test_annotation.py index 3a2120cc..1f5099d8 100644 --- a/tests/api/test_annotation.py +++ b/tests/api/test_annotation.py @@ -18,7 +18,7 @@ class AnnotationTestCase(ApiTestBase): def setUp(self): - super(AnnotationTestCase, self).setUp() + super().setUp() with db_session: root = Folder(name="Root", root=True, path="tests") diff --git a/tests/api/test_api_setup.py b/tests/api/test_api_setup.py index 9a91f158..1166323e 100644 --- a/tests/api/test_api_setup.py +++ b/tests/api/test_api_setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -22,7 +21,7 @@ class ApiSetupTestCase(TestBase): __with_api__ = True def setUp(self): - super(ApiSetupTestCase, self).setUp() + super().setUp() self._patch_client() def __basic_auth_get(self, username, password): diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index 273eab50..47321f08 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -20,7 +20,7 @@ class BrowseTestCase(ApiTestBase): def setUp(self): - super(BrowseTestCase, self).setUp() + super().setUp() with db_session: Folder(root=True, name="Empty root", path="/tmp") diff --git a/tests/api/test_chat.py b/tests/api/test_chat.py index 60f158f3..5c33f071 100644 --- a/tests/api/test_chat.py +++ b/tests/api/test_chat.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/api/test_lyrics.py b/tests/api/test_lyrics.py index 96ac17c1..362bfcd9 100644 --- a/tests/api/test_lyrics.py +++ b/tests/api/test_lyrics.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -21,7 +20,7 @@ class LyricsTestCase(ApiTestBase): def setUp(self): - super(LyricsTestCase, self).setUp() + super().setUp() with db_session: folder = Folder( diff --git a/tests/api/test_media.py b/tests/api/test_media.py index e8847e0d..0d1a5282 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -22,7 +22,7 @@ class MediaTestCase(ApiTestBase): def setUp(self): - super(MediaTestCase, self).setUp() + super().setUp() with db_session: folder = Folder( @@ -61,7 +61,7 @@ def setUp(self): artist=artist, album=album, path=os.path.abspath( - "tests/assets/formats/silence.{0}".format(self.formats[i]) + "tests/assets/formats/silence.{}".format(self.formats[i]) ), root_folder=folder, folder=folder, diff --git a/tests/api/test_playlist.py b/tests/api/test_playlist.py index 327d01de..76f9e666 100644 --- a/tests/api/test_playlist.py +++ b/tests/api/test_playlist.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -19,7 +18,7 @@ class PlaylistTestCase(ApiTestBase): def setUp(self): - super(PlaylistTestCase, self).setUp() + super().setUp() with db_session: root = Folder(root=True, name="Root folder", path="tests/assets") diff --git a/tests/api/test_radio.py b/tests/api/test_radio.py index f01ef29d..c0795676 100644 --- a/tests/api/test_radio.py +++ b/tests/api/test_radio.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -19,7 +18,7 @@ class RadioStationTestCase(ApiTestBase): def setUp(self): - super(RadioStationTestCase, self).setUp() + super().setUp() @db_session def assertRadioStationCountEqual(self, count): diff --git a/tests/api/test_response_helper.py b/tests/api/test_response_helper.py index 4ea9806b..9cae2017 100644 --- a/tests/api/test_response_helper.py +++ b/tests/api/test_response_helper.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -18,10 +17,10 @@ from ..testbase import TestBase -class UnwrapperMixin(object): +class UnwrapperMixin: def make_response(self, elem, data): with self.request_context(): - rv = super(UnwrapperMixin, self).make_response(elem, data) + rv = super().make_response(elem, data) return rv.get_data(as_text=True) @staticmethod @@ -34,7 +33,7 @@ class Unwrapper(UnwrapperMixin, cls): class ResponseHelperJsonTestCase(TestBase, UnwrapperMixin.create_from(JSONFormatter)): def make_response(self, elem, data): - rv = super(ResponseHelperJsonTestCase, self).make_response(elem, data) + rv = super().make_response(elem, data) return flask.json.loads(rv) def process_and_extract(self, d): @@ -117,7 +116,7 @@ def test_basic(self): class ResponseHelperXMLTestCase(TestBase, UnwrapperMixin.create_from(XMLFormatter)): def make_response(self, elem, data): - xml = super(ResponseHelperXMLTestCase, self).make_response(elem, data) + xml = super().make_response(elem, data) xml = xml.replace('xmlns="http://subsonic.org/restapi"', "") root = ElementTree.fromstring(xml) return root @@ -131,7 +130,7 @@ def assertAttributesMatchDict(self, elem, d): self.assertDictEqual(elem.attrib, d) def test_root(self): - xml = super(ResponseHelperXMLTestCase, self).make_response("tag", {}) + xml = super().make_response("tag", {}) self.assertIn("")) diff --git a/tests/api/test_search.py b/tests/api/test_search.py index 1c96ee0b..3854b8be 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -20,7 +19,7 @@ class SearchTestCase(ApiTestBase): def setUp(self): - super(SearchTestCase, self).setUp() + super().setUp() with db_session: root = Folder(root=True, name="Root folder", path="tests/assets") diff --git a/tests/api/test_system.py b/tests/api/test_system.py index bc9dfeb4..72d640a4 100644 --- a/tests/api/test_system.py +++ b/tests/api/test_system.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index a79057e3..ba687627 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -22,7 +22,7 @@ class TranscodingTestCase(ApiTestBase): def setUp(self): - super(TranscodingTestCase, self).setUp() + super().setUp() with db_session: folder = FolderManager.add("Folder", "tests/assets/folder") diff --git a/tests/base/__init__.py b/tests/base/__init__.py index 3a677170..3633340a 100644 --- a/tests/base/__init__.py +++ b/tests/base/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_cache.py b/tests/base/test_cache.py index 13fe2b8d..dc047b63 100644 --- a/tests/base/test_cache.py +++ b/tests/base/test_cache.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -74,8 +73,7 @@ def test_store_generated(self): val = [b"0", b"12", b"345", b"6789"] def gen(): - for b in val: - yield b + yield from val t = [] for x in cache.set_generated("key", gen): @@ -160,8 +158,7 @@ def test_cleanup_on_error(self): def gen(): # Cause a TypeError halfway through - for b in [b"0", b"12", object(), b"345", b"6789"]: - yield b + yield from [b"0", b"12", object(), b"345", b"6789"] with self.assertRaises(TypeError): for x in cache.set_generated("key", gen): @@ -174,8 +171,7 @@ def test_parallel_generation(self): cache = Cache(self.__dir, 20) def gen(): - for b in [b"0", b"12", b"345", b"6789"]: - yield b + yield from [b"0", b"12", b"345", b"6789"] g1 = cache.set_generated("key", gen) g2 = cache.set_generated("key", gen) diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index c093ccb3..297e7ff7 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -42,7 +42,7 @@ def tearDown(self): os.remove(self.__db[1]) def __add_folder(self, name, path): - self.__cli.onecmd("folder add {0} {1}".format(name, shlex.quote(path))) + self.__cli.onecmd("folder add {} {}".format(name, shlex.quote(path))) def test_folder_add(self): with tempfile.TemporaryDirectory() as d: diff --git a/tests/base/test_config.py b/tests/base/test_config.py index 4340401e..a48684cb 100644 --- a/tests/base/test_config.py +++ b/tests/base/test_config.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/base/test_db.py b/tests/base/test_db.py index 51187af9..1778881a 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/base/test_lastfm.py b/tests/base/test_lastfm.py index 9ade2809..b5d8c43c 100644 --- a/tests/base/test_lastfm.py +++ b/tests/base/test_lastfm.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -21,7 +20,7 @@ def test_request(self): logging.getLogger("supysonic.lastfm").addHandler(logging.NullHandler()) lastfm = LastFm({"api_key": "key", "secret": "secret"}, None) - rv = lastfm._LastFm__api_request(False, method="dummy", accents=u"àéèùö") + rv = lastfm._LastFm__api_request(False, method="dummy", accents="àéèùö") self.assertIsInstance(rv, dict) diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index 9b435532..4ebde722 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -42,7 +42,7 @@ def __temporary_track_copy(self): with tempfile.NamedTemporaryFile( dir=os.path.dirname(track.path), delete=False ) as tf: - with io.open(track.path, "rb") as f: + with open(track.path, "rb") as f: tf.write(f.read()) try: yield tf.name diff --git a/tests/base/test_secret.py b/tests/base/test_secret.py index dbbe925f..0fd38742 100644 --- a/tests/base/test_secret.py +++ b/tests/base/test_secret.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index 08aa4110..9b704037 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -28,7 +28,7 @@ class WatcherTestConfig(TestConfig): DAEMON = {"wait_delay": 0.5, "log_file": "/dev/null", "log_level": "DEBUG"} def __init__(self, db_uri): - super(WatcherTestConfig, self).__init__(False, False) + super().__init__(False, False) self.BASE["database_uri"] = db_uri @@ -64,7 +64,7 @@ def _sleep(self): class WatcherTestCase(WatcherTestBase): def setUp(self): - super(WatcherTestCase, self).setUp() + super().setUp() self.__dir = tempfile.mkdtemp() with db_session: FolderManager.add("Folder", self.__dir) @@ -73,7 +73,7 @@ def setUp(self): def tearDown(self): self._stop() shutil.rmtree(self.__dir) - super(WatcherTestCase, self).tearDown() + super().tearDown() @staticmethod def _tempname(): diff --git a/tests/frontend/__init__.py b/tests/frontend/__init__.py index 1aad44bc..0208f63e 100644 --- a/tests/frontend/__init__.py +++ b/tests/frontend/__init__.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/frontend/frontendtestbase.py b/tests/frontend/frontendtestbase.py index a05a96c0..8d23eafa 100644 --- a/tests/frontend/frontendtestbase.py +++ b/tests/frontend/frontendtestbase.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -14,7 +12,7 @@ class FrontendTestBase(TestBase): __with_webui__ = True def setUp(self): - super(FrontendTestBase, self).setUp() + super().setUp() self._patch_client() def _login(self, username, password): diff --git a/tests/frontend/test_folder.py b/tests/frontend/test_folder.py index c0ca1ea7..af009989 100644 --- a/tests/frontend/test_folder.py +++ b/tests/frontend/test_folder.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/frontend/test_login.py b/tests/frontend/test_login.py index d2c1d3c0..01524f1e 100644 --- a/tests/frontend/test_login.py +++ b/tests/frontend/test_login.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/frontend/test_playlist.py b/tests/frontend/test_playlist.py index 3c403fa1..6dbd67d2 100644 --- a/tests/frontend/test_playlist.py +++ b/tests/frontend/test_playlist.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -19,7 +18,7 @@ class PlaylistTestCase(FrontendTestBase): def setUp(self): - super(PlaylistTestCase, self).setUp() + super().setUp() with db_session: folder = Folder(name="Root", path="tests/assets", root=True) diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index ae361bcd..d94918e6 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -20,7 +19,7 @@ class UserTestCase(FrontendTestBase): def setUp(self): - super(UserTestCase, self).setUp() + super().setUp() with db_session: self.users = {u.name: u.id for u in User.select()} diff --git a/tests/issue101.py b/tests/issue101.py index 189b0cab..d7ce8ca3 100644 --- a/tests/issue101.py +++ b/tests/issue101.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/issue129.py b/tests/issue129.py index 8eb29309..2db00a65 100644 --- a/tests/issue129.py +++ b/tests/issue129.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -21,7 +19,7 @@ class Issue129TestCase(TestBase): def setUp(self): - super(Issue129TestCase, self).setUp() + super().setUp() with db_session: folder = FolderManager.add("folder", os.path.abspath("tests/assets/folder")) diff --git a/tests/issue133.py b/tests/issue133.py index 076d75bd..7c776194 100644 --- a/tests/issue133.py +++ b/tests/issue133.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/issue139.py b/tests/issue139.py index c888e34d..46fcead9 100644 --- a/tests/issue139.py +++ b/tests/issue139.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/managers/__init__.py b/tests/managers/__init__.py index b547c90d..efe761bb 100644 --- a/tests/managers/__init__.py +++ b/tests/managers/__init__.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/managers/test_manager_folder.py b/tests/managers/test_manager_folder.py index 27a3b2e3..7ecb950c 100644 --- a/tests/managers/test_manager_folder.py +++ b/tests/managers/test_manager_folder.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index f9a3b63f..c30a38f9 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -71,7 +71,7 @@ def test_encrypt_password(self): ("d68c95a91ed7773aa57c7c044d2309a5bf1da2e7", "pepper"), ) self.assertEqual( - func(u"éèàïô", "ABC+"), ("b639ba5217b89c906019d89d5816b407d8730898", "ABC+") + func("éèàïô", "ABC+"), ("b639ba5217b89c906019d89d5816b407d8730898", "ABC+") ) @db_session diff --git a/tests/testbase.py b/tests/testbase.py index 4879ed18..3b78d9df 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -32,7 +32,7 @@ class TestConfig(DefaultConfig): } def __init__(self, with_webui, with_api): - super(TestConfig, self).__init__() + super().__init__() for cls in reversed(inspect.getmro(self.__class__)): for attr, value in cls.__dict__.items(): @@ -47,7 +47,7 @@ def __init__(self, with_webui, with_api): self.WEBAPP.update({"mount_webui": with_webui, "mount_api": with_api}) -class MockResponse(object): +class MockResponse: def __init__(self, response): self.__status_code = response.status_code self.__data = response.get_data(as_text=True) diff --git a/tests/utils.py b/tests/utils.py index a095571e..1b214d2a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/with_net.py b/tests/with_net.py index dda1cd9a..64a268c7 100644 --- a/tests/with_net.py +++ b/tests/with_net.py @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # From 9a3bdc30acd948fdc66be0c342217557ff04c54b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 22 Nov 2020 16:28:26 +0100 Subject: [PATCH 025/237] Removing shebang and executable flag from explicit python files --- bin/supysonic-cli | 1 - bin/supysonic-watcher | 1 - cgi-bin/server.py | 2 -- cgi-bin/supysonic.cgi | 4 +--- cgi-bin/supysonic.fcgi | 4 +--- cgi-bin/supysonic.wsgi | 3 --- setup.py | 2 -- supysonic/cli.py | 0 supysonic/daemon/__main__.py | 2 -- supysonic/db.py | 0 tests/api/test_album_songs.py | 2 -- tests/api/test_annotation.py | 2 -- tests/api/test_api_setup.py | 2 -- tests/api/test_browse.py | 2 -- tests/api/test_chat.py | 2 -- tests/api/test_lyrics.py | 2 -- tests/api/test_media.py | 2 -- tests/api/test_playlist.py | 2 -- tests/api/test_radio.py | 2 -- tests/api/test_response_helper.py | 2 -- tests/api/test_search.py | 2 -- tests/api/test_system.py | 2 -- tests/api/test_transcoding.py | 2 -- tests/api/test_user.py | 2 -- tests/base/test_cache.py | 2 -- tests/base/test_cli.py | 2 -- tests/base/test_config.py | 2 -- tests/base/test_db.py | 2 -- tests/base/test_lastfm.py | 2 -- tests/base/test_scanner.py | 2 -- tests/base/test_secret.py | 2 -- tests/base/test_watcher.py | 2 -- tests/frontend/__init__.py | 2 -- tests/frontend/test_folder.py | 2 -- tests/frontend/test_login.py | 2 -- tests/frontend/test_playlist.py | 2 -- tests/frontend/test_user.py | 2 -- tests/managers/test_manager_folder.py | 2 -- tests/managers/test_manager_user.py | 2 -- 39 files changed, 2 insertions(+), 75 deletions(-) mode change 100755 => 100644 cgi-bin/server.py mode change 100755 => 100644 setup.py mode change 100755 => 100644 supysonic/cli.py mode change 100755 => 100644 supysonic/daemon/__main__.py mode change 100755 => 100644 supysonic/db.py diff --git a/bin/supysonic-cli b/bin/supysonic-cli index 6f8a99e7..4b2bd50f 100755 --- a/bin/supysonic-cli +++ b/bin/supysonic-cli @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/bin/supysonic-watcher b/bin/supysonic-watcher index 33a62198..40e2dcce 100755 --- a/bin/supysonic-watcher +++ b/bin/supysonic-watcher @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. diff --git a/cgi-bin/server.py b/cgi-bin/server.py old mode 100755 new mode 100644 index 4c7a4431..e0985bbf --- a/cgi-bin/server.py +++ b/cgi-bin/server.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/cgi-bin/supysonic.cgi b/cgi-bin/supysonic.cgi index f47636f9..b44b1e2b 100755 --- a/cgi-bin/supysonic.cgi +++ b/cgi-bin/supysonic.cgi @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -13,5 +12,4 @@ from supysonic.web import create_application app = create_application() if app: - CGIHandler().run(app) - + CGIHandler().run(app) diff --git a/cgi-bin/supysonic.fcgi b/cgi-bin/supysonic.fcgi index 31ee1d3e..0241a6ac 100755 --- a/cgi-bin/supysonic.fcgi +++ b/cgi-bin/supysonic.fcgi @@ -1,5 +1,4 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. @@ -13,5 +12,4 @@ from supysonic.web import create_application app = create_application() if app: - WSGIServer(app, bindAddress = '/path/to/fcgi.sock').run() - + WSGIServer(app, bindAddress = "/path/to/fcgi.sock").run() diff --git a/cgi-bin/supysonic.wsgi b/cgi-bin/supysonic.wsgi index a824c354..f25309c6 100644 --- a/cgi-bin/supysonic.wsgi +++ b/cgi-bin/supysonic.wsgi @@ -1,5 +1,3 @@ -# coding: utf-8 -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # @@ -9,4 +7,3 @@ from supysonic.web import create_application application = create_application() - diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 0bdfc075..4b94f9a7 --- a/setup.py +++ b/setup.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/cli.py b/supysonic/cli.py old mode 100755 new mode 100644 diff --git a/supysonic/daemon/__main__.py b/supysonic/daemon/__main__.py old mode 100755 new mode 100644 index b15b5b2a..a31ac196 --- a/supysonic/daemon/__main__.py +++ b/supysonic/daemon/__main__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/supysonic/db.py b/supysonic/db.py old mode 100755 new mode 100644 diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index 56598c0f..a94fec88 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_annotation.py b/tests/api/test_annotation.py index 1f5099d8..90e74812 100644 --- a/tests/api/test_annotation.py +++ b/tests/api/test_annotation.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_api_setup.py b/tests/api/test_api_setup.py index 1166323e..78ba4e1e 100644 --- a/tests/api/test_api_setup.py +++ b/tests/api/test_api_setup.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index 47321f08..b7021e3c 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_chat.py b/tests/api/test_chat.py index 5c33f071..37277504 100644 --- a/tests/api/test_chat.py +++ b/tests/api/test_chat.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_lyrics.py b/tests/api/test_lyrics.py index 362bfcd9..a66829c6 100644 --- a/tests/api/test_lyrics.py +++ b/tests/api/test_lyrics.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_media.py b/tests/api/test_media.py index 0d1a5282..d88ce263 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_playlist.py b/tests/api/test_playlist.py index 76f9e666..3eee60ac 100644 --- a/tests/api/test_playlist.py +++ b/tests/api/test_playlist.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_radio.py b/tests/api/test_radio.py index c0795676..5793b320 100644 --- a/tests/api/test_radio.py +++ b/tests/api/test_radio.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_response_helper.py b/tests/api/test_response_helper.py index 9cae2017..ce59c51f 100644 --- a/tests/api/test_response_helper.py +++ b/tests/api/test_response_helper.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_search.py b/tests/api/test_search.py index 3854b8be..316fab39 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_system.py b/tests/api/test_system.py index 72d640a4..7281cdcc 100644 --- a/tests/api/test_system.py +++ b/tests/api/test_system.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index ba687627..f7690bc2 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/api/test_user.py b/tests/api/test_user.py index b3043ca0..9c204814 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_cache.py b/tests/base/test_cache.py index dc047b63..eb0fbfe3 100644 --- a/tests/base/test_cache.py +++ b/tests/base/test_cache.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index 297e7ff7..5c3b6926 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_config.py b/tests/base/test_config.py index a48684cb..e9c82b34 100644 --- a/tests/base/test_config.py +++ b/tests/base/test_config.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_db.py b/tests/base/test_db.py index 1778881a..ecb91f21 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_lastfm.py b/tests/base/test_lastfm.py index b5d8c43c..dfa72b86 100644 --- a/tests/base/test_lastfm.py +++ b/tests/base/test_lastfm.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index 4ebde722..393df1c9 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_secret.py b/tests/base/test_secret.py index 0fd38742..92658ac0 100644 --- a/tests/base/test_secret.py +++ b/tests/base/test_secret.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index 9b704037..2bfa2c3a 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/frontend/__init__.py b/tests/frontend/__init__.py index 0208f63e..24bb0bbe 100644 --- a/tests/frontend/__init__.py +++ b/tests/frontend/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/frontend/test_folder.py b/tests/frontend/test_folder.py index af009989..2af75147 100644 --- a/tests/frontend/test_folder.py +++ b/tests/frontend/test_folder.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/frontend/test_login.py b/tests/frontend/test_login.py index 01524f1e..56915626 100644 --- a/tests/frontend/test_login.py +++ b/tests/frontend/test_login.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/frontend/test_playlist.py b/tests/frontend/test_playlist.py index 6dbd67d2..6a1b12ca 100644 --- a/tests/frontend/test_playlist.py +++ b/tests/frontend/test_playlist.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index d94918e6..2d30a695 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/managers/test_manager_folder.py b/tests/managers/test_manager_folder.py index 7ecb950c..43975577 100644 --- a/tests/managers/test_manager_folder.py +++ b/tests/managers/test_manager_folder.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index c30a38f9..798ca2a1 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # From 2c100a021adfdb4915cb4af40f4aacf5ca12e921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 22 Nov 2020 18:09:15 +0100 Subject: [PATCH 026/237] Properly release resources Hunting for the last ResourceWarnings --- supysonic/api/media.py | 26 ++++++++++++++------------ tests/api/test_media.py | 25 ++++++++++--------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 28e7477f..dc9cc413 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -195,7 +195,9 @@ def handle_transcoding(): raise finally: if dec_proc != None: + dec_proc.stdout.close() dec_proc.wait() + proc.stdout.close() proc.wait() resp_content = cache.set_generated(cache_key, handle_transcoding) @@ -303,19 +305,19 @@ def cover_art(): else: return send_file(cover_path) - im = Image.open(cover_path) - mimetype = "image/{}".format(im.format.lower()) - if size > im.width and size > im.height: - return send_file(cover_path, mimetype=mimetype) + with Image.open(cover_path) as im: + mimetype = "image/{}".format(im.format.lower()) + if size > im.width and size > im.height: + return send_file(cover_path, mimetype=mimetype) - cache_key = "{}-cover-{}".format(eid, size) - try: - return send_file(cache.get(cache_key), mimetype=mimetype) - except CacheMiss: - im.thumbnail([size, size], Image.ANTIALIAS) - with cache.set_fileobj(cache_key) as fp: - im.save(fp, im.format) - return send_file(cache.get(cache_key), mimetype=mimetype) + cache_key = "{}-cover-{}".format(eid, size) + try: + return send_file(cache.get(cache_key), mimetype=mimetype) + except CacheMiss: + im.thumbnail([size, size], Image.ANTIALIAS) + with cache.set_fileobj(cache_key) as fp: + im.save(fp, im.format) + return send_file(cache.get(cache_key), mimetype=mimetype) @api.route("/getLyrics.view", methods=["GET", "POST"]) diff --git a/tests/api/test_media.py b/tests/api/test_media.py index d88ce263..819f935e 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -135,6 +135,11 @@ def test_download(self): self.assertEqual(rv.status_code, 200) self.assertEqual(rv.mimetype, "application/zip") + def __assert_image_data(self, resp, format, size): + with Image.open(BytesIO(resp.data)) as im: + self.assertEqual(im.format, format) + self.assertEqual(im.size, (size, size)) + def test_get_cover_art(self): self._make_request("getCoverArt", error=10) self._make_request("getCoverArt", {"id": "string"}, error=0) @@ -150,9 +155,7 @@ def test_get_cover_art(self): ) as rv: self.assertEqual(rv.status_code, 200) self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (420, 420)) + self.__assert_image_data(rv, "JPEG", 420) args["size"] = 600 with closing( @@ -160,9 +163,7 @@ def test_get_cover_art(self): ) as rv: self.assertEqual(rv.status_code, 200) self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (420, 420)) + self.__assert_image_data(rv, "JPEG", 420) args["size"] = 120 with closing( @@ -170,9 +171,7 @@ def test_get_cover_art(self): ) as rv: self.assertEqual(rv.status_code, 200) self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (120, 120)) + self.__assert_image_data(rv, "JPEG", 120) # rerequest, just in case with closing( @@ -180,9 +179,7 @@ def test_get_cover_art(self): ) as rv: self.assertEqual(rv.status_code, 200) self.assertEqual(rv.mimetype, "image/jpeg") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "JPEG") - self.assertEqual(im.size, (120, 120)) + self.__assert_image_data(rv, "JPEG", 120) # TODO test non square covers @@ -193,9 +190,7 @@ def test_get_cover_art(self): ) as rv: self.assertEqual(rv.status_code, 200) self.assertEqual(rv.mimetype, "image/png") - im = Image.open(BytesIO(rv.data)) - self.assertEqual(im.format, "PNG") - self.assertEqual(im.size, (120, 120)) + self.__assert_image_data(rv, "PNG", 120) def test_get_avatar(self): self._make_request("getAvatar", error=0) From 8cef6282191696e1df480016b8247c654cee9826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Wed, 25 Nov 2020 21:24:59 +0100 Subject: [PATCH 027/237] Advertising (and testing) Python 3.9 support --- .github/workflows/tests.yaml | 1 + README.md | 2 +- setup.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d3e58e94..977c37f9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -15,6 +15,7 @@ jobs: - 3.6 - 3.7 - 3.8 + - 3.9 fail-fast: false steps: - name: Checkout diff --git a/README.md b/README.md index f11d7650..4b6dd370 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ _Supysonic_ is a Python implementation of the [Subsonic][] server API. ![Build Status](https://github.com/spl0k/supysonic/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) -![Python](https://img.shields.io/badge/python-3.5%2C%203.6%2C%203.7%2C%203.8-blue.svg) +![Python](https://img.shields.io/badge/python-3.5--3.9-blue.svg) Current supported features are: * browsing (by folders or tags) diff --git a/setup.py b/setup.py index 4b94f9a7..d5f4363e 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,7 @@ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Topic :: Multimedia :: Sound/Audio", ], ) From 6c89accc86610ea55ef600e5f478dadcb97f2d65 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 16 Nov 2020 14:32:58 +0100 Subject: [PATCH 028/237] scan Api implementation --- supysonic/api/__init__.py | 1 + supysonic/api/exceptions.py | 7 +++++++ supysonic/api/scan.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 supysonic/api/scan.py diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 175e69ee..880ef65f 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -116,3 +116,4 @@ def get_entity_id(cls, eid): from .jukebox import * from .radio import * from .unsupported import * +from .scan import * \ No newline at end of file diff --git a/supysonic/api/exceptions.py b/supysonic/api/exceptions.py index 6a1a80e2..0354f83a 100644 --- a/supysonic/api/exceptions.py +++ b/supysonic/api/exceptions.py @@ -122,3 +122,10 @@ def get_response(self, environ=None): ) # rv.status_code = self.code return rv + +class DaemonUnavailable(SubsonicAPIException): + code = 404 + api_code = 80 + message = ( + "Supysonic Daemon not running on this server." + ) \ No newline at end of file diff --git a/supysonic/api/scan.py b/supysonic/api/scan.py new file mode 100644 index 00000000..55eb585f --- /dev/null +++ b/supysonic/api/scan.py @@ -0,0 +1,33 @@ + +from . import api +from functools import wraps +from flask import request +from flask import current_app +from .user import admin_only +from .exceptions import Forbidden,DaemonUnavailable,ServerError +from ..db import Folder +from ..daemon.client import DaemonClient +from ..daemon.exceptions import DaemonUnavailableError +from ..managers.folder import FolderManager + +@api.route("/startScan.view", methods=["GET", "POST"]) +@admin_only +def startScan(): + try: + DaemonClient(current_app.config["DAEMON"]["socket"]).scan() + except ValueError as e: + ServerError(str(e)) + except DaemonUnavailableError: + raise DaemonUnavailable() + return getScanStatus() + +@api.route("/getScanStatus.view", methods=["GET", "POST"]) +@admin_only +def getScanStatus(): + try: + scanned=DaemonClient(current_app.config["DAEMON"]["socket"]).get_scanning_progress() + except DaemonUnavailableError: + raise DaemonUnavailable() + return request.formatter("scanStatus", + dict(scanning='true' if scanned is not None else 'false', + count= scanned if scanned is not None else 0)) \ No newline at end of file From 7fa30db64709cfd69d12f0ebdde4e7b15f931328 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 16 Nov 2020 14:33:20 +0100 Subject: [PATCH 029/237] add .vscode to gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 73cce212..019d2cac 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,4 @@ target/ *~ *.orig - +.vscode From 479d4ec6548a25875a7c4ee758f4927f6d98c1a4 Mon Sep 17 00:00:00 2001 From: vincent Date: Sun, 22 Nov 2020 09:16:07 +0100 Subject: [PATCH 030/237] correct exeption and double daeomon use --- supysonic/api/__init__.py | 2 +- supysonic/api/exceptions.py | 7 ------ supysonic/api/scan.py | 45 ++++++++++++++++++++++--------------- 3 files changed, 28 insertions(+), 26 deletions(-) mode change 100644 => 100755 supysonic/api/exceptions.py mode change 100644 => 100755 supysonic/api/scan.py diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 880ef65f..ccee972e 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -116,4 +116,4 @@ def get_entity_id(cls, eid): from .jukebox import * from .radio import * from .unsupported import * -from .scan import * \ No newline at end of file +from .scan import * diff --git a/supysonic/api/exceptions.py b/supysonic/api/exceptions.py old mode 100644 new mode 100755 index 0354f83a..6a1a80e2 --- a/supysonic/api/exceptions.py +++ b/supysonic/api/exceptions.py @@ -122,10 +122,3 @@ def get_response(self, environ=None): ) # rv.status_code = self.code return rv - -class DaemonUnavailable(SubsonicAPIException): - code = 404 - api_code = 80 - message = ( - "Supysonic Daemon not running on this server." - ) \ No newline at end of file diff --git a/supysonic/api/scan.py b/supysonic/api/scan.py old mode 100644 new mode 100755 index 55eb585f..30a21918 --- a/supysonic/api/scan.py +++ b/supysonic/api/scan.py @@ -1,33 +1,42 @@ - from . import api -from functools import wraps from flask import request from flask import current_app from .user import admin_only -from .exceptions import Forbidden,DaemonUnavailable,ServerError -from ..db import Folder +from .exceptions import ServerError from ..daemon.client import DaemonClient -from ..daemon.exceptions import DaemonUnavailableError -from ..managers.folder import FolderManager + @api.route("/startScan.view", methods=["GET", "POST"]) @admin_only def startScan(): try: - DaemonClient(current_app.config["DAEMON"]["socket"]).scan() - except ValueError as e: - ServerError(str(e)) - except DaemonUnavailableError: - raise DaemonUnavailable() - return getScanStatus() + daeomonclient = DaemonClient(current_app.config["DAEMON"]["socket"]) + daeomonclient.scan() + scanned = daeomonclient.get_scanning_progress() + except Exception as e: + raise ServerError(str(e)) + return request.formatter( + "scanStatus", + dict( + scanning="true" if scanned is not None else "false", + count=scanned if scanned is not None else 0, + ), + ) + @api.route("/getScanStatus.view", methods=["GET", "POST"]) @admin_only def getScanStatus(): try: - scanned=DaemonClient(current_app.config["DAEMON"]["socket"]).get_scanning_progress() - except DaemonUnavailableError: - raise DaemonUnavailable() - return request.formatter("scanStatus", - dict(scanning='true' if scanned is not None else 'false', - count= scanned if scanned is not None else 0)) \ No newline at end of file + scanned = DaemonClient( + current_app.config["DAEMON"]["socket"] + ).get_scanning_progress() + except Exception as e: + raise ServerError(str(e)) + return request.formatter( + "scanStatus", + dict( + scanning="true" if scanned is not None else "false", + count=scanned if scanned is not None else 0, + ), + ) From 13b7c4b3dee1849cbc51130a7c92826a80dff7e0 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 24 Nov 2020 10:55:52 +0100 Subject: [PATCH 031/237] add version parameter to ApiTestBase class --- tests/api/apitestbase.py | 10 +- tests/assets/subsonic-rest-api-1.16.0.xsd | 638 ++++++++++++++++++++++ 2 files changed, 644 insertions(+), 4 deletions(-) mode change 100644 => 100755 tests/api/apitestbase.py create mode 100644 tests/assets/subsonic-rest-api-1.16.0.xsd diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py old mode 100644 new mode 100755 index bad1c685..46ad9065 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -21,10 +21,12 @@ class ApiTestBase(TestBase): __with_api__ = True - def setUp(self): + def setUp(self, apiVersion="1.10.2"): super().setUp() - - xsd = etree.parse("tests/assets/subsonic-rest-api-1.10.2.xsd") + self.apiVersion = apiVersion + xsd = etree.parse( + "tests/assets/subsonic-rest-api-{}.xsd".format(self.apiVersion) + ) self.schema = etree.XMLSchema(xsd) def _find(self, xml, path): @@ -65,7 +67,7 @@ def _make_request(self, endpoint, args={}, tag=None, error=None, skip_post=False if tag and not isinstance(tag, str): raise TypeError("'tag', expecting a str, got " + type(tag).__name__) - args.update({"c": "tests", "v": "1.9.0"}) + args.update({"c": "tests", "v": self.apiVersion}) if "u" not in args: args.update({"u": "alice", "p": "Alic3"}) diff --git a/tests/assets/subsonic-rest-api-1.16.0.xsd b/tests/assets/subsonic-rest-api-1.16.0.xsd new file mode 100644 index 00000000..590ff899 --- /dev/null +++ b/tests/assets/subsonic-rest-api-1.16.0.xsd @@ -0,0 +1,638 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From d08db741bc4c5705d0d20a2201a7aa68b5263a04 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 24 Nov 2020 13:44:36 +0100 Subject: [PATCH 032/237] add test for api scan endpoint --- tests/api/test_scan.py | 53 ++++++++++++++++++++++++++++++++++++++++++ tests/testbase.py | 10 ++++---- 2 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 tests/api/test_scan.py diff --git a/tests/api/test_scan.py b/tests/api/test_scan.py new file mode 100644 index 00000000..9e330710 --- /dev/null +++ b/tests/api/test_scan.py @@ -0,0 +1,53 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from pony.orm import db_session + +from supysonic.db import Folder + +from .apitestbase import ApiTestBase + +from supysonic.daemon.server import Daemon +from threading import Thread +import logging + +logger = logging.getLogger() + + +class DaemonThread(Thread): + def __init__(self, daemon): + super(DaemonThread, self).__init__(target=daemon.run) + self.daemon = True + self.start() + + +class ScanTestCase(ApiTestBase): + def setUp(self): + super(ScanTestCase, self).setUp(apiVersion="1.16.0") + + with db_session: + Folder(name="Root", root=True, path="tests/assets") + + def test_startScan(self): + self._make_request("startScan", error=0) + daemon = Daemon(self.config) + with db_session: + daemonThread = DaemonThread(daemon) + rv, child = self._make_request("startScan", tag="scanStatus") + self.assertTrue(child.get("scanning")) + self.assertGreaterEqual(int(child.get("count")), 0) + daemon.terminate() + + def test_getScanStatus(self): + self._make_request("getScanStatus", error=0) + daemon = Daemon(self.config) + with db_session: + daemonThread = DaemonThread(daemon) + rv, child = self._make_request("getScanStatus", tag="scanStatus") + self.assertIn(child.get("scanning"), ["true", "false"]) + self.assertGreaterEqual(int(child.get("count")), 0) + daemon.terminate() diff --git a/tests/testbase.py b/tests/testbase.py index 3b78d9df..e22eb4df 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -83,14 +83,14 @@ class TestBase(unittest.TestCase): def setUp(self): self.__db = tempfile.mkstemp() self.__dir = tempfile.mkdtemp() - config = TestConfig(self.__with_webui__, self.__with_api__) - config.BASE["database_uri"] = "sqlite:///" + self.__db[1] - config.WEBAPP["cache_dir"] = self.__dir + self.config = TestConfig(self.__with_webui__, self.__with_api__) + self.config.BASE["database_uri"] = "sqlite:///" + self.__db[1] + self.config.WEBAPP["cache_dir"] = self.__dir - init_database(config.BASE["database_uri"]) + init_database(self.config.BASE["database_uri"]) release_database() - self.__app = create_application(config) + self.__app = create_application(self.config) self.client = self.__app.test_client() with db_session: From 6bb3cd71cfa910437011482334145b1c15b2c0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 28 Nov 2020 12:25:43 +0100 Subject: [PATCH 033/237] chmod -x --- supysonic/api/exceptions.py | 0 supysonic/api/scan.py | 0 tests/api/apitestbase.py | 0 tests/assets/formats/silence.m4a | Bin 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 supysonic/api/exceptions.py mode change 100755 => 100644 supysonic/api/scan.py mode change 100755 => 100644 tests/api/apitestbase.py mode change 100755 => 100644 tests/assets/formats/silence.m4a diff --git a/supysonic/api/exceptions.py b/supysonic/api/exceptions.py old mode 100755 new mode 100644 diff --git a/supysonic/api/scan.py b/supysonic/api/scan.py old mode 100755 new mode 100644 diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py old mode 100755 new mode 100644 diff --git a/tests/assets/formats/silence.m4a b/tests/assets/formats/silence.m4a old mode 100755 new mode 100644 From 36cea89b26a27717d9792fac623a567825dea56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 28 Nov 2020 15:15:24 +0100 Subject: [PATCH 034/237] Improving scan tests --- supysonic/api/scan.py | 25 ++++++++++++++------ tests/api/test_scan.py | 52 ++++++++++++++++++++++-------------------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/supysonic/api/scan.py b/supysonic/api/scan.py index 30a21918..7537269e 100644 --- a/supysonic/api/scan.py +++ b/supysonic/api/scan.py @@ -1,19 +1,30 @@ -from . import api +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2020 Alban 'spl0k' Féron +# 2020 Vincent Ducamps +# +# Distributed under terms of the GNU AGPLv3 license. + from flask import request from flask import current_app + +from ..daemon.client import DaemonClient +from ..daemon.exceptions import DaemonUnavailableError + +from . import api from .user import admin_only from .exceptions import ServerError -from ..daemon.client import DaemonClient @api.route("/startScan.view", methods=["GET", "POST"]) @admin_only def startScan(): try: - daeomonclient = DaemonClient(current_app.config["DAEMON"]["socket"]) - daeomonclient.scan() - scanned = daeomonclient.get_scanning_progress() - except Exception as e: + daemonclient = DaemonClient(current_app.config["DAEMON"]["socket"]) + daemonclient.scan() + scanned = daemonclient.get_scanning_progress() + except DaemonUnavailableError as e: raise ServerError(str(e)) return request.formatter( "scanStatus", @@ -31,7 +42,7 @@ def getScanStatus(): scanned = DaemonClient( current_app.config["DAEMON"]["socket"] ).get_scanning_progress() - except Exception as e: + except DaemonUnavailableError as e: raise ServerError(str(e)) return request.formatter( "scanStatus", diff --git a/tests/api/test_scan.py b/tests/api/test_scan.py index 9e330710..84e3167b 100644 --- a/tests/api/test_scan.py +++ b/tests/api/test_scan.py @@ -1,53 +1,55 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. from pony.orm import db_session +from threading import Thread +from supysonic.daemon.server import Daemon from supysonic.db import Folder from .apitestbase import ApiTestBase -from supysonic.daemon.server import Daemon -from threading import Thread -import logging -logger = logging.getLogger() +class ScanTestCase(ApiTestBase): + def setUp(self): + super().setUp(apiVersion="1.16.0") + def test_unauthorized(self): + self._make_request("startScan", args={"u": "bob", "p": "B0b"}, error=50) + self._make_request("getScanStatus", args={"u": "bob", "p": "B0b"}, error=50) -class DaemonThread(Thread): - def __init__(self, daemon): - super(DaemonThread, self).__init__(target=daemon.run) - self.daemon = True - self.start() + def test_unavailable(self): + self._make_request("startScan", error=0) + self._make_request("getScanStatus", error=0) -class ScanTestCase(ApiTestBase): +class ScanWithDaemonTestCase(ApiTestBase): def setUp(self): - super(ScanTestCase, self).setUp(apiVersion="1.16.0") + super().setUp(apiVersion="1.16.0") with db_session: Folder(name="Root", root=True, path="tests/assets") + self._daemon = Daemon(self.config) + self._thread = Thread(target=self._daemon.run) + self._thread.start() + + def tearDown(self): + self._daemon.terminate() + self._thread.join() + + super().tearDown() + def test_startScan(self): - self._make_request("startScan", error=0) - daemon = Daemon(self.config) - with db_session: - daemonThread = DaemonThread(daemon) rv, child = self._make_request("startScan", tag="scanStatus") - self.assertTrue(child.get("scanning")) + self.assertEqual(child.get("scanning"), "true") self.assertGreaterEqual(int(child.get("count")), 0) - daemon.terminate() def test_getScanStatus(self): - self._make_request("getScanStatus", error=0) - daemon = Daemon(self.config) - with db_session: - daemonThread = DaemonThread(daemon) rv, child = self._make_request("getScanStatus", tag="scanStatus") - self.assertIn(child.get("scanning"), ["true", "false"]) - self.assertGreaterEqual(int(child.get("count")), 0) - daemon.terminate() + self.assertEqual(child.get("scanning"), "false") + self.assertEqual(int(child.get("count")), 0) From f8018b2751c0bd8a0eb4211904d8eb785a5b640c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 29 Nov 2020 17:24:28 +0100 Subject: [PATCH 035/237] Some housekeeping Trying to make flake8 happy --- supysonic/api/__init__.py | 3 +- supysonic/api/albums_songs.py | 2 -- supysonic/api/annotation.py | 28 ++++++++----------- supysonic/api/browse.py | 1 - supysonic/api/chat.py | 2 +- supysonic/api/errors.py | 3 +- supysonic/api/media.py | 10 ++----- supysonic/api/radio.py | 2 +- supysonic/api/user.py | 2 +- supysonic/db.py | 2 +- supysonic/frontend/__init__.py | 10 ++++++- supysonic/frontend/user.py | 2 +- supysonic/managers/folder.py | 1 - supysonic/scanner.py | 8 ++---- supysonic/schema/migration/mysql/20171230.py | 2 +- .../schema/migration/postgres/20180317.py | 1 - supysonic/schema/migration/sqlite/20171230.py | 4 +-- supysonic/schema/migration/sqlite/20180317.py | 1 - supysonic/web.py | 1 - tests/api/apitestbase.py | 1 - tests/api/test_album_songs.py | 2 +- tests/api/test_annotation.py | 1 + tests/api/test_api_setup.py | 1 + tests/api/test_browse.py | 4 +-- tests/api/test_lyrics.py | 3 +- tests/api/test_media.py | 1 + tests/api/test_playlist.py | 1 + tests/api/test_response_helper.py | 24 ++++++++-------- tests/api/test_search.py | 2 +- tests/api/test_system.py | 2 ++ tests/api/test_transcoding.py | 4 +-- tests/api/test_user.py | 2 ++ tests/base/test_db.py | 14 +++++----- tests/base/test_scanner.py | 7 ----- tests/base/test_watcher.py | 4 +-- tests/frontend/test_folder.py | 2 +- tests/frontend/test_login.py | 1 + tests/frontend/test_playlist.py | 1 + tests/frontend/test_user.py | 1 + tests/issue101.py | 1 - tests/issue129.py | 2 +- tests/issue133.py | 2 +- tests/issue139.py | 1 - tests/issue148.py | 1 - tests/managers/test_manager_folder.py | 5 ++-- tests/managers/test_manager_user.py | 1 - 46 files changed, 83 insertions(+), 93 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index ccee972e..7662166f 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -15,9 +15,10 @@ from pony.orm import ObjectNotFound from pony.orm import commit +from ..db import ClientPrefs, Folder from ..managers.user import UserManager -from .exceptions import Unauthorized +from .exceptions import GenericError, Unauthorized from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter api = Blueprint("api", __name__) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 822d249a..469d60ff 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -11,10 +11,8 @@ from ..db import ( Folder, - Artist, Album, Track, - RatingFolder, StarredFolder, StarredArtist, StarredAlbum, diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index dae6fd88..3d44876d 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -5,15 +5,13 @@ # # Distributed under terms of the GNU AGPLv3 license. -import sys import time -import uuid from flask import current_app, request from pony.orm import delete from pony.orm import ObjectNotFound -from ..db import Track, Album, Artist, Folder, User +from ..db import Track, Album, Artist, Folder from ..db import StarredTrack, StarredAlbum, StarredArtist, StarredFolder from ..db import RatingTrack, RatingFolder from ..lastfm import LastFm @@ -22,10 +20,11 @@ from .exceptions import AggregateException, GenericError, MissingParameter, NotFound -def star_single(cls, eid): +def star_single(cls, starcls, eid): """Stars an entity :param cls: entity class, Folder, Artist, Album or Track + :param starcls: matching starred class, StarredFolder, StarredArtist, StarredAlbum or StarredTrack :param eid: id of the entity to star """ @@ -34,27 +33,24 @@ def star_single(cls, eid): except ObjectNotFound: raise NotFound("{} {}".format(cls.__name__, eid)) - starred_cls = getattr(sys.modules[__name__], "Starred" + cls.__name__) try: - starred_cls[request.user, eid] + starcls[request.user, eid] raise GenericError("{} {} already starred".format(cls.__name__, eid)) except ObjectNotFound: pass - starred_cls(user=request.user, starred=e) + starcls(user=request.user, starred=e) -def unstar_single(cls, eid): +def unstar_single(cls, starcls, eid): """Unstars an entity :param cls: entity class, Folder, Artist, Album or Track + :param starcls: matching starred class, StarredFolder, StarredArtist, StarredAlbum or StarredTrack :param eid: id of the entity to unstar """ - starred_cls = getattr(sys.modules[__name__], "Starred" + cls.__name__) - delete( - s for s in starred_cls if s.user.id == request.user.id and s.starred.id == eid - ) + delete(s for s in starcls if s.user.id == request.user.id and s.starred.id == eid) return None @@ -81,12 +77,12 @@ def handle_star_request(func): if tid is not None: try: - func(Track, tid) + func(Track, StarredTrack, tid) except Exception as e: err = e else: try: - func(Folder, fid) + func(Folder, StarredFolder, fid) except Exception as e: err = e @@ -96,14 +92,14 @@ def handle_star_request(func): for alId in albumId: alb_id = get_entity_id(Album, alId) try: - func(Album, alb_id) + func(Album, StarredAlbum, alb_id) except Exception as e: errors.append(e) for arId in artistId: art_id = get_entity_id(Artist, arId) try: - func(Artist, art_id) + func(Artist, StarredArtist, art_id) except Exception as e: errors.append(e) diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index bb47abfd..728ea9b3 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -7,7 +7,6 @@ import re import string -import uuid from flask import current_app, request from pony.orm import ObjectNotFound, select, count diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index f5faa7ad..486ed8cc 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -7,7 +7,7 @@ from flask import request -from ..db import ChatMessage, User +from ..db import ChatMessage from . import api diff --git a/supysonic/api/errors.py b/supysonic/api/errors.py index 7842524d..7050b9e4 100644 --- a/supysonic/api/errors.py +++ b/supysonic/api/errors.py @@ -5,7 +5,6 @@ # # Distributed under terms of the GNU AGPLv3 license. -from flask import current_app from pony.orm import rollback from pony.orm import ObjectNotFound from werkzeug.exceptions import BadRequestKeyError @@ -27,7 +26,7 @@ def key_error(e): @api.errorhandler(ObjectNotFound) -def not_found(e): +def object_not_found(e): rollback() return NotFound(e.entity.__name__) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index dc9cc413..3271d315 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -7,7 +7,6 @@ # Distributed under terms of the GNU AGPLv3 license. import hashlib -import io import json import logging import mediafile @@ -16,7 +15,6 @@ import requests import shlex import subprocess -import uuid import zlib from flask import request, Response, send_file @@ -27,14 +25,12 @@ from zipfile import ZIP_DEFLATED from zipstream import ZipFile -from .. import scanner from ..cache import CacheMiss -from ..db import Track, Album, Artist, Folder, User, ClientPrefs, now +from ..db import Track, Album, Folder, now from . import api, get_entity, get_entity_id from .exceptions import ( GenericError, - MissingParameter, NotFound, ServerError, UnsupportedParameter, @@ -168,7 +164,7 @@ def transcode(): yield data def kill_processes(): - if dec_proc != None: + if dec_proc is not None: dec_proc.kill() proc.kill() @@ -194,7 +190,7 @@ def handle_transcoding(): kill_processes() raise finally: - if dec_proc != None: + if dec_proc is not None: dec_proc.stdout.close() dec_proc.wait() proc.stdout.close() diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index e035d759..4bb58d85 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -10,7 +10,7 @@ from ..db import RadioStation from . import api, get_entity -from .exceptions import Forbidden, MissingParameter, NotFound +from .exceptions import Forbidden, MissingParameter @api.route("/getInternetRadioStations.view", methods=["GET", "POST"]) diff --git a/supysonic/api/user.py b/supysonic/api/user.py index 9c744a9c..19546d1b 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -12,7 +12,7 @@ from ..managers.user import UserManager from . import api, decode_password -from .exceptions import Forbidden, GenericError, NotFound +from .exceptions import Forbidden, NotFound def admin_only(f): diff --git a/supysonic/db.py b/supysonic/db.py index 2f817974..2fa3fa07 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -16,7 +16,7 @@ from pony.orm import Database, Required, Optional, Set, PrimaryKey, LongStr from pony.orm import ObjectNotFound, DatabaseError from pony.orm import buffer -from pony.orm import min, max, avg, sum, count, exists +from pony.orm import min, avg, sum, count, exists from pony.orm import db_session from urllib.parse import urlparse, parse_qsl from uuid import UUID, uuid4 diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index 1378c4cc..27ad49c0 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -6,7 +6,15 @@ # # Distributed under terms of the GNU AGPLv3 license. -from flask import current_app, redirect, request, session, url_for +from flask import ( + current_app, + flash, + redirect, + request, + render_template, + session, + url_for, +) from flask import Blueprint from functools import wraps from pony.orm import ObjectNotFound diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index 8e57a753..f2150283 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -12,7 +12,7 @@ from functools import wraps from pony.orm import ObjectNotFound -from ..db import User, ClientPrefs +from ..db import User from ..lastfm import LastFm from ..managers.user import UserManager diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index 84573db0..2d6930f5 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -6,7 +6,6 @@ # Distributed under terms of the GNU AGPLv3 license. import os.path -import uuid from pony.orm import select from pony.orm import ObjectNotFound diff --git a/supysonic/scanner.py b/supysonic/scanner.py index f56ff885..b9ad35a0 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -6,7 +6,8 @@ # Distributed under terms of the GNU AGPLv3 license. import logging -import os, os.path +import os +import os.path import mediafile import time @@ -16,9 +17,7 @@ from threading import Thread, Event from .covers import find_cover_in_folder, CoverFile -from .db import Folder, Artist, Album, Track, User -from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack -from .db import RatingFolder, RatingTrack +from .db import Folder, Artist, Album, Track logger = logging.getLogger(__name__) @@ -212,7 +211,6 @@ def scan_file(self, path_or_direntry): return mtime = int(stat.st_mtime) - size = stat.st_size tr = Track.get(path=path) if tr is not None: diff --git a/supysonic/schema/migration/mysql/20171230.py b/supysonic/schema/migration/mysql/20171230.py index aece82b5..39e8c246 100644 --- a/supysonic/schema/migration/mysql/20171230.py +++ b/supysonic/schema/migration/mysql/20171230.py @@ -32,7 +32,7 @@ def process_table(connection, table, fields, nullable_fields=()): c.execute("SELECT {1} FROM {0}".format(table, ",".join(fields + nullable_fields))) for row in c: for field, value in zip(fields + nullable_fields, row): - if value is None or not isinstance(value, basestring): + if value is None or not isinstance(value, str): continue to_update[field].add(value) diff --git a/supysonic/schema/migration/postgres/20180317.py b/supysonic/schema/migration/postgres/20180317.py index 5f9216fe..5719f771 100644 --- a/supysonic/schema/migration/postgres/20180317.py +++ b/supysonic/schema/migration/postgres/20180317.py @@ -1,7 +1,6 @@ import argparse import hashlib import psycopg2 -import uuid try: bytes = buffer diff --git a/supysonic/schema/migration/sqlite/20171230.py b/supysonic/schema/migration/sqlite/20171230.py index 6f48054f..0251e6c0 100644 --- a/supysonic/schema/migration/sqlite/20171230.py +++ b/supysonic/schema/migration/sqlite/20171230.py @@ -23,13 +23,13 @@ def process_table(connection, table, fields): c = connection.cursor() for row in c.execute("SELECT {1} FROM {0}".format(table, ",".join(fields))): for field, value in zip(fields, row): - if value is None or not isinstance(value, basestring): + if value is None or not isinstance(value, str): continue to_update[field].add(value) for field, values in to_update.iteritems(): sql = "UPDATE {0} SET {1}=? WHERE {1}=?".format(table, field) - c.executemany(sql, map(lambda v: (buffer(UUID(v).bytes), v), values)) + c.executemany(sql, map(lambda v: (UUID(v).bytes, v), values)) connection.commit() diff --git a/supysonic/schema/migration/sqlite/20180317.py b/supysonic/schema/migration/sqlite/20180317.py index 5888cc97..485c387a 100644 --- a/supysonic/schema/migration/sqlite/20180317.py +++ b/supysonic/schema/migration/sqlite/20180317.py @@ -1,7 +1,6 @@ import argparse import hashlib import sqlite3 -import uuid try: bytes = buffer diff --git a/supysonic/web.py b/supysonic/web.py index 2d051490..9a128c96 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -7,7 +7,6 @@ # # Distributed under terms of the GNU AGPLv3 license. -import io import logging import mimetypes diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index 46ad9065..6f9ab505 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -8,7 +8,6 @@ import re from lxml import etree -from supysonic.managers.user import UserManager from ..testbase import TestBase diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index a94fec88..c5da99b9 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -5,7 +5,7 @@ # # Distributed under terms of the GNU AGPLv3 license. -import uuid +import unittest from pony.orm import db_session diff --git a/tests/api/test_annotation.py b/tests/api/test_annotation.py index 90e74812..07b96e7b 100644 --- a/tests/api/test_annotation.py +++ b/tests/api/test_annotation.py @@ -5,6 +5,7 @@ # # Distributed under terms of the GNU AGPLv3 license. +import unittest import uuid from pony.orm import db_session diff --git a/tests/api/test_api_setup.py b/tests/api/test_api_setup.py index 78ba4e1e..a7b8577e 100644 --- a/tests/api/test_api_setup.py +++ b/tests/api/test_api_setup.py @@ -8,6 +8,7 @@ import base64 import flask.json +import unittest from xml.etree import ElementTree diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index b7021e3c..c976f6fb 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -6,9 +6,9 @@ # Distributed under terms of the GNU AGPLv3 license. import time +import unittest import uuid -from lxml import etree from pony.orm import db_session from supysonic.db import Folder, Artist, Album, Track @@ -43,7 +43,7 @@ def setUp(self): album = Album(name=letter + lether + "lbum", artist=artist) for num, song in enumerate(["One", "Two", "Three"]): - track = Track( + Track( disc=1, number=num, title=song, diff --git a/tests/api/test_lyrics.py b/tests/api/test_lyrics.py index a66829c6..abbd0e33 100644 --- a/tests/api/test_lyrics.py +++ b/tests/api/test_lyrics.py @@ -8,6 +8,7 @@ import flask.json import os.path import requests +import unittest from pony.orm import db_session @@ -32,7 +33,7 @@ def setUp(self): artist = Artist(name="Artist") album = Album(artist=artist, name="Album") - track = Track( + Track( title="23bytes", number=1, disc=1, diff --git a/tests/api/test_media.py b/tests/api/test_media.py index 819f935e..6c16ef40 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -6,6 +6,7 @@ # Distributed under terms of the GNU AGPLv3 license. import os.path +import unittest import uuid from contextlib import closing diff --git a/tests/api/test_playlist.py b/tests/api/test_playlist.py index 3eee60ac..c040ef9a 100644 --- a/tests/api/test_playlist.py +++ b/tests/api/test_playlist.py @@ -5,6 +5,7 @@ # # Distributed under terms of the GNU AGPLv3 license. +import unittest import uuid from pony.orm import db_session diff --git a/tests/api/test_response_helper.py b/tests/api/test_response_helper.py index ce59c51f..78b0b548 100644 --- a/tests/api/test_response_helper.py +++ b/tests/api/test_response_helper.py @@ -87,18 +87,18 @@ def test_nesting(self): self.assertIn("dict", resp) self.assertIn("list", resp) - d = resp["dict"] - l = resp["list"] - - self.assertIn("value", d) - self.assertIn("list", d) - self.assertNotIn("emptyList", d) - self.assertIn("subdict", d) - self.assertIsInstance(d["value"], str) - self.assertIsInstance(d["list"], list) - self.assertIsInstance(d["subdict"], dict) - - self.assertEqual(l, [{"b": "B"}, {"c": "C"}, [4, 5, 6], "final string"]) + dct = resp["dict"] + lst = resp["list"] + + self.assertIn("value", dct) + self.assertIn("list", dct) + self.assertNotIn("emptyList", dct) + self.assertIn("subdict", dct) + self.assertIsInstance(dct["value"], str) + self.assertIsInstance(dct["list"], list) + self.assertIsInstance(dct["subdict"], dict) + + self.assertEqual(lst, [{"b": "B"}, {"c": "C"}, [4, 5, 6], "final string"]) class ResponseHelperJsonpTestCase(TestBase, UnwrapperMixin.create_from(JSONPFormatter)): diff --git a/tests/api/test_search.py b/tests/api/test_search.py index 316fab39..77bece28 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -40,7 +40,7 @@ def setUp(self): album = Album(name=letter + lether + "lbum", artist=artist) for num, song in enumerate(["One", "Two", "Three"]): - track = Track( + Track( disc=1, number=num, title=song, diff --git a/tests/api/test_system.py b/tests/api/test_system.py index 7281cdcc..4a8bfb5d 100644 --- a/tests/api/test_system.py +++ b/tests/api/test_system.py @@ -6,6 +6,8 @@ # # Distributed under terms of the GNU AGPLv3 license. +import unittest + from .apitestbase import ApiTestBase diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index f7690bc2..ec131fb4 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -11,7 +11,7 @@ from flask import current_app from pony.orm import db_session -from supysonic.db import Folder, Track +from supysonic.db import Track from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner @@ -23,7 +23,7 @@ def setUp(self): super().setUp() with db_session: - folder = FolderManager.add("Folder", "tests/assets/folder") + FolderManager.add("Folder", "tests/assets/folder") scanner = Scanner() scanner.queue_folder("Folder") scanner.run() diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 9c204814..e263cd0e 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -6,6 +6,8 @@ # # Distributed under terms of the GNU AGPLv3 license. +import unittest + from ..utils import hexlify from .apitestbase import ApiTestBase diff --git a/tests/base/test_db.py b/tests/base/test_db.py index ecb91f21..9fd5d099 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -32,7 +32,7 @@ def tearDown(self): def create_some_folders(self): root_folder = db.Folder(root=True, name="Root folder", path="tests") - child_folder = db.Folder( + db.Folder( root=False, name="Child folder", path="tests/assets", @@ -40,7 +40,7 @@ def create_some_folders(self): parent=root_folder, ) - child_2 = db.Folder( + db.Folder( root=False, name="Child folder (No Art)", path="tests/formats", @@ -158,10 +158,10 @@ def test_folder_annotation(self): root_folder, child_folder, _ = self.create_some_folders() user = self.create_user() - star = db.StarredFolder(user=user, starred=root_folder) - rating_user = db.RatingFolder(user=user, rated=root_folder, rating=2) + db.StarredFolder(user=user, starred=root_folder) + db.RatingFolder(user=user, rated=root_folder, rating=2) other = self.create_user("Other") - rating_other = db.RatingFolder(user=other, rated=root_folder, rating=5) + db.RatingFolder(user=other, rated=root_folder, rating=5) root = root_folder.as_subsonic_child(user) self.assertIn("starred", root) @@ -180,7 +180,7 @@ def test_artist(self): artist = db.Artist(name="Test Artist") user = self.create_user() - star = db.StarredArtist(user=user, starred=artist) + db.StarredArtist(user=user, starred=artist) artist_dict = artist.as_subsonic_artist(user) self.assertIsInstance(artist_dict, dict) @@ -204,7 +204,7 @@ def test_album(self): album = db.Album(artist=artist, name="Test Album") user = self.create_user() - star = db.StarredAlbum(user=user, starred=album) + db.StarredAlbum(user=user, starred=album) # No tracks, shouldn't be stored under normal circumstances self.assertRaises(ValueError, album.as_subsonic_album, user) diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index 393df1c9..feb3496c 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -5,7 +5,6 @@ # # Distributed under terms of the GNU AGPLv3 license. -import io import mutagen import os import os.path @@ -131,8 +130,6 @@ def test_move_file(self): @db_session def test_rescan_corrupt_file(self): - track = db.Track.select().first() - with self.__temporary_track_copy() as tf: self.__scan() self.assertEqual(db.Track.select().count(), 2) @@ -147,8 +144,6 @@ def test_rescan_corrupt_file(self): @db_session def test_rescan_removed_file(self): - track = db.Track.select().first() - with self.__temporary_track_copy(): self.__scan() self.assertEqual(db.Track.select().count(), 2) @@ -158,8 +153,6 @@ def test_rescan_removed_file(self): @db_session def test_scan_tag_change(self): - folder = db.Folder[self.folderid] - with self.__temporary_track_copy() as tf: self.__scan() copy = db.Track.get(path=tf) diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index 2bfa2c3a..7720d5fa 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -277,7 +277,7 @@ def test_remove_cover(self): self.assertIsNone(Folder.select().first().cover_art) def test_naming_add_good(self): - bad = os.path.basename(self._addcover()) + self._addcover() self._sleep() good = os.path.basename(self._addcover("cover")) self._sleep() @@ -288,7 +288,7 @@ def test_naming_add_good(self): def test_naming_add_bad(self): good = os.path.basename(self._addcover("cover")) self._sleep() - bad = os.path.basename(self._addcover()) + self._addcover() self._sleep() with db_session: diff --git a/tests/frontend/test_folder.py b/tests/frontend/test_folder.py index 2af75147..ce6a96a3 100644 --- a/tests/frontend/test_folder.py +++ b/tests/frontend/test_folder.py @@ -5,7 +5,7 @@ # # Distributed under terms of the GNU AGPLv3 license. -import uuid +import unittest from pony.orm import db_session diff --git a/tests/frontend/test_login.py b/tests/frontend/test_login.py index 56915626..2a3853b4 100644 --- a/tests/frontend/test_login.py +++ b/tests/frontend/test_login.py @@ -6,6 +6,7 @@ # # Distributed under terms of the GNU AGPLv3 license. +import unittest import uuid from pony.orm import db_session diff --git a/tests/frontend/test_playlist.py b/tests/frontend/test_playlist.py index 6a1b12ca..da3d96a2 100644 --- a/tests/frontend/test_playlist.py +++ b/tests/frontend/test_playlist.py @@ -5,6 +5,7 @@ # # Distributed under terms of the GNU AGPLv3 license. +import unittest import uuid from pony.orm import db_session diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index 2d30a695..9b759f8e 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -5,6 +5,7 @@ # # Distributed under terms of the GNU AGPLv3 license. +import unittest import uuid from flask import escape diff --git a/tests/issue101.py b/tests/issue101.py index d7ce8ca3..0801a989 100644 --- a/tests/issue101.py +++ b/tests/issue101.py @@ -13,7 +13,6 @@ from pony.orm import db_session from supysonic.db import init_database, release_database -from supysonic.db import Folder from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner diff --git a/tests/issue129.py b/tests/issue129.py index 2db00a65..dbc6ace1 100644 --- a/tests/issue129.py +++ b/tests/issue129.py @@ -22,7 +22,7 @@ def setUp(self): super().setUp() with db_session: - folder = FolderManager.add("folder", os.path.abspath("tests/assets/folder")) + FolderManager.add("folder", os.path.abspath("tests/assets/folder")) scanner = Scanner() scanner.queue_folder("folder") scanner.run() diff --git a/tests/issue133.py b/tests/issue133.py index 7c776194..11ff762d 100644 --- a/tests/issue133.py +++ b/tests/issue133.py @@ -12,7 +12,7 @@ from pony.orm import db_session from supysonic.db import init_database, release_database -from supysonic.db import Folder, Track +from supysonic.db import Track from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner diff --git a/tests/issue139.py b/tests/issue139.py index 46fcead9..d9f2c66c 100644 --- a/tests/issue139.py +++ b/tests/issue139.py @@ -12,7 +12,6 @@ from pony.orm import db_session from supysonic.db import init_database, release_database -from supysonic.db import Folder, Track from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner diff --git a/tests/issue148.py b/tests/issue148.py index d6a3dcf8..f5eba7a8 100644 --- a/tests/issue148.py +++ b/tests/issue148.py @@ -14,7 +14,6 @@ from pony.orm import db_session from supysonic.db import init_database, release_database -from supysonic.db import Folder from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner diff --git a/tests/managers/test_manager_folder.py b/tests/managers/test_manager_folder.py index 43975577..b9e86edd 100644 --- a/tests/managers/test_manager_folder.py +++ b/tests/managers/test_manager_folder.py @@ -13,7 +13,6 @@ import shutil import tempfile import unittest -import uuid from pony.orm import db_session, ObjectNotFound @@ -37,7 +36,7 @@ def create_folders(self): self.assertIsNotNone(FolderManager.add("media", self.media_dir)) self.assertIsNotNone(FolderManager.add("music", self.music_dir)) - folder = db.Folder( + db.Folder( root=False, name="non-root", path=os.path.join(self.music_dir, "subfolder") ) @@ -45,7 +44,7 @@ def create_folders(self): album = db.Album(name="Album", artist=artist) root = db.Folder.get(name="media") - track = db.Track( + db.Track( title="Track", artist=artist, album=album, diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index 798ca2a1..3b5d1fa6 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -9,7 +9,6 @@ from supysonic import db from supysonic.managers.user import UserManager -import io import unittest import uuid From 95a275837d7ac449a66cb419d01f847872c6487c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 13 Dec 2020 17:08:39 +0100 Subject: [PATCH 036/237] Update API doc about scan --- docs/api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index 810e481c..64127306 100644 --- a/docs/api.md +++ b/docs/api.md @@ -103,8 +103,8 @@ or with version 1.8.0. | [`deleteBookmark`](#deletebookmark) | 1.9.0 | ❔ | | [`getPlayQueue`](#getplayqueue) | 1.12.0 | ❔ | | [`savePlayQueue`](#saveplayqueue) | 1.12.0 | ❔ | -| [`getScanStatus`](#getscanstatus) | 1.15.0 | 📅 | -| [`startScan`](#startscan) | 1.15.0 | 📅 | +| [`getScanStatus`](#getscanstatus) | 1.15.0 | ✔️ | +| [`startScan`](#startscan) | 1.15.0 | ✔️ | ### Global @@ -778,11 +778,11 @@ No parameter ### Library scanning #### `getScanStatus` -📅 1.15.0 +✔️ 1.15.0 No parameter #### `startScan` -📅 1.15.0 +✔️ 1.15.0 No parameter ## Changes by version From 0b67aeb070f9eb410f29d9b6d257d7447d244d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 13 Dec 2020 17:10:15 +0100 Subject: [PATCH 037/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index d9bb7bcf..ff1aa2cb 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "supysonic" -VERSION = "0.6.1" +VERSION = "0.6.2" DESCRIPTION = "Python implementation of the Subsonic server API." KEYWORDS = "subsonic music api" AUTHOR_NAME = "Alban Féron" From cfc324e346402a63e4e3bfa9f55a9950eabbc711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 29 Dec 2020 11:39:34 +0100 Subject: [PATCH 038/237] Rewriting docs as reStructuredText Starting with the configuration page The goal being to publish it outside of GitHub --- docs/configuration.md | 250 --------------------------------- docs/configuration.rst | 307 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 250 deletions(-) delete mode 100644 docs/configuration.md create mode 100644 docs/configuration.rst diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index ecdbe601..00000000 --- a/docs/configuration.md +++ /dev/null @@ -1,250 +0,0 @@ -# Configuration - -_Supysonic_ looks for four files for its configuration: `/etc/supysonic`, -`~/.supysonic`, `~/.config/supysonic/supysonic.conf` and `supysonic.conf` in -the current folder, merging values from all files. - -Configuration files must respect a structure similar to Windows INI file, with -`[section]` headers and using a `KEY = VALUE` or `KEY: VALUE` syntax. - -You'll find a roughly documented configuration sample file at the root of the -project, file conveniently named `config.sample`. More details below. - -There are six sections in the configuration: -- [base](#base-section): defines the database and additional scanning config -- [webapp](#webapp-section): configuration relative to the HTTP server -- [daemon](#daemon-section): configuration for the scanning file watcher -- [lastfm](#lastfm-section): keys to enable Last.FM scrobbling -- [transcoding](#transcoding-section): defines transcoding programs -- [mimetypes](#mimetypes-section): some file extension to mimetype mappings - -## `[base]` section - -`database_uri`: the most important configuration, defines the type and -parameters of the database _Supysonic_ should connect to. It usually includes -username, password, hostname and database name. The typical form of a database -URI is: - - driver://username:password@host:port/database - -If the connection needs some additional parameters, they can be provided as a -query string, such as: - - driver://username:password@host:port/database?param1=value1¶m2=value2 - -Supported drivers are `sqlite`, `mysql` and `postgres` (or `postgresql`). - -As SQLite connects to local files, the format is slightly different. The "file" -portion of the URI is the filename of the database. For a relative path, it -requires three slashes, for absolute paths it's also three slashes followed by -the absolute path, meaning actually four slashes on Unix systems. - -```ini -; Relative path -database_uri = sqlite:///relative-file.db -; Absolute path on Unix-based systems -database_uri = sqlite:////home/user/supysonic.db -; Absolute path on Windows -database_uri = sqlite:///C:\Users\user\supysonic.db -``` - -A MySQL-compatible database requires either `MySQLdb` or `pymysql` to be -installed. PostgreSQL needs `psycopg2`. -Note that for MySQL if no character set is defined on the URI it defaults to -`utf8mb4` regardless of what's set on your MySQL installation. - -If `database_uri` isn't provided, it defaults to a SQLite database stored in -`/tmp/supysonic/supysonic.db`. - -`scanner_extensions`: A space separated list of file extensions the scanner is -restricted to. Useful if you have multiple audio formats in your library but -only want to serve some. If left empty, the scanner will try to read every file -it finds. - -`follow_symlinks`: if set to `yes`, allows the scanner to follow symbolic links. -Disabled by default, enable it only if you trust your file system as nothing is -done to handle broken links or loops. - -```ini -[base] -; A database URI. See the 'schema' folder for schema creation scripts -; Default: sqlite:////tmp/supysonic/supysonic.db -database_uri = sqlite:////var/supysonic/supysonic.db -;database_uri = mysql://supysonic:supysonic@localhost/supysonic -;database_uri = postgres://supysonic:supysonic@localhost/supysonic - -; Optional, restrict scanner to these extensions. Default: none -scanner_extensions = mp3 ogg - -; Should the scanner follow symbolic links? Default: no -follow_symlinks = no -``` - -## `[webapp]` section - -`cache_dir`: directory used to store generated files, such as resized cover -art or transcoded files. Defaults to `/tmp/supysonic`. - -`cache_size`: maximum size (in megabytes) of the cache (except for trancodes). -Defaults to 512 MB - -`transcode_cache_size`: maximum size (in megabytes) of the transcode cache. -Defaults to 1024 MB (1 GB) - -`log_file`: rotating file where some events generated by the web server are -logged. Leave empty to disable logging. - -`log_level`: defines the minimum severity threshold of messages to be added to -`log_file`. Possible values are: `DEBUG`, `INFO`, `WARNING`, `ERROR` and -`CRITICAL`. Defaults to `WARNING`. - -`mount_api`: [`on`/`off`] enable or disable the Subsonic REST API. Should be -kept on or _Supysonic_ would be quite useless. Exists mostly for testing -purposes. Defaults to `on`. - -`mount_webui`: [`on`/`off`] enable or disable the administrative web interface. -Note that setting this off will prevent users from defining a preferred -transcoding format. Defaults to `on`. - -`index_ignored_prefixes`: space separated list of prefixes that should be -ignored from artist names when returning their index. Example: if the word _The_ -is in this list, artist _The Rolling Stones_ will be listed under the letter _R_. -The match is case insensitive. -Defaults to `El La Le Las Les Los The`. - -```ini -[webapp] -; Optional cache directory. Default: /tmp/supysonic -cache_dir = /var/supysonic/cache - -; Main cache max size in MB. Default: 512 -cache_size = 512 - -; Transcode cache max size in MB. Default: 1024 (1GB) -transcode_cache_size = 1024 - -; Optional rotating log file. Default: none -log_file = /var/supysonic/supysonic.log - -; Log level. Possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL. -; Default: WARNING -log_level = WARNING - -; Enable the Subsonic REST API. You'll most likely want to keep this on. -; Here for testing purposes. Default: on -;mount_api = on - -; Enable the administrative web interface. Default: on -;mount_webui = on - -; Space separated list of prefixes that should be ignored on index endpoints -; Default: El La Le Las Les Los The -index_ignored_prefixes = El La Le Las Les Los The -``` - -## `[daemon]` section - -`socket`: Unix domain socket file (or named pipe on Windows) used to communicate -between the daemon and clients that rely on it (eg. CLI, folder admin web page, -etc.). Note that using an IP address here isn't supported. -Default: /tmp/supysonic/supysonic.sock - -`run_watcher`: whether or not to start the watcher that will listen for library -changes. Default: yes - -`wait_delay`: delay (in seconds) before triggering the scanning operation after -a change have been detected. This prevents running too many scans when multiple -changes are detected for a single file over a short time span. -Default: 5 seconds. - -`jukebox_command` : command used by the jukebox mode to play a single file. -See the [jukebox documentation](jukebox.md) for more details. - -`log_file`: rotating file where events generated by the file watcher are logged. -If left empty, any logging will be sent to stderr. - -`log_level`: defines the minimum severity threshold of messages to be added to -`log_file`. Possible values are: `DEBUG`, `INFO`, `WARNING`, `ERROR` and -`CRITICAL`. Defaults to `WARNING`. - -```ini -[daemon] -; Socket file the daemon will listen on for incoming management commands -; Default: /tmp/supysonic/supysonic.sock -socket = /var/run/supysonic.sock - -; Defines if the file watcher should be started. Default: yes -run_watcher = yes - -; Delay in seconds before triggering scanning operation after a change have been -; detected. -; This prevents running too many scans when multiple changes are detected for a -; single file over a short time span. Default: 5 -wait_delay = 5 - -; Command used by the jukebox -jukebox_command = mplayer -ss %offset %path - -; Optional rotating log file for the scanner daemon. Logs to stderr if empty -log_file = /var/supysonic/supysonic-daemon.log -log_level = INFO -``` - -## `[lastfm]` section - -This section allow defining API keys to enable Last.FM integration in -_Supysonic_. Currently it is only used to _scrobble_ played tracks and update -the _now playing_ information. -See https://www.last.fm/api to obtain such keys. -Once keys are set, users have to link their account by visiting their profile -page on _Supysonic_'s administrative UI. - -`api_key`: Last.FM API key - -`secret`: secret key associated to the API key - -```ini -[lastfm] -; API and secret key to enable scrobbling. http://www.last.fm/api/accounts -; Defaults: none -;api_key = -;secret = -``` - -## `[transcoding]` section - -This section defines command-line programs to be used to convert an audio file -to another format or change its bitrate. All configurations in the sample below -have **not** been thoroughly tested. -For more details, please refer to the -[transcoding configuration](transcoding.md). - -```ini -[transcoding] -; Programs used to convert from one format/bitrate to another. Defaults: none -transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - -transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - -decoder_mp3 = mpg123 --quiet -w - %srcpath -decoder_ogg = oggdec -o %srcpath -decoder_flac = flac -d -c -s %srcpath -encoder_mp3 = lame --quiet -b %outrate - - -encoder_ogg = oggenc2 -q -M %outrate - -``` - -## `[mimetypes]` section - -Use this section if the system _Supysonic_ is installed on has trouble guessing -the mimetype of some files. This might only be useful in some rare cases. - -See the following links for a list of examples: -* https://en.wikipedia.org/wiki/Media_type#Common_examples -* https://www.iana.org/assignments/media-types/media-types.xhtml - -```ini -[mimetypes] -; Extension to mimetype mappings in case your system has some trouble guessing -; Default: none -;mp3 = audio/mpeg -;ogg = audio/vorbis -``` - diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 00000000..01e12304 --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,307 @@ +Configuration +============= + +*Supysonic* looks for four files for its configuration: ``/etc/supysonic``, +``~/.supysonic``, ``~/.config/supysonic/supysonic.conf`` and ``supysonic.conf`` +in the current folder, merging values from all files. + +Configuration files must respect a structure similar to Windows INI file, with +``[section]`` headers and using a ``KEY = VALUE`` or ``KEY: VALUE`` syntax. + +You'll find a roughly documented configuration sample file at the root of the +project, file conveniently named ``config.sample``. More details below. + +``[base]`` section +------------------ + +This sections defines the database and additional scanning config. + +``database_uri`` + The most important configuration, defines the type and + parameters of the database *Supysonic* should connect to. It usually includes + username, password, hostname and database name. The typical form of a + database URI is:: + + driver://username:password@host:port/database + + If the connection needs some additional parameters, they can be provided as a + query string, such as:: + + driver://username:password@host:port/database?param1=value1¶m2=value2 + + Supported drivers are ``sqlite``, ``mysql`` and ``postgres`` (or + ``postgresql``). + + As SQLite connects to local files, the format is slightly different. The + "file" portion of the URI is the filename of the database. For a relative + path, it requires three slashes, for absolute paths it's also three slashes + followed by the absolute path, meaning actually four slashes on Unix systems. + + .. code-block:: ini + + ; Relative path + database_uri = sqlite:///relative-file.db + ; Absolute path on Unix-based systems + database_uri = sqlite:////home/user/supysonic.db + ; Absolute path on Windows + database_uri = sqlite:///C:\Users\user\supysonic.db + + A MySQL-compatible database requires either ``MySQLdb`` or ``pymysql`` to be + installed. PostgreSQL needs ``psycopg2``. + + .. note:: + + For MySQL if no character set is defined on the URI it defaults to + ``utf8mb4`` regardless of what's set on your MySQL installation. + + If ``database_uri`` isn't provided, it defaults to a SQLite database stored + in ``/tmp/supysonic/supysonic.db``. + +``scanner_extensions`` + A space separated list of file extensions the scanner is restricted to. + Useful if you have multiple audio formats in your library but only want to + serve some. If left empty, the scanner will try to read every file it finds. + +``follow_symlinks`` + If set to ``yes``, allows the scanner to follow symbolic links. + + Disabled by default, enable it only if you trust your file system as nothing + is done to handle broken links or loops. + +Sample configuration: + +.. code-block:: ini + + [base] + ; A database URI. See the 'schema' folder for schema creation scripts + ; Default: sqlite:////tmp/supysonic/supysonic.db + database_uri = sqlite:////var/supysonic/supysonic.db + ;database_uri = mysql://supysonic:supysonic@localhost/supysonic + ;database_uri = postgres://supysonic:supysonic@localhost/supysonic + + ; Optional, restrict scanner to these extensions. Default: none + scanner_extensions = mp3 ogg + + ; Should the scanner follow symbolic links? Default: no + follow_symlinks = no + +``[webapp]`` section +-------------------- + +Configuration relative to the HTTP server. + +``cache_dir``: + Directory used to store generated files, such as resized cover art or + transcoded files. Defaults to ``/tmp/supysonic``. + +``cache_size`` + Maximum size (in megabytes) of the cache (except for trancodes). + Defaults to 512 MB. + +``transcode_cache_size`` + Maximum size (in megabytes) of the transcode cache. + Defaults to 1024 MB (1 GB). + +``log_file`` + Rotating file where some events generated by the web server are + logged. Leave empty to disable logging. + +``log_level`` + Defines the minimum severity threshold of messages to be added to + ``log_file``. Possible values are: + + * ``DEBUG`` + * ``INFO`` + * ``WARNING`` + * ``ERROR`` + * ``CRITICAL`` + + Defaults to ``WARNING``. + +``mount_api`` (``on`` or ``off``) + Enable or disable the Subsonic REST API. Should be kept on or *Supysonic* + would be quite useless. Exists mostly for testing purposes. + Defaults to ``on``. + +``mount_webui`` (``on`` or ``off``) + Enable or disable the administrative web interface. + + .. note:: + Setting this off will prevent users from defining a preferred transcoding + format. + + Defaults to ``on``. + +``index_ignored_prefixes`` + Space-separated list of prefixes that should be ignored from artist names + when returning their index. Example: if the word *The* is in this list, + artist *The Rolling Stones* will be listed under the letter *R*. The match is + case insensitive. + Defaults to ``El La Le Las Les Los The``. + +Sample configuration: + +.. code-block:: ini + + [webapp] + ; Optional cache directory. Default: /tmp/supysonic + cache_dir = /var/supysonic/cache + + ; Main cache max size in MB. Default: 512 + cache_size = 512 + + ; Transcode cache max size in MB. Default: 1024 (1GB) + transcode_cache_size = 1024 + + ; Optional rotating log file. Default: none + log_file = /var/supysonic/supysonic.log + + ; Log level. Possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL. + ; Default: WARNING + log_level = WARNING + + ; Enable the Subsonic REST API. You'll most likely want to keep this on. + ; Here for testing purposes. Default: on + ;mount_api = on + + ; Enable the administrative web interface. Default: on + ;mount_webui = on + + ; Space separated list of prefixes that should be ignored on index endpoints + ; Default: El La Le Las Les Los The + index_ignored_prefixes = El La Le Las Les Los The + +``[daemon]`` section +-------------------- + +Configuration for the daemon process that is used to watch for changes in the +library folders and providing the jukebox feature. + +``socket`` + Unix domain socket file (or named pipe on Windows) used to communicate + between the daemon and clients that rely on it (eg. CLI, folder admin web + page, etc.). Note that using an IP address here isn't supported. + Default: /tmp/supysonic/supysonic.sock + +``run_watcher`` + Whether or not to start the watcher that will listen for library changes. + Default: yes + +``wait_delay`` + Delay (in seconds) before triggering the scanning operation after a change + have been detected. This prevents running too many scans when multiple + changes are detected for a single file over a short time span. + Default: 5 seconds. + +``jukebox_command`` + Command used by the jukebox mode to play a single file. + See the :doc:`jukebox documentation ` for more details. + +``log_file`` + Rotating file where events generated by the file watcher are logged. + If left empty, any logging will be sent to stderr. + +``log_level`` + Defines the minimum severity threshold of messages to be added to + ``log_file``. Possible values are: + + * ``DEBUG`` + * ``INFO`` + * ``WARNING`` + * ``ERROR`` + * ``CRITICAL`` + + Defaults to ``WARNING``. + +Sample configuration: + +.. code-block:: ini + + [daemon] + ; Socket file the daemon will listen on for incoming management commands + ; Default: /tmp/supysonic/supysonic.sock + socket = /var/run/supysonic.sock + + ; Defines if the file watcher should be started. Default: yes + run_watcher = yes + + ; Delay in seconds before triggering scanning operation after a change have been + ; detected. + ; This prevents running too many scans when multiple changes are detected for a + ; single file over a short time span. Default: 5 + wait_delay = 5 + + ; Command used by the jukebox + jukebox_command = mplayer -ss %offset %path + + ; Optional rotating log file for the scanner daemon. Logs to stderr if empty + log_file = /var/supysonic/supysonic-daemon.log + log_level = INFO + +``[lastfm]`` section +-------------------- + +This section allow defining API keys to enable Last.FM integration in +*Supysonic*. Currently it is only used to *scrobble* played tracks and update +the *now playing* information. + +See https://www.last.fm/api to obtain such keys. + +Once keys are set, users have to link their account by visiting their profile +page on *Supysonic*'s administrative UI. + +``api_key`` + Last.FM API key + +``secret`` + secret key associated to the API key + +Sample configuration: + +.. code-block:: ini + + [lastfm] + ; API and secret key to enable scrobbling. http://www.last.fm/api/accounts + ; Defaults: none + ;api_key = + ;secret = + +``[transcoding]`` section +------------------------- + +This section defines command-line programs to be used to convert an audio file +to another format or change its bitrate. All configurations in the sample below +have **not** been thoroughly tested. +For more details, please refer to the +:doc:`transcoding configuration `. + +.. code-block:: ini + + [transcoding] + ; Programs used to convert from one format/bitrate to another. Defaults: none + transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - + transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - + decoder_mp3 = mpg123 --quiet -w - %srcpath + decoder_ogg = oggdec -o %srcpath + decoder_flac = flac -d -c -s %srcpath + encoder_mp3 = lame --quiet -b %outrate - - + encoder_ogg = oggenc2 -q -M %outrate - + +``[mimetypes]`` section +----------------------- + +Use this section if the system *Supysonic* is installed on has trouble guessing +the mimetype of some files. This might only be useful in some rare cases. + +See the following links for a list of examples: + +* https://en.wikipedia.org/wiki/Media_type#Common_examples +* https://www.iana.org/assignments/media-types/media-types.xhtml + +.. code-block:: ini + + [mimetypes] + ; Extension to mimetype mappings in case your system has some trouble guessing + ; Default: none + ;mp3 = audio/mpeg + ;ogg = audio/vorbis From ff75ff1230d72c6c5f53055a334698921ec7a3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 29 Dec 2020 12:50:15 +0100 Subject: [PATCH 039/237] Docs as reST: transcoding --- docs/configuration.rst | 4 +- docs/transcoding.md | 105 ------------------------------- docs/transcoding.rst | 139 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 106 deletions(-) delete mode 100644 docs/transcoding.md create mode 100644 docs/transcoding.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index 01e12304..d83b1b43 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -90,7 +90,7 @@ Sample configuration: Configuration relative to the HTTP server. -``cache_dir``: +``cache_dir`` Directory used to store generated files, such as resized cover art or transcoded files. Defaults to ``/tmp/supysonic``. @@ -266,6 +266,8 @@ Sample configuration: ;api_key = ;secret = +.. _conf-transcoding: + ``[transcoding]`` section ------------------------- diff --git a/docs/transcoding.md b/docs/transcoding.md deleted file mode 100644 index 9b9fa28c..00000000 --- a/docs/transcoding.md +++ /dev/null @@ -1,105 +0,0 @@ -# Transcoding - -Transcoding is the process of converting from one audio format to another. This -allows for streaming of formats that wouldn't be streamable otherwise, or -reducing the quality of an audio file to allow a decent streaming for clients -with limited bandwidth, such as the ones running on a mobile connection. - -Transcoding in _Supysonic_ is achieved through the use of third-party -command-line programs. _Supysonic_ isn't bundled with such programs, and you are -left to choose which one you want to use. - -If you want to use transcoding but your client doesn't allow you to do so, you -can force _Supysonic_ to transcode for that client by going to your profile page -on the web interface. - -## Configuration - -Configuration of transcoders is done on the `[transcoding]` section of the -[configuration file](configuration.md). - -Transcoding can be done by one single program which is able to convert from one -format directly to another one, or by two programs: a decoder and an encoder. -All these are defined by the following variables: - -* `transcoder_EXT_EXT` -* `decoder_EXT` -* `encoder_EXT` -* `trancoder` -* `decoder` -* `encoder` -* `default_transcode_target` - -where `EXT` is the lowercase file extension of the matching audio format. -`transcoder`s variables have two extensions: the first one is the source -extension, and the second one is the extension to convert to. The same way, -`decoder`s extension is the source extension, and `encoder`s extension is the -extension to convert to. - -Notice that all of them have a version without extension. Those are generic -versions. The programs defined with these variables should be able to -transcode/decode/encode any format. For that reason, we suggest you don't use -these if you want to keep control over the available transcoders. - -_Supysonic_ will take the first available transcoding configuration in the -following order: - -1. specific transcoder -2. specific decoder / specific encoder -3. generic decoder / generic encoder (with the possibility to use a generic - decoder with a specific encoder, and vice-versa) -4. generic transcoder - -All the variables should be set to the command-line used to run the converter -program. The command-lines can include the following fields: - -* `%srcpath`: path to the original file to transcode -* `%srcfmt`: extension of the original file -* `%outfmt`: extension of the resulting file -* `%outrate`: bitrate of the resulting file -* `%title`: title of the file to transcode -* `%album`: album name of the file to transcode -* `%artist`: artist name of the file to transcode -* `%tracknumber`: track number of the file to transcode -* `%totaltracks`: number of tracks in the album of the file to transcode -* `%discnumber`: disc number of the file to transcode -* `%genre`: genre of the file to transcode (not always available, defaults to "") -* `%year`: year of the file to transcode (not always available, defaults to "") - -One final note: the original file should be provided as an argument of -transcoders and decoders. All transcoders, decoders and encoders should write -to standard output, and encoders should read from standard input. - -The value of `default_transcode_target` will be used as output format when a -client requests a bitrate lower than the original file and no specific format. - -## Suggested configuration - -Here is an example configuration that you could use. This is provided as-is, -and some configurations haven't been tested. - -Basic configuration: -```ini -[transcoding] -transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - -transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - -decoder_mp3 = mpg123 --quiet -w - %srcpath -decoder_ogg = oggdec -o %srcpath -decoder_flac = flac -d -c -s %srcpath -encoder_mp3 = lame --quiet -b %outrate - - -encoder_ogg = oggenc2 -Q -M %outrate - -default_transcode_target = mp3 -``` - -To include track metadata in the transcoded stream: -```ini -[transcoding] -transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 %srcpath - -transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -metadata title=%title -metadata album=%album -metadata author=%artist -metadata track=%tracknumber/%totaltracks -metadata disc=%discnumber -metadata genre=%genre -metadata date=%year -f %outfmt - -decoder_mp3 = mpg123 --quiet -w - %srcpath -decoder_ogg = oggdec -o %srcpath -decoder_flac = flac -d -c -s %srcpath -encoder_mp3 = lame --quiet -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 - - -encoder_ogg = oggenc2 -Q -M %outrate -t %title -l %album -a %artist -N %tracknumber -c TOTALTRACKS=%totaltracks -c DISCNUMBER=%discnumber -G %genre -d %year - -default_transcode_target = mp3 -``` diff --git a/docs/transcoding.rst b/docs/transcoding.rst new file mode 100644 index 00000000..ba45744c --- /dev/null +++ b/docs/transcoding.rst @@ -0,0 +1,139 @@ +Transcoding +=========== + +Transcoding is the process of converting from one audio format to another. This +allows for streaming of formats that wouldn't be streamable otherwise, or +reducing the quality of an audio file to allow a decent streaming for clients +with limited bandwidth, such as the ones running on a mobile connection. + +Transcoding in *Supysonic* is achieved through the use of third-party +command-line programs. *Supysonic* isn't bundled with such programs, and you are +left to choose which one you want to use. + +If you want to use transcoding but your client doesn't allow you to do so, you +can force *Supysonic* to transcode for that client by going to your profile page +on the web interface. + +Configuration +------------- + +Configuration of transcoders is done on the :ref:`conf-transcoding` of the +configuration file. + +Transcoding can be done by one single program which is able to convert from one +format directly to another one, or by two programs: a decoder and an encoder. +All these are defined by the following variables: + +* ``transcoder_EXT_EXT`` +* ``decoder_EXT`` +* ``encoder_EXT`` +* ``trancoder`` +* ``decoder`` +* ``encoder`` +* ``default_transcode_target`` + +where ``EXT`` is the lowercase file extension of the matching audio format. +``transcoder``\ s variables have two extensions: the first one is the source +extension, and the second one is the extension to convert to. The same way, +``decoder``\ s extension is the source extension, and ``encoder``\ s extension +is the extension to convert to. +The value of ``default_transcode_target`` will be used as output format when a +client requests a bitrate lower than the original file and no specific format. + +Notice that all of them have a version without extension. Those are generic +versions. The programs defined with these variables should be able to +transcode/decode/encode any format. For that reason, we suggest you don't use +these if you want to keep control over the available transcoders. + +*Supysonic* will take the first available transcoding configuration in the +following order: + +#. specific transcoder +#. specific decoder / specific encoder +#. generic decoder / generic encoder (with the possibility to use a generic + decoder with a specific encoder, and vice-versa) +#. generic transcoder + +All the variables should be set to the command-line used to run the converter +program. The command-lines can include the following fields: + +``%srcpath`` + path to the original file to transcode +``%srcfmt`` + extension of the original file +``%outfmt`` + extension of the resulting file +``%outrate`` + bitrate of the resulting file +``%title`` + title of the file to transcode +``%album`` + album name of the file to transcode +``%artist`` + artist name of the file to transcode +``%tracknumber`` + track number of the file to transcode +``%totaltracks`` + number of tracks in the album of the file to transcode +``%discnumber`` + disc number of the file to transcode +``%genre`` + genre of the file to transcode (not always available, defaults to "") +``%year`` + year of the file to transcode (not always available, defaults to "") + +One final note: the original file should be provided as an argument of +transcoders and decoders. All transcoders, decoders and encoders should write +to standard output, and encoders should read from standard input (decoders +output being piped into encoders) + +Suggested configuration +^^^^^^^^^^^^^^^^^^^^^^^ + +Here is an example configuration that you could use. This is provided as-is, +and some configurations haven't been tested. + +Basic configuration: + +.. code-block:: ini + + [transcoding] + transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - + transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - + decoder_mp3 = mpg123 --quiet -w - %srcpath + decoder_ogg = oggdec -o %srcpath + decoder_flac = flac -d -c -s %srcpath + encoder_mp3 = lame --quiet -b %outrate - - + encoder_ogg = oggenc2 -Q -M %outrate - + default_transcode_target = mp3 + +To include track metadata in the transcoded stream: + +.. code-block:: ini + + [transcoding] + transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 %srcpath - + transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -metadata title=%title -metadata album=%album -metadata author=%artist -metadata track=%tracknumber/%totaltracks -metadata disc=%discnumber -metadata genre=%genre -metadata date=%year -f %outfmt - + decoder_mp3 = mpg123 --quiet -w - %srcpath + decoder_ogg = oggdec -o %srcpath + decoder_flac = flac -d -c -s %srcpath + encoder_mp3 = lame --quiet -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 - - + encoder_ogg = oggenc2 -Q -M %outrate -t %title -l %album -a %artist -N %tracknumber -c TOTALTRACKS=%totaltracks -c DISCNUMBER=%discnumber -G %genre -d %year - + default_transcode_target = mp3 + +Enabling transcoding +-------------------- + +Once the transcoding configuration has been set, most clients will require the +user to specify that they want to transcode files. This might be done on the +client itself, but most importantly it should be done on *Supysonic* web +interface. Not doing so might prevent some clients to properly request +transcoding. + +To enable transcoding with the web interface, you should first start using the +client you want to set transcoding for. Only browsing the library should +suffice. Then open your browser of choice and navigate to the URL of your +*Supysonic* instance. Log in with your credentials and the click on your +username in the top bar. There you should be presented with a list of clients +you used to connect to *Supysonic* and be able to set you preferred streaming +format and bitrate. From 2ff1080fd43a8723341360c2f968754404b7dead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 29 Dec 2020 13:03:28 +0100 Subject: [PATCH 040/237] Docs as reST: jukebox --- docs/configuration.rst | 2 ++ docs/jukebox.md | 43 ----------------------------------------- docs/jukebox.rst | 44 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 43 deletions(-) delete mode 100644 docs/jukebox.md create mode 100644 docs/jukebox.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index d83b1b43..11d91db9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -171,6 +171,8 @@ Sample configuration: ; Default: El La Le Las Les Los The index_ignored_prefixes = El La Le Las Les Los The +.. _conf-daemon: + ``[daemon]`` section -------------------- diff --git a/docs/jukebox.md b/docs/jukebox.md deleted file mode 100644 index 5d3a0b85..00000000 --- a/docs/jukebox.md +++ /dev/null @@ -1,43 +0,0 @@ -# Jukebox - -The jukebox mode allow playing audio files on the hardware of the machine -running Supysonic, using regular clients that support it as a remote control. - -The daemon must be running in order to be able to use the jukebox mode. So be -sure to start the `supysonic-daemon` command and keep it running. A basic -_systemd_ service file can be found at the root of the project folder. - -## Setting the player program - -Jukebox mode in _Supysonic_ works through the use of third-party command-line -programs. _Supysonic_ isn't bundled with such programs, and you are left to -choose which one you want to use. The chosen program should be able to play a -single audio file from a path specified on its command-line. - -The configuration is done in the `[daemon]` section of the -[configuration file](configuration.md), with the `jukebox_command` variable. -This variable should include the following fields: - -- `%path`: absolute path of the file to be played -- `%offset`: time in seconds where to start playing (used for seeking) - -Here's an example using `mplayer`: -``` -jukebox_command = mplayer -ss %offset %path -``` - -Or using `mpv`: -``` -jukebox_command = mpv --start=%offset %path -``` - -Setting the output volume isn't currently supported. - -## Allowing users to act on the jukebox - -The jukebox mode is only accessible to chosen users. Granting (or revoking) -jukebox usage rights to a specific user is done with the [CLI](cli.md): - -``` -$ supysonic-cli user setroles --jukebox -``` diff --git a/docs/jukebox.rst b/docs/jukebox.rst new file mode 100644 index 00000000..80dda290 --- /dev/null +++ b/docs/jukebox.rst @@ -0,0 +1,44 @@ +Jukebox mode +============ + +The jukebox mode allow playing audio files on the hardware of the machine +running *Supysonic*, using regular clients that support it as a remote control. + +The daemon must be running in order to be able to use the jukebox mode. So be +sure to start the ``supysonic-daemon`` command and keep it running. A basic +*systemd* service file can be found at the root of the project folder. + +Setting the player program +-------------------------- + +Jukebox mode in *Supysonic* works through the use of third-party command-line +programs. *Supysonic* isn't bundled with such programs, and you are left to +choose which one you want to use. The chosen program should be able to play a +single audio file from a path specified on its command-line. + +The configuration is done in the :ref:`conf-daemon` of the configuration file, +with the ``jukebox_command`` variable. This variable should include the +following fields: + +``%path`` + absolute path of the file to be played +``%offset`` + time in seconds where to start playing (used for seeking) + +Here's an example using ``mplayer``:: + + jukebox_command = mplayer -ss %offset %path + +Or using ``mpv``:: + + jukebox_command = mpv --start=%offset %path + +Setting the output volume isn't currently supported. + +Allowing users to act on the jukebox +------------------------------------ + +The jukebox mode is only accessible to chosen users. Granting (or revoking) +jukebox usage rights to a specific user is done with the :doc:`cli`:: + + $ supysonic-cli user setroles --jukebox From 1c65fdede673c9d5688c1a011e116bee39f0028e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 29 Dec 2020 18:29:20 +0100 Subject: [PATCH 041/237] Docs to reST: api --- docs/api.md | 917 ----------------------------------- docs/api.rst | 1286 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1286 insertions(+), 917 deletions(-) delete mode 100644 docs/api.md create mode 100644 docs/api.rst diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 64127306..00000000 --- a/docs/api.md +++ /dev/null @@ -1,917 +0,0 @@ -# API breakdown - -This page lists all the API methods and their parameters up to the version -1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features -_Supysonic_ support, plan on supporting, or won't. - -At the moment, the current target API version is 1.10.2. - -The following information was gathered by _diff_-ing various snapshots of the -[Subsonic API page](http://www.subsonic.org/pages/api.jsp). - -- [Methods and parameters listing](#methods-and-parameters-listing) -- [Changes by version](#changes-by-version) - -## Methods and parameters listing - -Statuses explanation: -- 📅: planned -- ✔️: done -- ❌: done as not supported -- 🔴: won't be implemented -- ❔: not decided yet - -The version column specifies the API version which added the related method or -parameter. When no version is given, it means the item was introduced prior to -or with version 1.8.0. - -### All methods / pseudo-TOC - -| Method | Vers. | | -|-------------------------------------------------------------|--------|---| -| [`ping`](#ping) | | ✔️ | -| [`getLicense`](#getlicense) | | ✔️ | -| [`getMusicFolders`](#getmusicfolders) | | ✔️ | -| [`getIndexes`](#getindexes) | | ✔️ | -| [`getMusicDirectory`](#getmusicdirectory) | | ✔️ | -| [`getGenres`](#getgenres) | 1.9.0 | ✔️ | -| [`getArtists`](#getartists) | | ✔️ | -| [`getArtist`](#getartist) | | ✔️ | -| [`getAlbum`](#getalbum) | | ✔️ | -| [`getSong`](#getsong) | | ✔️ | -| [`getVideos`](#getvideos) | | ❌ | -| [`getVideoInfo`](#getvideoinfo) | 1.15.0 | 🔴 | -| [`getArtistInfo`](#getartistinfo) | 1.11.0 | 📅 | -| [`getArtistInfo2`](#getartistinfo2) | 1.11.0 | 📅 | -| [`getAlbumInfo`](#getalbuminfo) | 1.14.0 | 📅 | -| [`getAlbumInfo2`](#getalbuminfo2) | 1.14.0 | 📅 | -| [`getSimilarSongs`](#getsimilarsongs) | 1.11.0 | ❔ | -| [`getSimilarSongs2`](#getsimilarsongs2) | 1.11.0 | ❔ | -| [`getTopSongs`](#gettopsongs) | 1.13.0 | ❔ | -| [`getAlbumList`](#getalbumlist) | | ✔️ | -| [`getAlbumList2`](#getalbumlist2) | | ✔️ | -| [`getRandomSongs`](#getrandomsongs) | | ✔️ | -| [`getSongsByGenre`](#getsongsbygenre) | 1.9.0 | ✔️ | -| [`getNowPlaying`](#getnowplaying) | | ✔️ | -| [`getStarred`](#getstarred) | | ✔️ | -| [`getStarred2`](#getstarred2) | | ✔️ | -| [`search`](#search) | | ✔️ | -| [`search2`](#search2) | | ✔️ | -| [`search3`](#search3) | | ✔️ | -| [`getPlaylists`](#getplaylists) | | ✔️ | -| [`getPlaylist`](#getplaylist) | | ✔️ | -| [`createPlaylist`](#createplaylist) | | ✔️ | -| [`updatePlaylist`](#updateplaylist) | | ✔️ | -| [`deletePlaylist`](#deleteplaylist) | | ✔️ | -| [`stream`](#stream) | | ✔️ | -| [`download`](#download) | | ✔️ | -| [`hls`](#hls) | 1.9.0 | 🔴 | -| [`getCaptions`](#getcaptions) | 1.15.0 | 🔴 | -| [`getCoverArt`](#getcoverart) | | ✔️ | -| [`getLyrics`](#getlyrics) | | ✔️ | -| [`getAvatar`](#getavatar) | | ❌ | -| [`star`](#star) | | ✔️ | -| [`unstar`](#unstar) | | ✔️ | -| [`setRating`](#setrating) | | ✔️ | -| [`scrobble`](#scrobble) | | ✔️ | -| [`getShares`](#getshares) | | ❌ | -| [`createShare`](#createshare) | | ❌ | -| [`updateShare`](#updateshare) | | ❌ | -| [`deleteShare`](#deleteshare) | | ❌ | -| [`getPodcasts`](#getpodcasts) | | ❔ | -| [`getNewestPodcasts`](#getnewestpodcasts) | 1.14.0 | ❔ | -| [`refreshPodcasts`](#refreshpodcasts) | 1.9.0 | ❔ | -| [`createPodcastChannel`](#createpodcastchannel) | 1.9.0 | ❔ | -| [`deletePodcastChannel`](#deletepodcastchannel) | 1.9.0 | ❔ | -| [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | ❔ | -| [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | ❔ | -| [`jukeboxControl`](#jukeboxcontrol) | | ✔️ | -| [`getInternetRadioStations`](#getinternetradiostations) | 1.9.0 | ✔️ | -| [`createInternetRadioStation`](#createinternetradiostation) | 1.16.0 | ✔️ | -| [`updateInternetRadioStation`](#updateinternetradiostation) | 1.16.0 | ✔️ | -| [`deleteInternetRadioStation`](#deleteinternetradiostation) | 1.16.0 | ✔️ | -| [`getChatMessages`](#getchatmessages) | | ✔️ | -| [`addChatMessage`](#addchatmessage) | | ✔️ | -| [`getUser`](#getuser) | | ✔️ | -| [`getUsers`](#getusers) | 1.9.0 | ✔️ | -| [`createUser`](#createuser) | | ✔️ | -| [`updateUser`](#updateuser) | 1.10.2 | ✔️ | -| [`deleteUser`](#deleteuser) | | ✔️ | -| [`changePassword`](#changepassword) | | ✔️ | -| [`getBookmarks`](#getbookmarks) | 1.9.0 | ❔ | -| [`createBookmark`](#createbookmark) | 1.9.0 | ❔ | -| [`deleteBookmark`](#deletebookmark) | 1.9.0 | ❔ | -| [`getPlayQueue`](#getplayqueue) | 1.12.0 | ❔ | -| [`savePlayQueue`](#saveplayqueue) | 1.12.0 | ❔ | -| [`getScanStatus`](#getscanstatus) | 1.15.0 | ✔️ | -| [`startScan`](#startscan) | 1.15.0 | ✔️ | - -### Global - -Parameters used for any request - -| P. | Vers. | | -|-----|--------|---| -| `u` | | ✔️ | -| `p` | | ✔️ | -| `t` | 1.13.0 | 🔴 | -| `s` | 1.13.0 | 🔴 | -| `v` | | ✔️ | -| `c` | | ✔️ | -| `f` | | ✔️ | - -Error codes - -| # | Vers. | | -|----|--------|---| -| 0 | | ✔️ | -| 10 | | ✔️ | -| 20 | | ✔️ | -| 30 | | ✔️ | -| 40 | | ✔️ | -| 41 | 1.15.0 | 📅 | -| 50 | | ✔️ | -| 60 | | ✔️ | -| 70 | | ✔️ | - -### System - -#### `ping` -✔️ -No parameter - -#### `getLicense` -✔️ -No parameter - -### Browsing - -#### `getMusicFolders` -✔️ -No parameter - -#### `getIndexes` -✔️ - -| Parameter | Vers. | | -|-------------------|-------|---| -| `musicFolderId` | | ✔️ | -| `ifModifiedSince` | | ✔️ | - -#### `getMusicDirectory` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | - -#### `getGenres` -✔️ 1.9.0 -No parameter - -#### `getArtists` -✔️ - -| Parameter | Vers. | | -|-----------------|--------|---| -| `musicFolderId` | 1.14.0 | 📅 | - -#### `getArtist` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | - -#### `getAlbum` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | - -#### `getSong` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | - -#### `getVideos` -❌ -No parameter - -#### `getVideoInfo` -🔴 1.15.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `id` | 1.15.0 | 🔴 | - -#### `getArtistInfo` -📅 1.11.0 - -| Parameter | Vers. | | -|---------------------|--------|---| -| `id` | 1.11.0 | 📅 | -| `count` | 1.11.0 | 📅 | -| `includeNotPresent` | 1.11.0 | 📅 | - -#### `getArtistInfo2` -📅 1.11.0 - -| Parameter | Vers. | | -|---------------------|--------|---| -| `id` | 1.11.0 | 📅 | -| `count` | 1.11.0 | 📅 | -| `includeNotPresent` | 1.11.0 | 📅 | - -#### `getAlbumInfo` -📅 1.14.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `id` | 1.14.0 | 📅 | - -#### `getAlbumInfo2` -📅 1.14.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `id` | 1.14.0 | 📅 | - -#### `getSimilarSongs` -❔ 1.11.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `id` | 1.11.0 | ❔ | -| `count` | 1.11.0 | ❔ | - -#### `getSimilarSongs2` -❔ 1.11.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `id` | 1.11.0 | ❔ | -| `count` | 1.11.0 | ❔ | - -#### `getTopSongs` -❔ 1.13.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `artist` | 1.13.0 | ❔ | -| `count` | 1.13.0 | ❔ | - -### Album/song lists - -#### `getAlbumList` -✔️ - -| Parameter | Vers. | | -|-----------------|--------|---| -| `type` | | ✔️ | -| `size` | | ✔️ | -| `offset` | | ✔️ | -| `fromYear` | | ✔️ | -| `toYear` | | ✔️ | -| `genre` | | ✔️ | -| `musicFolderId` | 1.12.0 | 📅 | - -On 1.10.1, `byYear` and `byGenre` were added to `type` - -#### `getAlbumList2` -✔️ - -| Parameter | Vers. | | -|-----------------|--------|---| -| `type` | | ✔️ | -| `size` | | ✔️ | -| `offset` | | ✔️ | -| `fromYear` | | ✔️ | -| `toYear` | | ✔️ | -| `genre` | | ✔️ | -| `musicFolderId` | 1.12.0 | 📅 | - -On 1.10.1, `byYear` and `byGenre` were added to `type` - -#### `getRandomSongs` -✔️ - -| Parameter | Vers. | | -|-----------------|-------|---| -| `size` | | ✔️ | -| `genre` | | ✔️ | -| `fromYear` | | ✔️ | -| `toYear` | | ✔️ | -| `musicFolderId` | | ✔️ | - -#### `getSongsByGenre` -✔️ 1.9.0 - -| Parameter | Vers. | | -|-----------------|--------|---| -| `genre` | 1.9.0 | ✔️ | -| `count` | 1.9.0 | ✔️ | -| `offset` | 1.9.0 | ✔️ | -| `musicFolderId` | 1.12.0 | 📅 | - -#### `getNowPlaying` -✔️ -No parameter - -#### `getStarred` -✔️ - -| Parameter | Vers. | | -|-----------------|--------|---| -| `musicFolderId` | 1.12.0 | 📅 | - -#### `getStarred2` -✔️ - -| Parameter | Vers. | | -|-----------------|--------|---| -| `musicFolderId` | 1.12.0 | 📅 | - -### Searching - -#### `search` -✔️ - -| Parameter | Vers. | | -|-------------|-------|---| -| `artist` | | ✔️ | -| `album` | | ✔️ | -| `title` | | ✔️ | -| `any` | | ✔️ | -| `count` | | ✔️ | -| `offset` | | ✔️ | -| `newerThan` | | ✔️ | - -#### `search2` -✔️ - -| Parameter | Vers. | | -|-----------------|--------|---| -| `query` | | ✔️ | -| `artistCount` | | ✔️ | -| `artistOffset` | | ✔️ | -| `albumCount` | | ✔️ | -| `albumOffset` | | ✔️ | -| `songCount` | | ✔️ | -| `songOffset` | | ✔️ | -| `musicFolderId` | 1.12.0 | 📅 | - -#### `search3` -✔️ - -| Parameter | Vers. | | -|-----------------|--------|---| -| `query` | | ✔️ | -| `artistCount` | | ✔️ | -| `artistOffset` | | ✔️ | -| `albumCount` | | ✔️ | -| `albumOffset` | | ✔️ | -| `songCount` | | ✔️ | -| `songOffset` | | ✔️ | -| `musicFolderId` | 1.12.0 | 📅 | - -### Playlists - -#### `getPlaylists` -✔️ - -| Parameter | Vers. | | -|------------|-------|---| -| `username` | | ✔️ | - -#### `getPlaylist` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | - -#### `createPlaylist` -✔️ - -| Parameter | Vers. | | -|--------------|-------|---| -| `playlistId` | | ✔️ | -| `name` | | ✔️ | -| `songId` | | ✔️ | - -#### `updatePlaylist` -✔️ - -| Parameter | Vers. | | -|---------------------|-------|---| -| `playlistId` | | ✔️ | -| `name` | | ✔️ | -| `comment` | | ✔️ | -| `public` | 1.9.0 | ✔️ | -| `songIdToAdd` | | ✔️ | -| `songIndexToRemove` | | ✔️ | - -#### `deletePlaylist` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | - -### Media retrieval - -#### `stream` -✔️ - -| Parameter | Vers. | | -|-------------------------|--------|---| -| `id` | | ✔️ | -| `maxBitRate` | | ✔️ | -| `format` | | ✔️ | -| `timeOffset` | | ❌ | -| `size` | | ❌ | -| `estimateContentLength` | | ✔️ | -| `converted` | 1.15.0 | 🔴 | - -#### `download` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | - -#### `hls` -🔴 1.9.0 - -| Parameter | Vers. | | -|--------------|--------|---| -| `id` | 1.9.0 | 🔴 | -| `bitRate` | 1.9.0 | 🔴 | -| `audioTrack` | 1.15.0 | 🔴 | - -#### `getCaptions` -🔴 1.15.0 - -| Parameter | Vers. | | -|-------------|--------|---| -| `id` | 1.15.0 | 🔴 | -| `format` | 1.15.0 | 🔴 | - -#### `getCoverArt` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | -| `size` | | ✔️ | - -#### `getLyrics` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `artist` | | ✔️ | -| `title` | | ✔️ | - -#### `getAvatar` -❌ - -| Parameter | Vers. | | -|------------|-------|---| -| `username` | | ❌ | - -### Media annotation - -#### `star` -✔️ - -| Parameter | Vers. | | -|------------|-------|---| -| `id` | | ✔️ | -| `albumId` | | ✔️ | -| `artistId` | | ✔️ | - -#### `unstar` -✔️ - -| Parameter | Vers. | | -|------------|-------|---| -| `id` | | ✔️ | -| `albumId` | | ✔️ | -| `artistId` | | ✔️ | - -#### `setRating` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ✔️ | -| `rating` | | ✔️ | - -#### `scrobble` -✔️ - -| Parameter | Vers. | | -|--------------|-------|---| -| `id` | | ✔️ | -| `time` | 1.9.0 | ✔️ | -| `submission` | | ✔️ | - -### Sharing - -#### `getShares` -❌ -No parameter - -#### `createShare` -❌ - -| Parameter | Vers. | | -|---------------|-------|---| -| `id` | | ❌ | -| `description` | | ❌ | -| `expires` | | ❌ | - -#### `updateShare` -❌ - -| Parameter | Vers. | | -|---------------|-------|---| -| `id` | | ❌ | -| `description` | | ❌ | -| `expires` | | ❌ | - -#### `deleteShare` -❌ - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | | ❌ | - -### Podcast - -#### `getPodcasts` -❔ - -| Parameter | Vers. | | -|-------------------|-------|---| -| `includeEpisodes` | 1.9.0 | ❔ | -| `id` | 1.9.0 | ❔ | - -#### `getNewestPodcasts` -❔ 1.14.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `count` | 1.14.0 | ❔ | - -#### `refreshPodcasts` -❔ 1.9.0 - -No parameter - -#### `createPodcastChannel` -❔ 1.9.0 - -| Parameter | Vers. | | -|-----------|-------|---| -| `url` | 1.9.0 | ❔ | - -#### `deletePodcastChannel` -❔ 1.9.0 - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | 1.9.0 | ❔ | - -#### `deletePodcastEpisode` -❔ 1.9.0 - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | 1.9.0 | ❔ | - - -#### `downloadPodcastEpisode` -❔ 1.9.0 - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | 1.9.0 | ❔ | - -### Jukebox - -#### `jukeboxControl` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `action` | | ✔️ | -| `index` | | ✔️ | -| `offset` | | ✔️ | -| `id` | | ✔️ | -| `gain` | | ❌ | - -### Internet radio - -#### `getInternetRadioStations` -❔ 1.9.0 - -No parameter - -#### `createInternetRadioStation` -❔ 1.16.0 - -| Parameter | Vers. | | -|---------------|--------|---| -| `streamUrl` | 1.16.0 | ❔ | -| `name` | 1.16.0 | ❔ | -| `homepageUrl` | 1.16.0 | ❔ | - -#### `updateInternetRadioStation` -❔ 1.16.0 - -| Parameter | Vers. | | -|---------------|--------|---| -| `id` | 1.16.0 | ❔ | -| `streamUrl` | 1.16.0 | ❔ | -| `name` | 1.16.0 | ❔ | -| `homepageUrl` | 1.16.0 | ❔ | - -#### `deleteInternetRadioStation` -❔ 1.16.0 - -| Parameter | Vers. | | -|-----------|--------|---| -| `id` | 1.16.0 | ❔ | - -### Chat - -#### `getChatMessages` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `since` | | ✔️ | - -#### `addChatMessage` -✔️ - -| Parameter | Vers. | | -|-----------|-------|---| -| `message` | | ✔️ | - -### User management - -#### `getUser` -✔️ - -| Parameter | Vers. | | -|------------|-------|---| -| `username` | | ✔️ | - -#### `getUsers` -✔️ 1.9.0 - -No parameter - -#### `createUser` -✔️ - -| Parameter | Vers. | | -|-----------------------|--------|---| -| `username` | | ✔️ | -| `password` | | ✔️ | -| `email` | | ✔️ | -| `ldapAuthenticated` | | | -| `adminRole` | | ✔️ | -| `settingsRole` | | | -| `streamRole` | | | -| `jukeboxRole` | | ✔️ | -| `downloadRole` | | | -| `uploadRole` | | | -| `playlistRole` | | | -| `coverArtRole` | | | -| `commentRole` | | | -| `podcastRole` | | | -| `shareRole` | | | -| `videoConversionRole` | 1.14.0 | | -| `musicFolderId` | 1.12.0 | 📅 | - -#### `updateUser` -✔️ 1.10.2 - -| Parameter | Vers. | | -|-----------------------|--------|---| -| `username` | 1.10.2 | ✔️ | -| `password` | 1.10.2 | ✔️ | -| `email` | 1.10.2 | ✔️ | -| `ldapAuthenticated` | 1.10.2 | | -| `adminRole` | 1.10.2 | ✔️ | -| `settingsRole` | 1.10.2 | | -| `streamRole` | 1.10.2 | | -| `jukeboxRole` | 1.10.2 | ✔️ | -| `downloadRole` | 1.10.2 | | -| `uploadRole` | 1.10.2 | | -| `coverArtRole` | 1.10.2 | | -| `commentRole` | 1.10.2 | | -| `podcastRole` | 1.10.2 | | -| `shareRole` | 1.10.2 | | -| `videoConversionRole` | 1.14.0 | | -| `musicFolderId` | 1.12.0 | 📅 | -| `maxBitRate` | 1.13.0 | 📅 | - -#### `deleteUser` -✔️ - -| Parameter | Vers. | | -|------------|--------|---| -| `username` | | ✔️ | - -#### `changePassword` -✔️ - -| Parameter | Vers. | | -|------------|--------|---| -| `username` | | ✔️ | -| `password` | | ✔️ | - -### Bookmarks - -#### `getBookmarks` -❔ 1.9.0 -No parameter - -#### `createBookmark` -❔ 1.9.0 - -| Parameter | Vers. | | -|------------|-------|---| -| `id` | 1.9.0 | ❔ | -| `position` | 1.9.0 | ❔ | -| `comment` | 1.9.0 | ❔ | - -#### `deleteBookmark` -❔ 1.9.0 - -| Parameter | Vers. | | -|-----------|-------|---| -| `id` | 1.9.0 | ❔ | - -#### `getPlayQueue` -❔ 1.12.0 -No parameter - -#### `savePlayQueue` -❔ 1.12.0 - -| Parameter | Vers. | | -|------------|--------|---| -| `id` | 1.12.0 | ❔ | -| `current` | 1.12.0 | ❔ | -| `position` | 1.12.0 | ❔ | - -### Library scanning - -#### `getScanStatus` -✔️ 1.15.0 -No parameter - -#### `startScan` -✔️ 1.15.0 -No parameter - -## Changes by version - -### Version 1.9.0 - -Added methods: -- `getGenres` -- `getSongsByGenre` -- `hls` -- `refreshPodcasts` -- `createPodcastChannel` -- `deletePodcastChannel` -- `deletePodcastEpisode` -- `downloadPodcastEpisode` -- `getInternetRadioStations` -- `getUsers` -- `getBookmarks` -- `createBookmark` -- `deleteBookmark` - -Added method parameters: -- `updatePlaylist` - - `public` -- `scrobble` - - `time` -- `getPodcasts` - - `includeEpisodes` - - `id` - -### Version 1.10.1 - -Added method parameters: -- `getAlbumList` - - `fromYear` - - `toYear` - - `genre` -- `getAlbumList2` - - `fromYear` - - `toYear` - - `genre` - -### Version 1.10.2 - -Added methods: -- `updateUser` - -### Version 1.11.0 - -Added methods: -- `getArtistInfo` -- `getArtistInfo2` -- `getSimilarSongs` -- `getSimilarSongs2` - -### Version 1.12.0 - -Added methods: -- `getPlayQueue` -- `savePlayQueue` - -Added method parameters: -- `getAlbumList` - - `musicFolderId` -- `getAlbumList2` - - `musicFolderId` -- `getSongsByGenre` - - `musicFolderId` -- `getStarred` - - `musicFolderId` -- `getStarred2` - - `musicFolderId` -- `search2` - - `musicFolderId` -- `search3` - - `musicFolderId` -- `createUser` - - `musicFolderId` -- `updateUser` - - `musicFolderId` - -### Version 1.13.0 - -Added global parameters: -- `t` -- `s` - -Added methods: -- `getTopSongs` - -Added method parameters: -- `updateUser` - - `maxBitRate` - -### Version 1.14.0 - -Added methods: -- `getAlbumInfo` -- `getAlbumInfo2` -- `getNewestPodcasts` - -Added method parameters: -- `getArtists` - - `musicFolderId` -- `createUser` - - `videoConversionRole` -- `updateUser` - - `videoConversionRole` - -### Version 1.15.0 - -Added error code `41` - -Added methods: -- `getVideoInfo` -- `getCaptions` -- `getScanStatus` -- `startScan` - -Added method parameters: -- `stream` - - `converted` -- `hls` - - `audioTrack` - -### Version 1.16.0 - -Added methods: -- `createInternetRadioStation` -- `updateInternetRadioStation` -- `deleteInternetRadioStation` - diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..d3612a29 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,1286 @@ +Subsonic API breakdown +====================== + +This page lists all the API methods and their parameters up to the version +1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features +*Supysonic* support, plan on supporting, or won't. + +At the moment, the current target API version is 1.10.2. + +The following information was gathered by *diff*-ing various snapshots of the +`Subsonic API page `_. + +Methods and parameters listing +------------------------------ + +Statuses explanation: + +* 📅: planned +* ✔️: done +* ❌: done as not supported +* 🔴: won't be implemented +* ❔: not decided yet + +The version column specifies the API version which added the related method or +parameter. When no version is given, it means the item was introduced prior to +or with version 1.8.0. + +All methods / pseudo-TOC +^^^^^^^^^^^^^^^^^^^^^^^^ + +============================================================== ====== = +Method Vers. +============================================================== ====== = +:ref:`ping ` ✔️ +:ref:`getLicense ` ✔️ +:ref:`getMusicFolders ` ✔️ +:ref:`getIndexes ` ✔️ +:ref:`getMusicDirectory ` ✔️ +:ref:`getGenres ` 1.9.0 ✔️ +:ref:`getArtists ` ✔️ +:ref:`getArtist ` ✔️ +:ref:`getAlbum ` ✔️ +:ref:`getSong ` ✔️ +:ref:`getVideos ` ❌ +:ref:`getVideoInfo ` 1.15.0 🔴 +:ref:`getArtistInfo ` 1.11.0 📅 +:ref:`getArtistInfo2 ` 1.11.0 📅 +:ref:`getAlbumInfo ` 1.14.0 📅 +:ref:`getAlbumInfo2 ` 1.14.0 📅 +:ref:`getSimilarSongs ` 1.11.0 ❔ +:ref:`getSimilarSongs2 ` 1.11.0 ❔ +:ref:`getTopSongs ` 1.13.0 ❔ +:ref:`getAlbumList ` ✔️ +:ref:`getAlbumList2 ` ✔️ +:ref:`getRandomSongs ` ✔️ +:ref:`getSongsByGenre ` 1.9.0 ✔️ +:ref:`getNowPlaying ` ✔️ +:ref:`getStarred ` ✔️ +:ref:`getStarred2 ` ✔️ +:ref:`search ` ✔️ +:ref:`search2 ` ✔️ +:ref:`search3 ` ✔️ +:ref:`getPlaylists ` ✔️ +:ref:`getPlaylist ` ✔️ +:ref:`createPlaylist ` ✔️ +:ref:`updatePlaylist ` ✔️ +:ref:`deletePlaylist ` ✔️ +:ref:`stream ` ✔️ +:ref:`download ` ✔️ +:ref:`hls ` 1.9.0 🔴 +:ref:`getCaptions ` 1.15.0 🔴 +:ref:`getCoverArt ` ✔️ +:ref:`getLyrics ` ✔️ +:ref:`getAvatar ` ❌ +:ref:`star ` ✔️ +:ref:`unstar ` ✔️ +:ref:`setRating ` ✔️ +:ref:`scrobble ` ✔️ +:ref:`getShares ` ❌ +:ref:`createShare ` ❌ +:ref:`updateShare ` ❌ +:ref:`deleteShare ` ❌ +:ref:`getPodcasts ` ❔ +:ref:`getNewestPodcasts ` 1.14.0 ❔ +:ref:`refreshPodcasts ` 1.9.0 ❔ +:ref:`createPodcastChannel ` 1.9.0 ❔ +:ref:`deletePodcastChannel ` 1.9.0 ❔ +:ref:`deletePodcastEpisode ` 1.9.0 ❔ +:ref:`downloadPodcastEpisode ` 1.9.0 ❔ +:ref:`jukeboxControl ` ✔️ +:ref:`getInternetRadioStations ` 1.9.0 ✔️ +:ref:`createInternetRadioStation ` 1.16.0 ✔️ +:ref:`updateInternetRadioStation ` 1.16.0 ✔️ +:ref:`deleteInternetRadioStation ` 1.16.0 ✔️ +:ref:`getChatMessages ` ✔️ +:ref:`addChatMessage ` ✔️ +:ref:`getUser ` ✔️ +:ref:`getUsers ` 1.9.0 ✔️ +:ref:`createUser ` ✔️ +:ref:`updateUser ` 1.10.2 ✔️ +:ref:`deleteUser ` ✔️ +:ref:`changePassword ` ✔️ +:ref:`getBookmarks ` 1.9.0 ❔ +:ref:`createBookmark ` 1.9.0 ❔ +:ref:`deleteBookmark ` 1.9.0 ❔ +:ref:`getPlayQueue ` 1.12.0 ❔ +:ref:`savePlayQueue ` 1.12.0 ❔ +:ref:`getScanStatus ` 1.15.0 ✔️ +:ref:`startScan ` 1.15.0 ✔️ +============================================================== ====== = + +Global +^^^^^^ + +Parameters used for any request + +===== ====== = +P. Vers. +===== ====== = +``u`` ✔️ +``p`` ✔️ +``t`` 1.13.0 🔴 +``s`` 1.13.0 🔴 +``v`` ✔️ +``c`` ✔️ +``f`` ✔️ +===== ====== = + +Error codes + +== ====== = +# Vers. +== ====== = +0 ✔️ +10 ✔️ +20 ✔️ +30 ✔️ +40 ✔️ +41 1.15.0 📅 +50 ✔️ +60 ✔️ +70 ✔️ +== ====== = + +System +^^^^^^ + +.. _ping: + +``ping`` + ✔️ + + No parameter + +.. _getLicense: + +``getLicense`` + ✔️ + + No parameter + +Browsing +^^^^^^^^ + +.. _getMusicFolders: + +``getMusicFolders`` + ✔️ + + No parameter + +.. _getIndexes: + +``getIndexes`` + ✔️ + + =================== ===== = + Parameter Vers. + =================== ===== = + ``musicFolderId`` ✔️ + ``ifModifiedSince`` ✔️ + =================== ===== = + +.. _getMusicDirectory: + +``getMusicDirectory`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getGenres: + +``getGenres`` + ✔️ 1.9.0 + + No parameter + +.. _getArtists: + +``getArtists`` + ✔️ + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.14.0 📅 + ================= ====== = + +.. _getArtist: + +``getArtist`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getAlbum: + +``getAlbum`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getSong: + +``getSong`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getVideos: + +``getVideos`` + ❌ + + No parameter + +.. _getVideoInfo: + +``getVideoInfo`` + 🔴 1.15.0 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.15.0 🔴 + ========= ====== = + +.. _getArtistInfo: + +``getArtistInfo`` + 📅 1.11.0 + + ===================== ====== = + Parameter Vers. + ===================== ====== = + ``id`` 1.11.0 📅 + ``count`` 1.11.0 📅 + ``includeNotPresent`` 1.11.0 📅 + ===================== ====== = + +.. _getArtistInfo2: + +``getArtistInfo2`` + 📅 1.11.0 + + ===================== ====== = + Parameter Vers. + ===================== ====== = + ``id`` 1.11.0 📅 + ``count`` 1.11.0 📅 + ``includeNotPresent`` 1.11.0 📅 + ===================== ====== = + +.. _getAlbumInfo: + +``getAlbumInfo`` + 📅 1.14.0 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.14.0 📅 + ========= ====== = + +.. _getAlbumInfo2: + +``getAlbumInfo2`` + 📅 1.14.0 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.14.0 📅 + ========= ====== = + +.. _getSimilarSongs: + +``getSimilarSongs`` + ❔ 1.11.0 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.11.0 ❔ + ``count`` 1.11.0 ❔ + ========= ====== = + +.. _getSimilarSongs2: + +``getSimilarSongs2`` + ❔ 1.11.0 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.11.0 ❔ + ``count`` 1.11.0 ❔ + ========= ====== = + +.. _getTopSongs: + +``getTopSongs`` + ❔ 1.13.0 + + ========== ====== = + Parameter Vers. + ========== ====== = + ``artist`` 1.13.0 ❔ + ``count`` 1.13.0 ❔ + ========== ====== = + +Album/song lists +^^^^^^^^^^^^^^^^ + +.. _getAlbumList: + +``getAlbumList`` + ✔️ + + ================= ====== = + Parameter Vers. + ================= ====== = + ``type`` ✔️ + ``size`` ✔️ + ``offset`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``genre`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + + .. versionadded:: 1.10.1 + ``byYear`` and ``byGenre`` were added to ``type`` + +.. _getAlbumList2: + +``getAlbumList2`` + ✔️ + + ================= ====== = + Parameter Vers. + ================= ====== = + ``type`` ✔️ + ``size`` ✔️ + ``offset`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``genre`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + + .. versionadded:: 1.10.1 + ``byYear`` and ``byGenre`` were added to ``type`` + +.. _getRandomSongs: + +``getRandomSongs`` + ✔️ + + ================= ===== = + Parameter Vers. + ================= ===== = + ``size`` ✔️ + ``genre`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``musicFolderId`` ✔️ + ================= ===== = + +.. _getSongsByGenre: + +``getSongsByGenre`` + ✔️ 1.9.0 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``genre`` 1.9.0 ✔️ + ``count`` 1.9.0 ✔️ + ``offset`` 1.9.0 ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +.. _getNowPlaying: + +``getNowPlaying`` + ✔️ + + No parameter + +.. _getStarred: + +``getStarred`` + ✔️ + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +.. _getStarred2: + +``getStarred2`` + ✔️ + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +Searching +^^^^^^^^^ + +.. _search-: + +``search`` + ✔️ + + ============= ===== = + Parameter Vers. + ============= ===== = + ``artist`` ✔️ + ``album`` ✔️ + ``title`` ✔️ + ``any`` ✔️ + ``count`` ✔️ + ``offset`` ✔️ + ``newerThan`` ✔️ + ============= ===== = + +.. _search2: + +``search2`` + ✔️ + + ================= ====== = + Parameter Vers. + ================= ====== = + ``query`` ✔️ + ``artistCount`` ✔️ + ``artistOffset`` ✔️ + ``albumCount`` ✔️ + ``albumOffset`` ✔️ + ``songCount`` ✔️ + ``songOffset`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +.. _search3: + +``search3`` + ✔️ + + ================= ====== = + Parameter Vers. + ================= ====== = + ``query`` ✔️ + ``artistCount`` ✔️ + ``artistOffset`` ✔️ + ``albumCount`` ✔️ + ``albumOffset`` ✔️ + ``songCount`` ✔️ + ``songOffset`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +Playlists +^^^^^^^^^ + +.. _getPlaylists: + +``getPlaylists`` + ✔️ + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = + +.. _getPlaylist: + +``getPlaylist`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _createPlaylist: + +``createPlaylist`` + ✔️ + + ============== ===== = + Parameter Vers. + ============== ===== = + ``playlistId`` ✔️ + ``name`` ✔️ + ``songId`` ✔️ + ============== ===== = + +.. _updatePlaylist: + +``updatePlaylist`` + ✔️ + + ===================== ===== = + Parameter Vers. + ===================== ===== = + ``playlistId`` ✔️ + ``name`` ✔️ + ``comment`` ✔️ + ``public`` 1.9.0 ✔️ + ``songIdToAdd`` ✔️ + ``songIndexToRemove`` ✔️ + ===================== ===== = + +.. _deletePlaylist: + +``deletePlaylist`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +Media retrieval +^^^^^^^^^^^^^^^ + +.. _stream: + +``stream`` + ✔️ + + ========================= ====== = + Parameter Vers. + ========================= ====== = + ``id`` ✔️ + ``maxBitRate`` ✔️ + ``format`` ✔️ + ``timeOffset`` ❌ + ``size`` ❌ + ``estimateContentLength`` ✔️ + ``converted`` 1.15.0 🔴 + ========================= ====== = + +.. _download: + +``download`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _hls: + +``hls`` + 🔴 1.9.0 + + ============== ====== = + Parameter Vers. + ============== ====== = + ``id`` 1.9.0 🔴 + ``bitRate`` 1.9.0 🔴 + ``audioTrack`` 1.15.0 🔴 + ============== ====== = + +.. _getCaptions: + +``getCaptions`` + 🔴 1.15.0 + + ========== ====== = + Parameter Vers. + ========== ====== = + ``id`` 1.15.0 🔴 + ``format`` 1.15.0 🔴 + ========== ====== = + +.. _getCoverArt: + +``getCoverArt`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ``size`` ✔️ + ========= ===== = + +.. _getLyrics: + +``getLyrics`` + ✔️ + + ========== ===== = + Parameter Vers. + ========== ===== = + ``artist`` ✔️ + ``title`` ✔️ + ========== ===== = + +.. _getAvatar: + +``getAvatar`` + ❌ + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ❌ + ============ ===== = + +Media annotation +^^^^^^^^^^^^^^^^ + +.. _star: + +``star`` + ✔️ + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` ✔️ + ``albumId`` ✔️ + ``artistId`` ✔️ + ============ ===== = + +.. _unstar: + +``unstar`` + ✔️ + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` ✔️ + ``albumId`` ✔️ + ``artistId`` ✔️ + ============ ===== = + +.. _setRating: + +``setRating`` + ✔️ + + ========== ===== = + Parameter Vers. + ========== ===== = + ``id`` ✔️ + ``rating`` ✔️ + ========== ===== = + +.. _scrobble: + +``scrobble`` + ✔️ + + ============== ===== = + Parameter Vers. + ============== ===== = + ``id`` ✔️ + ``time`` 1.9.0 ✔️ + ``submission`` ✔️ + ============== ===== = + +Sharing +^^^^^^^ + +.. _getShares: + +``getShares`` + ❌ + + No parameter + +.. _createShare: + +``createShare`` + ❌ + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` ❌ + ``description`` ❌ + ``expires`` ❌ + =============== ===== = + +.. _updateShare: + +``updateShare`` + ❌ + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` ❌ + ``description`` ❌ + ``expires`` ❌ + =============== ===== = + +.. _deleteShare: + +``deleteShare`` + ❌ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ❌ + ========= ===== = + +Podcast +^^^^^^^ + +.. _getPodcasts: + +``getPodcasts`` + ❔ + + =================== ===== = + Parameter Vers. + =================== ===== = + ``includeEpisodes`` 1.9.0 ❔ + ``id`` 1.9.0 ❔ + =================== ===== = + +.. _getNewestPodcasts: + +``getNewestPodcasts`` + ❔ 1.14.0 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``count`` 1.14.0 ❔ + ========= ====== = + +.. _refreshPodcasts: + +``refreshPodcasts`` + ❔ 1.9.0 + + No parameter + +.. _createPodcastChannel: + +``createPodcastChannel`` + ❔ 1.9.0 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``url`` 1.9.0 ❔ + ========= ===== = + +.. _deletePodcastChannel: + +``deletePodcastChannel`` + ❔ 1.9.0 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = + +.. _deletePodcastEpisode: + +``deletePodcastEpisode`` + ❔ 1.9.0 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = + +.. _downloadPodcastEpisode: + +``downloadPodcastEpisode`` + ❔ 1.9.0 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = + +Jukebox +^^^^^^^ + +.. _jukeboxControl: + +``jukeboxControl`` + ✔️ + + ========== ===== = + Parameter Vers. + ========== ===== = + ``action`` ✔️ + ``index`` ✔️ + ``offset`` ✔️ + ``id`` ✔️ + ``gain`` ❌ + ========== ===== = + +Internet radio +^^^^^^^^^^^^^^ + +.. _getInternetRadioStations: + +``getInternetRadioStations`` + ❔ 1.9.0 + + No parameter + +.. _createInternetRadioStation: + +``createInternetRadioStation`` + ❔ 1.16.0 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``streamUrl`` 1.16.0 ❔ + ``name`` 1.16.0 ❔ + ``homepageUrl`` 1.16.0 ❔ + =============== ====== = + +.. _updateInternetRadioStation: + +``updateInternetRadioStation`` + ❔ 1.16.0 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``id`` 1.16.0 ❔ + ``streamUrl`` 1.16.0 ❔ + ``name`` 1.16.0 ❔ + ``homepageUrl`` 1.16.0 ❔ + =============== ====== = + +.. _deleteInternetRadioStation: + +``deleteInternetRadioStation`` + ❔ 1.16.0 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``id`` 1.16.0 ❔ + =============== ====== = + +Chat +^^^^ + +.. _getChatMessages: + +``getChatMessages`` + ✔️ + + ========= ===== = + Parameter Vers. + ========= ===== = + ``since`` ✔️ + ========= ===== = + +.. _addChatMessage: + +``addChatMessage`` + ✔️ + + =========== ===== = + Parameter Vers. + =========== ===== = + ``message`` ✔️ + =========== ===== = + +User management +^^^^^^^^^^^^^^^ + +.. _getUser: + +``getUser`` + ✔️ + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = + +.. _getUsers: + +``getUsers`` + ✔️ 1.9.0 + + No parameter + +.. _createUser: + +``createUser`` + ✔️ + + ======================= ====== = + Parameter Vers. + ======================= ====== = + ``username`` ✔️ + ``password`` ✔️ + ``email`` ✔️ + ``ldapAuthenticated`` + ``adminRole`` ✔️ + ``settingsRole`` + ``streamRole`` + ``jukeboxRole`` ✔️ + ``downloadRole`` + ``uploadRole`` + ``playlistRole`` + ``coverArtRole`` + ``commentRole`` + ``podcastRole`` + ``shareRole`` + ``videoConversionRole`` 1.14.0 + ``musicFolderId`` 1.12.0 📅 + ======================= ====== = + +.. _updateUser: + +``updateUser`` + ✔️ 1.10.2 + + ======================= ====== = + Parameter Vers. + ======================= ====== = + ``username`` 1.10.2 ✔️ + ``password`` 1.10.2 ✔️ + ``email`` 1.10.2 ✔️ + ``ldapAuthenticated`` 1.10.2 + ``adminRole`` 1.10.2 ✔️ + ``settingsRole`` 1.10.2 + ``streamRole`` 1.10.2 + ``jukeboxRole`` 1.10.2 ✔️ + ``downloadRole`` 1.10.2 + ``uploadRole`` 1.10.2 + ``coverArtRole`` 1.10.2 + ``commentRole`` 1.10.2 + ``podcastRole`` 1.10.2 + ``shareRole`` 1.10.2 + ``videoConversionRole`` 1.14.0 + ``musicFolderId`` 1.12.0 📅 + ``maxBitRate`` 1.13.0 📅 + ======================= ====== = + +.. _deleteUser: + +``deleteUser`` + ✔️ + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = + +.. _changePassword: + +``changePassword`` + ✔️ + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ``password`` ✔️ + ============ ===== = + +Bookmarks +^^^^^^^^^ + +.. _getBookmarks: + +``getBookmarks`` + ❔ 1.9.0 + + No parameter + +.. _createBookmark: + +``createBookmark`` + ❔ 1.9.0 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` 1.9.0 ❔ + ``position`` 1.9.0 ❔ + ``comment`` 1.9.0 ❔ + ============ ===== = + +.. _deleteBookmark: + +``deleteBookmark`` + ❔ 1.9.0 + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` 1.9.0 ❔ + =============== ===== = + +.. _getPlayQueue: + +``getPlayQueue`` + ❔ 1.12.0 + + No parameter + +.. _savePlayQueue: + +``savePlayQueue`` + ❔ 1.12.0 + + ============ ====== = + Parameter Vers. + ============ ====== = + ``id`` 1.12.0 ❔ + ``current`` 1.12.0 ❔ + ``position`` 1.12.0 ❔ + ============ ====== = + +Library scanning +^^^^^^^^^^^^^^^^ + +.. _getScanStatus: + +``getScanStatus`` + ✔️ 1.15.0 + + No parameter + +.. _startScan: + +``startScan`` + ✔️ 1.15.0 + + No parameter + +Changes by version +------------------ + +Version 1.9.0 +^^^^^^^^^^^^^ + +Added methods: + +* :ref:`getGenres ` +* :ref:`getSongsByGenre ` +* :ref:`hls ` +* :ref:`refreshPodcasts ` +* :ref:`createPodcastChannel ` +* :ref:`deletePodcastChannel ` +* :ref:`deletePodcastEpisode ` +* :ref:`downloadPodcastEpisode ` +* :ref:`getInternetRadioStations ` +* :ref:`getUsers ` +* :ref:`getBookmarks ` +* :ref:`createBookmark ` +* :ref:`deleteBookmark ` + +Added method parameters: + +* :ref:`updatePlaylist ` + + * ``public`` + +* :ref:`scrobble ` + + * ``time`` + +* :ref:`getPodcasts ` + + * ``includeEpisodes`` + * ``id`` + +Version 1.10.1 +^^^^^^^^^^^^^^ + +Added method parameters: + +* :ref:`getAlbumList ` + + * ``fromYear`` + * ``toYear`` + * ``genre`` + +* :ref:`getAlbumList2 ` + + * ``fromYear`` + * ``toYear`` + * ``genre`` + +Version 1.10.2 +^^^^^^^^^^^^^^ + +Added methods: + +* :ref:`updateUser ` + +Version 1.11.0 +^^^^^^^^^^^^^^ + +Added methods: + +* :ref:`getArtistInfo ` +* :ref:`getArtistInfo2 ` +* :ref:`getSimilarSongs ` +* :ref:`getSimilarSongs2 ` + +Version 1.12.0 +^^^^^^^^^^^^^^ + +Added methods: + +* :ref:`getPlayQueue ` +* :ref:`savePlayQueue ` + +Added method parameters: + +* :ref:`getAlbumList ` + + * ``musicFolderId`` + +* :ref:`getAlbumList2 ` + + * ``musicFolderId`` + +* :ref:`getSongsByGenre ` + + * ``musicFolderId`` + +* :ref:`getStarred ` + + * ``musicFolderId`` + +* :ref:`getStarred2 ` + + * ``musicFolderId`` + +* :ref:`search2 ` + + * ``musicFolderId`` + +* :ref:`search3 ` + + * ``musicFolderId`` + +* :ref:`createUser ` + + * ``musicFolderId`` + +* :ref:`updateUser ` + + * ``musicFolderId`` + +Version 1.13.0 +^^^^^^^^^^^^^^ + +Added global parameters: + +* ``t`` +* ``s`` + +Added methods: + +* :ref:`getTopSongs ` + +Added method parameters: + +* :ref:`updateUser ` + + * ``maxBitRate`` + +Version 1.14.0 +^^^^^^^^^^^^^^ + +Added methods: + +* :ref:`getAlbumInfo ` +* :ref:`getAlbumInfo2 ` +* :ref:`getNewestPodcasts ` + +Added method parameters: + +* :ref:`getArtists ` + + * ``musicFolderId`` + +* :ref:`createUser ` + + * ``videoConversionRole`` + +* :ref:`updateUser ` + + * ``videoConversionRole`` + +Version 1.15.0 +^^^^^^^^^^^^^^ + +Added error code ``41`` + +Added methods: + +* :ref:`getVideoInfo ` +* :ref:`getCaptions ` +* :ref:`getScanStatus ` +* :ref:`startScan ` + +Added method parameters: + +* :ref:`stream ` + + * ``converted`` + +* :ref:`hls ` + + * ``audioTrack`` + +Version 1.16.0 +^^^^^^^^^^^^^^ + +Added methods: + +* :ref:`createInternetRadioStation ` +* :ref:`updateInternetRadioStation ` +* :ref:`deleteInternetRadioStation ` From c8db5b81ab26a9896ebb8fbeb3974efabe38ed72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 2 Jan 2021 17:24:30 +0100 Subject: [PATCH 042/237] Simplify API cross references --- docs/api.rst | 264 +++++++++++++++++++++++++-------------------------- 1 file changed, 132 insertions(+), 132 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index d3612a29..73b075b5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -28,86 +28,86 @@ or with version 1.8.0. All methods / pseudo-TOC ^^^^^^^^^^^^^^^^^^^^^^^^ -============================================================== ====== = -Method Vers. -============================================================== ====== = -:ref:`ping ` ✔️ -:ref:`getLicense ` ✔️ -:ref:`getMusicFolders ` ✔️ -:ref:`getIndexes ` ✔️ -:ref:`getMusicDirectory ` ✔️ -:ref:`getGenres ` 1.9.0 ✔️ -:ref:`getArtists ` ✔️ -:ref:`getArtist ` ✔️ -:ref:`getAlbum ` ✔️ -:ref:`getSong ` ✔️ -:ref:`getVideos ` ❌ -:ref:`getVideoInfo ` 1.15.0 🔴 -:ref:`getArtistInfo ` 1.11.0 📅 -:ref:`getArtistInfo2 ` 1.11.0 📅 -:ref:`getAlbumInfo ` 1.14.0 📅 -:ref:`getAlbumInfo2 ` 1.14.0 📅 -:ref:`getSimilarSongs ` 1.11.0 ❔ -:ref:`getSimilarSongs2 ` 1.11.0 ❔ -:ref:`getTopSongs ` 1.13.0 ❔ -:ref:`getAlbumList ` ✔️ -:ref:`getAlbumList2 ` ✔️ -:ref:`getRandomSongs ` ✔️ -:ref:`getSongsByGenre ` 1.9.0 ✔️ -:ref:`getNowPlaying ` ✔️ -:ref:`getStarred ` ✔️ -:ref:`getStarred2 ` ✔️ -:ref:`search ` ✔️ -:ref:`search2 ` ✔️ -:ref:`search3 ` ✔️ -:ref:`getPlaylists ` ✔️ -:ref:`getPlaylist ` ✔️ -:ref:`createPlaylist ` ✔️ -:ref:`updatePlaylist ` ✔️ -:ref:`deletePlaylist ` ✔️ -:ref:`stream ` ✔️ -:ref:`download ` ✔️ -:ref:`hls ` 1.9.0 🔴 -:ref:`getCaptions ` 1.15.0 🔴 -:ref:`getCoverArt ` ✔️ -:ref:`getLyrics ` ✔️ -:ref:`getAvatar ` ❌ -:ref:`star ` ✔️ -:ref:`unstar ` ✔️ -:ref:`setRating ` ✔️ -:ref:`scrobble ` ✔️ -:ref:`getShares ` ❌ -:ref:`createShare ` ❌ -:ref:`updateShare ` ❌ -:ref:`deleteShare ` ❌ -:ref:`getPodcasts ` ❔ -:ref:`getNewestPodcasts ` 1.14.0 ❔ -:ref:`refreshPodcasts ` 1.9.0 ❔ -:ref:`createPodcastChannel ` 1.9.0 ❔ -:ref:`deletePodcastChannel ` 1.9.0 ❔ -:ref:`deletePodcastEpisode ` 1.9.0 ❔ -:ref:`downloadPodcastEpisode ` 1.9.0 ❔ -:ref:`jukeboxControl ` ✔️ -:ref:`getInternetRadioStations ` 1.9.0 ✔️ -:ref:`createInternetRadioStation ` 1.16.0 ✔️ -:ref:`updateInternetRadioStation ` 1.16.0 ✔️ -:ref:`deleteInternetRadioStation ` 1.16.0 ✔️ -:ref:`getChatMessages ` ✔️ -:ref:`addChatMessage ` ✔️ -:ref:`getUser ` ✔️ -:ref:`getUsers ` 1.9.0 ✔️ -:ref:`createUser ` ✔️ -:ref:`updateUser ` 1.10.2 ✔️ -:ref:`deleteUser ` ✔️ -:ref:`changePassword ` ✔️ -:ref:`getBookmarks ` 1.9.0 ❔ -:ref:`createBookmark ` 1.9.0 ❔ -:ref:`deleteBookmark ` 1.9.0 ❔ -:ref:`getPlayQueue ` 1.12.0 ❔ -:ref:`savePlayQueue ` 1.12.0 ❔ -:ref:`getScanStatus ` 1.15.0 ✔️ -:ref:`startScan ` 1.15.0 ✔️ -============================================================== ====== = +=========================== ====== = +Method Vers. +=========================== ====== = +ping_ ✔️ +getLicense_ ✔️ +getMusicFolders_ ✔️ +getIndexes_ ✔️ +getMusicDirectory_ ✔️ +getGenres_ 1.9.0 ✔️ +getArtists_ ✔️ +getArtist_ ✔️ +getAlbum_ ✔️ +getSong_ ✔️ +getVideos_ ❌ +getVideoInfo_ 1.15.0 🔴 +getArtistInfo_ 1.11.0 📅 +getArtistInfo2_ 1.11.0 📅 +getAlbumInfo_ 1.14.0 📅 +getAlbumInfo2_ 1.14.0 📅 +getSimilarSongs_ 1.11.0 ❔ +getSimilarSongs2_ 1.11.0 ❔ +getTopSongs_ 1.13.0 ❔ +getAlbumList_ ✔️ +getAlbumList2_ ✔️ +getRandomSongs_ ✔️ +getSongsByGenre_ 1.9.0 ✔️ +getNowPlaying_ ✔️ +getStarred_ ✔️ +getStarred2_ ✔️ +search_ ✔️ +search2_ ✔️ +search3_ ✔️ +getPlaylists_ ✔️ +getPlaylist_ ✔️ +createPlaylist_ ✔️ +updatePlaylist_ ✔️ +deletePlaylist_ ✔️ +stream_ ✔️ +download_ ✔️ +hls_ 1.9.0 🔴 +getCaptions_ 1.15.0 🔴 +getCoverArt_ ✔️ +getLyrics_ ✔️ +getAvatar_ ❌ +star_ ✔️ +unstar_ ✔️ +setRating_ ✔️ +scrobble_ ✔️ +getShares_ ❌ +createShare_ ❌ +updateShare_ ❌ +deleteShare_ ❌ +getPodcasts_ ❔ +getNewestPodcasts_ 1.14.0 ❔ +refreshPodcasts_ 1.9.0 ❔ +createPodcastChannel_ 1.9.0 ❔ +deletePodcastChannel_ 1.9.0 ❔ +deletePodcastEpisode_ 1.9.0 ❔ +downloadPodcastEpisode_ 1.9.0 ❔ +jukeboxControl_ ✔️ +getInternetRadioStations_ 1.9.0 ✔️ +createInternetRadioStation_ 1.16.0 ✔️ +updateInternetRadioStation_ 1.16.0 ✔️ +deleteInternetRadioStation_ 1.16.0 ✔️ +getChatMessages_ ✔️ +addChatMessage_ ✔️ +getUser_ ✔️ +getUsers_ 1.9.0 ✔️ +createUser_ ✔️ +updateUser_ 1.10.2 ✔️ +deleteUser_ ✔️ +changePassword_ ✔️ +getBookmarks_ 1.9.0 ❔ +createBookmark_ 1.9.0 ❔ +deleteBookmark_ 1.9.0 ❔ +getPlayQueue_ 1.12.0 ❔ +savePlayQueue_ 1.12.0 ❔ +getScanStatus_ 1.15.0 ✔️ +startScan_ 1.15.0 ✔️ +=========================== ====== = Global ^^^^^^ @@ -449,7 +449,7 @@ Album/song lists Searching ^^^^^^^^^ -.. _search-: +.. _search: ``search`` ✔️ @@ -1104,31 +1104,31 @@ Version 1.9.0 Added methods: -* :ref:`getGenres ` -* :ref:`getSongsByGenre ` -* :ref:`hls ` -* :ref:`refreshPodcasts ` -* :ref:`createPodcastChannel ` -* :ref:`deletePodcastChannel ` -* :ref:`deletePodcastEpisode ` -* :ref:`downloadPodcastEpisode ` -* :ref:`getInternetRadioStations ` -* :ref:`getUsers ` -* :ref:`getBookmarks ` -* :ref:`createBookmark ` -* :ref:`deleteBookmark ` +* getGenres_ +* getSongsByGenre_ +* hls_ +* refreshPodcasts_ +* createPodcastChannel_ +* deletePodcastChannel_ +* deletePodcastEpisode_ +* downloadPodcastEpisode_ +* getInternetRadioStations_ +* getUsers_ +* getBookmarks_ +* createBookmark_ +* deleteBookmark_ Added method parameters: -* :ref:`updatePlaylist ` +* updatePlaylist_ * ``public`` -* :ref:`scrobble ` +* scrobble_ * ``time`` -* :ref:`getPodcasts ` +* getPodcasts_ * ``includeEpisodes`` * ``id`` @@ -1138,13 +1138,13 @@ Version 1.10.1 Added method parameters: -* :ref:`getAlbumList ` +* getAlbumList_ * ``fromYear`` * ``toYear`` * ``genre`` -* :ref:`getAlbumList2 ` +* getAlbumList2_ * ``fromYear`` * ``toYear`` @@ -1155,61 +1155,61 @@ Version 1.10.2 Added methods: -* :ref:`updateUser ` +* updateUser_ Version 1.11.0 ^^^^^^^^^^^^^^ Added methods: -* :ref:`getArtistInfo ` -* :ref:`getArtistInfo2 ` -* :ref:`getSimilarSongs ` -* :ref:`getSimilarSongs2 ` +* getArtistInfo_ +* getArtistInfo2_ +* getSimilarSongs_ +* getSimilarSongs2_ Version 1.12.0 ^^^^^^^^^^^^^^ Added methods: -* :ref:`getPlayQueue ` -* :ref:`savePlayQueue ` +* getPlayQueue_ +* savePlayQueue_ Added method parameters: -* :ref:`getAlbumList ` +* getAlbumList_ * ``musicFolderId`` -* :ref:`getAlbumList2 ` +* getAlbumList2_ * ``musicFolderId`` -* :ref:`getSongsByGenre ` +* getSongsByGenre_ * ``musicFolderId`` -* :ref:`getStarred ` +* getStarred_ * ``musicFolderId`` -* :ref:`getStarred2 ` +* getStarred2_ * ``musicFolderId`` -* :ref:`search2 ` +* search2_ * ``musicFolderId`` -* :ref:`search3 ` +* search3_ * ``musicFolderId`` -* :ref:`createUser ` +* createUser_ * ``musicFolderId`` -* :ref:`updateUser ` +* updateUser_ * ``musicFolderId`` @@ -1223,11 +1223,11 @@ Added global parameters: Added methods: -* :ref:`getTopSongs ` +* getTopSongs_ Added method parameters: -* :ref:`updateUser ` +* updateUser_ * ``maxBitRate`` @@ -1236,21 +1236,21 @@ Version 1.14.0 Added methods: -* :ref:`getAlbumInfo ` -* :ref:`getAlbumInfo2 ` -* :ref:`getNewestPodcasts ` +* getAlbumInfo_ +* getAlbumInfo2_ +* getNewestPodcasts_ Added method parameters: -* :ref:`getArtists ` +* getArtists_ * ``musicFolderId`` -* :ref:`createUser ` +* createUser_ * ``videoConversionRole`` -* :ref:`updateUser ` +* updateUser_ * ``videoConversionRole`` @@ -1261,18 +1261,18 @@ Added error code ``41`` Added methods: -* :ref:`getVideoInfo ` -* :ref:`getCaptions ` -* :ref:`getScanStatus ` -* :ref:`startScan ` +* getVideoInfo_ +* getCaptions_ +* getScanStatus_ +* startScan_ Added method parameters: -* :ref:`stream ` +* stream_ * ``converted`` -* :ref:`hls ` +* hls_ * ``audioTrack`` @@ -1281,6 +1281,6 @@ Version 1.16.0 Added methods: -* :ref:`createInternetRadioStation ` -* :ref:`updateInternetRadioStation ` -* :ref:`deleteInternetRadioStation ` +* createInternetRadioStation_ +* updateInternetRadioStation_ +* deleteInternetRadioStation_ From fb41bf28148e29d1f2366b04ce8780c870a4dc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 3 Jan 2021 16:34:29 +0100 Subject: [PATCH 043/237] Improve manpages --- docs/cli.md | 74 ---------------------------- docs/jukebox.rst | 3 +- docs/man/supysonic-cli-folder.rst | 64 +++++++++++++++++-------- docs/man/supysonic-cli-user.rst | 80 ++++++++++++++++++++----------- docs/man/supysonic-cli.rst | 51 ++++++++++---------- docs/man/supysonic-daemon.rst | 34 +++++++------ 6 files changed, 142 insertions(+), 164 deletions(-) delete mode 100644 docs/cli.md diff --git a/docs/cli.md b/docs/cli.md deleted file mode 100644 index 7e5ee366..00000000 --- a/docs/cli.md +++ /dev/null @@ -1,74 +0,0 @@ -# Command line interface - -The command-line interface (often abbreviated CLI) is an interface allowing -administration operations without the use of the web interface. It can either -be run in interactive mode (`supysonic-cli`) or to issue a single command -(`supysonic-cli `). - -If ran without arguments, `supysonic-cli` will open an interactive prompt. You -can use the command line tool to do a few things: - -## Help commands - -Whenever you are lost - -``` -Usage: - supysonic-cli help - supysonic-cli help user - supysonic-cli help folder - -Arguments: - user Display the help message for the user command - folder Display the help message for the folder command -``` - -## User management commands - -``` -Usage: - supysonic-cli user add [-p ] [-e ] - supysonic-cli user delete - supysonic-cli user changepass - supysonic-cli user list - supysonic-cli user setroles [-a|-A] [-j|-J] - -Arguments: - add Add a new user - delete Delete the user - changepass Change the user's password - list List all the users - setroles Give or remove rights to the user - -Options: - -p --password Specify the user's password - -e --email Specify the user's email - -a --noadmin Revoke admin rights - -A --admin Grant admin rights - -j --nojukebox Revoke jukebox rights - -J --jukebox Grant jukebox rights -``` - -## Folder management commands - -``` -Usage: - supysonic-cli folder add - supysonic-cli folder delete - supysonic-cli folder list - supysonic-cli folder scan [-f] [--background | --foreground] [...] - -Arguments: - add Add a new folder - delete Delete a folder - list List all the folders - scan Scan all or specified folders - -Options: - -f --force Force scan of already known files even if they - haven't changed - --background Scan in the background. Requires the daemon to - be running. - --foreground Scan in the foreground, blocking the process - while the scan is running -``` diff --git a/docs/jukebox.rst b/docs/jukebox.rst index 80dda290..3066b4b0 100644 --- a/docs/jukebox.rst +++ b/docs/jukebox.rst @@ -39,6 +39,7 @@ Allowing users to act on the jukebox ------------------------------------ The jukebox mode is only accessible to chosen users. Granting (or revoking) -jukebox usage rights to a specific user is done with the :doc:`cli`:: +jukebox usage rights to a specific user is done with the +:doc:`command line interface `:: $ supysonic-cli user setroles --jukebox diff --git a/docs/man/supysonic-cli-folder.rst b/docs/man/supysonic-cli-folder.rst index 9d0dee5f..869fbd59 100644 --- a/docs/man/supysonic-cli-folder.rst +++ b/docs/man/supysonic-cli-folder.rst @@ -6,50 +6,72 @@ supysonic-cli-folder Supysonic folder management commands ------------------------------------ -:Author: Louis-Philippe Véronneau -:Date: 2019 +:Author: Louis-Philippe Véronneau, Alban Féron +:Date: 2019, 2021 :Manual section: 1 Synopsis ======== -| supysonic-cli folder **add** -| supysonic-cli folder **delete** -| supysonic-cli folder **list** -| supysonic-cli folder **scan** [-f] [--background | --foreground] [...] +| ``supysonic-cli folder list`` +| ``supysonic-cli folder add`` `name` `path` +| ``supysonic-cli folder delete`` `name` +| ``supysonic-cli folder scan`` [``--force``] [``--background``\|\ ``--foreground``] [`name`]... -Arguments -========= +Description +=========== -| **add** Add a new folder -| **delete** Delete a folder -| **list** List all the folders -| **scan** Scan all or specified folders +The ``supysonic-cli folder`` subcommand manages your library folders, where the +audio files are located. This allows to list, add, delete and scan the folders. + +``supysonic-cli folder list`` + List all the folders. + +``supysonic-cli folder add`` `name` `path` + Add a new library folder called `name` and located at `path`. `name` must be + unique and `path` pointing to an existing directory. If ``supysonic-daemon`` + is running it will start to listen for changes in this folder but will not + scan files already present in the folder. + +``supysonic-cli folder delete`` `name` + Delete the folder called `name`. + +``supysonic-cli folder scan`` [``--force``] [``--background``\|\ ``--foreground``] [`name`]... + Scan the specified folders. If none is given, all the registered folders are + scanned. Options ======= -| **-f** | **--force** -|     Force scan of already known files even if they haven't changed +-f, --force + Force scan of already known files even if they haven't changed. Might be + useful if an update to *Supysonic* adds new metadata to audio files. + +--background + Scan in the background. Requires the ``supysonic-daemon`` to be running -| **--background** -|     Scan in the background. Requires the daemon to be running +--foreground + Scan in the foreground, blocking the process while the scan is running -| **--foreground** -|     Scan in the foreground, blocking the process while the scan is running +If neither ``--background`` nor ``--foreground`` is provided, ``supysonic-cli`` +will try to connect to the daemon to initiate a background scan, falling back to +a foreground scan if it isn't available. Examples ======== To add a new folder to your music library, you can do something like this:: - $ supysonic-cli folder add MyLibrary /home/username/Music + $ supysonic-cli folder add MyLibrary /home/username/Music Once you've added a folder, you will need to scan it:: - $ supysonic-cli folder scan MyLibrary + $ supysonic-cli folder scan MyLibrary + +The audio files residing in `/home/username/Music` will now appear under the +`MyLibrary` folder on the clients. See Also ======== -supysonic-cli(1), supysonic-cli-user(1) +``supysonic-cli``\ (1), ``supysonic-cli-user``\ (1) diff --git a/docs/man/supysonic-cli-user.rst b/docs/man/supysonic-cli-user.rst index 33096cc1..c25df9ea 100644 --- a/docs/man/supysonic-cli-user.rst +++ b/docs/man/supysonic-cli-user.rst @@ -6,56 +6,78 @@ supysonic-cli-user Supysonic user management commands ---------------------------------- -:Author: Louis-Philippe Véronneau -:Date: 2019 +:Author: Louis-Philippe Véronneau, Alban Féron +:Date: 2019, 2021 :Manual section: 1 Synopsis ======== -| supysonic-cli user **add** [-p ] [-e ] -| supysonic-cli user **delete** -| supysonic-cli user **changepass** -| supysonic-cli user **list** -| supysonic-cli user **setroles** [-a|-A] [-j|-J] +| ``supysonic-cli user list`` +| ``supysonic-cli user add`` `user` [``--password`` `password`] [``--email`` `email`] +| ``supysonic-cli user delete`` `user` +| ``supysonic-cli user changepass`` `user` `password` +| ``supysonic-cli user setroles`` [``--admin``\|\ ``--noadmin``] [``--jukebox``\|\ ``--nojukebox``] `user` -Arguments -========= +Description +=========== -| **add** Add a new user -| **delete** Delete the user -| **changepass** Change the user's password -| **list** List all the users -| **setroles** Give or remove rights to the user +The ``supysonic-cli user`` subcommand manages users, allowing to list them, add +a new user, delete an existing user, and change their password or roles. + +``supysonic-cli user list`` + List all the users. + +``supysonic-cli user add`` `user` [``--password`` `password`] [``--email`` `email`] + Add a new user named `user`. Will prompt for a password if it isn't given + with the ``--password`` option. + +``supysonic-cli user delete`` `user` + Delete the user `user`. + +``supysonic-cli user changepass`` `user` [`password`] + Change the password of user `user`. Will prompt for the new password if not + provided. + +``supysonic-cli user setroles`` [``--admin``\|\ ``--noadmin``] [``--jukebox``\|\ ``--nojukebox``] `user` + Give or remove rights to user `user`. Options ======= -| **-p** | **--password** ** -|     Specify the user's password +-p password, --password password + Specify the user's password upon creation. + +-e email, --email email + Specify the user's email. -| **-e** | **--email** ** -|     Specify the user's email +The next options relate to user roles. They work in pairs, one option granting +a right while the other revokes it; obviously options of the same pair are +mutually exclusive. The long options are named with the matching right, prefix +it with a ``no`` to revoke the right. For short options, the upper case letter +grants the right while the lower case letter revokes it. Short options might be +combined into a single one such as ``-aJ`` to both revoke the admin right and +grant the jukebox one. -| **-a** | **--noadmin** -|     Revoke admin rights +-A, --admin + Grant admin rights. -| **-A** | **--admin** -|     Grant admin rights +-a, --noadmin + Revoke admin rights. -| **-j** | **--nojukebox** -|     Revoke jukebox rights +-J, --jukebox + Grant jukebox rights. -| **-J** | **--jukebox** -|     Grant jukebox rights +-j, --nojukebox + Revoke jukebox rights. Examples ======== -To add a new admin user:: +To add a new admin user named `MyUserName` having password `MyAwesomePassword`:: - $ supysonic-cli user add MyUserName -p MyAwesomePassword - $ supysonic-cli user setroles -A MyUserName + $ supysonic-cli user add MyUserName -p MyAwesomePassword + $ supysonic-cli user setroles -A MyUserName See Also ======== diff --git a/docs/man/supysonic-cli.rst b/docs/man/supysonic-cli.rst index d9d71b34..a19de78c 100644 --- a/docs/man/supysonic-cli.rst +++ b/docs/man/supysonic-cli.rst @@ -6,25 +6,21 @@ supysonic-cli Python implementation of the Subsonic server API ------------------------------------------------ -:Author: Louis-Philippe Véronneau -:Date: 2019 +:Author: Louis-Philippe Véronneau, Alban Féron +:Date: 2019, 2021 :Manual section: 1 Synopsis ======== -| supysonic-cli [**subcommand**] -| supysonic-cli **help** -| supysonic-cli **help** *user* -| supysonic-cli **help** *folder* +| ``supysonic-cli`` [`subcommand`] +| ``supysonic-cli help`` [`subcommand`] Description =========== -| supysonic is a Python implementation of the Subsonic server API. -| Current supported features are: - -| +Supysonic is a Python implementation of the Subsonic server API. +Current supported features are: | * browsing (by folders or tags) | * streaming of various audio file formats @@ -33,34 +29,41 @@ Description | * cover arts (as image files in the same folder as music files) | * starred tracks/albums and ratings | * Last.FM scrobbling +| * Jukebox mode + +The "Subsonic API" is a set of adhoc standards to browse, stream or download a +music collection over HTTP. -| The "Subsonic API" is a set of adhoc standards to browse, stream or -| download a music collection over HTTP. +The command-line interface is an interface allowing administration operations +without the use of the web interface. If ran without arguments, +``supysonic-cli`` will open an interactive prompt, with arguments it will run +a single command and exit. Subcommands =========== -| If ran without arguments, **supysonic-cli** will open an interactive -| prompt. +``supysonic-cli`` has three different subcommands: -**supysonic-cli** has three different subcommands: +``help`` [`subcommand`] + When used without argument, displays the list of available subcommands. With + an argument, shows the help and arguments for the given subcommand. -| +``user`` `args` ... + User management commands -| * help -| * user -| * folder +``folder`` `args` ... + Folder managemnt commands -| For more details on the **user** and **folder** subcommands, see the -| subsonic-cli-user(1), subsonic-cli-folder(1) manual pages. +For more details on the ``user`` and ``folder`` subcommands, see the +``subsonic-cli-user``\ (1), ``subsonic-cli-folder``\ (1) manual pages. Bugs ==== -| Bugs can be reported to your distribution's bug tracker or upstream -| at https://github.com/spl0k/supysonic/issues. +Bugs can be reported to your distribution's bug tracker or upstream +at https://github.com/spl0k/supysonic/issues. See Also ======== -supysonic-cli-user(1), supysonic-cli-folder(1) +``supysonic-cli-user``\ (1), ``supysonic-cli-folder``\ (1) diff --git a/docs/man/supysonic-daemon.rst b/docs/man/supysonic-daemon.rst index d343e296..39acd79d 100644 --- a/docs/man/supysonic-daemon.rst +++ b/docs/man/supysonic-daemon.rst @@ -2,37 +2,41 @@ supysonic-daemon ================ ------------------------- -Supysonic scanner daemon ------------------------- +--------------------------- +Supysonic background daemon +--------------------------- -:Author: Louis-Philippe Véronneau -:Date: 2019 +:Author: Louis-Philippe Véronneau, Alban Féron +:Date: 2019, 2021 :Manual section: 1 Synopsis ======== -| supysonic-daemon +``supysonic-daemon`` Description =========== -| **supysonic-daemon** is an optional non-exiting process made to be ran in the -| background to manage background scans and library changes detection. +``supysonic-daemon`` is an optional non-exiting process made to be ran in the +background to manage background scans, library changes detection and the jukebox +mode (audio played on the server hardware). -| If **supysonic-daemon** is running when you start a manual scan using -| **supysonic-cli(1)**, the scan will be run by the daemon process in the -| background instead of running in the foreground. This daemon also enables the -| web UI scan feature. +If ``supysonic-daemon`` is running when you start a manual scan using +``supysonic-cli``\ (1), the scan will be run by the daemon process in the +background instead of running in the foreground. This daemon also enables the +web UI scan feature. + +With proper configuration, ``supysonic-daemon`` also allows authorized users to +play audio on the machine's hardware, using their client as a remote control. Bugs ==== -| Bugs can be reported to your distribution's bug tracker or upstream -| at https://github.com/spl0k/supysonic/issues. +Bugs can be reported to your distribution's bug tracker or upstream +at https://github.com/spl0k/supysonic/issues. See Also ======== -supysonic-cli(1) +``supysonic-cli``\ (1) From c52141e5e99ff8ca69971681979608ed6609485e Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 7 Jan 2021 20:18:25 +0100 Subject: [PATCH 044/237] change distinct following #208 --- supysonic/api/albums_songs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 469d60ff..72ddae13 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -7,7 +7,7 @@ from datetime import timedelta from flask import request -from pony.orm import select, desc, avg, max, min, count, between +from pony.orm import select, desc, avg, max, min, count, between, distinct from ..db import ( Folder, @@ -81,7 +81,7 @@ def album_list(): dict( album=[ a.as_subsonic_child(request.user) - for a in query.distinct().random(size) + for a in distinct(query.random(size)) ] ), ) From f92c7110aeba4cc9cb8dd47c967bfe5603c3696e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 9 Jan 2021 16:05:49 +0100 Subject: [PATCH 045/237] Installation doc --- docs/setup/index.rst | 10 ++++ docs/setup/install.rst | 110 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 docs/setup/index.rst create mode 100644 docs/setup/install.rst diff --git a/docs/setup/index.rst b/docs/setup/index.rst new file mode 100644 index 00000000..58231fc2 --- /dev/null +++ b/docs/setup/index.rst @@ -0,0 +1,10 @@ +Supysonic setup +=============== + +This guide details the required steps to get a *Supysonic* instance ready to +start serving your music. + +.. toctree:: + :maxdepth: 2 + + install diff --git a/docs/setup/install.rst b/docs/setup/install.rst new file mode 100644 index 00000000..58b2705b --- /dev/null +++ b/docs/setup/install.rst @@ -0,0 +1,110 @@ +Installing Supysonic +==================== + +Supysonic is written in Python and supports Python 3.5+. + +Linux +----- + +Currently, only Debian-based distributions might provide Supysonic in their +package repositories. Install the package ``supysonic`` using either +:command:`apt` or :command:`apt-get`:: + + $ apt-get install supysonic + +This will install Supysonic along with the minimal dependencies it needs to +run. + +.. note:: + + As of January 2021, Supysonic only reached Debian's *testing* release. If + you're using the *stable* release it might not be available in the packages + yet. + +If you plan on using it with a MySQL or PostgreSQL you also need the +corresponding Python package, ``python-pymysql`` for MySQL or +``python-psycopg2`` for PostgreSQL. + +:: + + $ apt-get install python-pymysql + +:: + + $ apt-get install python-psycopg2 + +For other distributions, you might consider installing from `docker`_ images or +from `source`_. + +Windows +------- + +.. note:: + While Supysonic hasn't been thoroughly tested on Windows, it *should* work. + If something is broken, we're really sorry. Don't hesitate to `open an + issue`__ on GitHub. + + __ https://github.com/spl0k/supysonic/issues + +Most Windows users do not have Python installed by default, so we begin with +the installation of Python itself. To check if you already have Python +installed, open the *Command Prompt* (:kbd:`Win-R` and type :command:`cmd`). +Once the command prompt is open, type :command:`python --version` and press +Enter. If Python is installed, you will see the version of Python printed to +the screen. If you do not have Python installed, refer to the `Hitchhikers +Guide to Python's`__ Python on Windows installation guides. You must install +`Python 3`__. + +Once Python is installed, you can install Supysonic using :command:`pip`. Refer +to the `source installation instructions `_ below for more information. + +__ https://docs.python-guide.org/ +__ https://docs.python-guide.org/starting/install3/win/ + +.. _docker: + +Docker +------ + +While we don't provide Docker images for Supysonic, that didn't keep the +community from creating some. Take a look on the `Docker Hub`__ and pick one you +like. For more details on their usage, please refer to the readme of said +images. + +__ https://hub.docker.com/search?q=supysonic&type=image + +.. _source: + +Source +------ + +You can install Supysonic directly from a clone of the `Git repository`__. This +can be done either by cloning the repo and installing from the local clone, or +simply installing directly via :command:`pip`. + +:: + + $ git clone https://github.com/spl0k/supysonic.git + $ cd supysonic + $ pip install . + +:: + + $ pip install git+https://github.com/spl0k/supysonic.git + +This will install Supysonic along with the minimal dependencies it needs to +run. + +If you plan on using it with a MySQL or PostgreSQL you also need the +corresponding package, ``pymysql`` for MySQL or ``psycopg2-binary`` for +PostgreSQL. + +:: + + $ pip install pymysql + +:: + + $ pip install psycopg2-binary + +__ https://github.com/spl0k/supysonic From 15e5114cd916c135768a3a0b36d8741d54580cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 9 Jan 2021 16:58:00 +0100 Subject: [PATCH 046/237] Database setup doc --- docs/setup/database.rst | 39 +++++++++++++++++++++++++++++++++++++++ docs/setup/index.rst | 1 + docs/setup/install.rst | 4 ++-- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 docs/setup/database.rst diff --git a/docs/setup/database.rst b/docs/setup/database.rst new file mode 100644 index 00000000..cd089faa --- /dev/null +++ b/docs/setup/database.rst @@ -0,0 +1,39 @@ +Database setup +============== + +Supysonic needs a database to run. It can either be a SQLite, MySQL-compatible +or PostgreSQL database. + +If you absolutely have no clue about databases, you can go with SQLite as it +doesn't need any setup other than specifying a path for the database in the +:doc:`configuration <../configuration>`. + +.. note:: + + SQLite, while being a viable option, isn't recommended for large + installations. First of all its performance *might* start to decrease as the + size of your library grows. But most importantly if you have a lot of users + reaching the instance at the same time you will start to see the performance + drop, or even errors. + +Please refer to the documentation of the DBMS you've chosen on how to create a +database. Once it has a database, Supysonic will automatically create the +tables it needs and keep the schema up-to-date. + +The PostgreSQL case +------------------- + +If you want to use PostgreSQL you'll have to add the ``citext`` extension to the +database once created. This can be done when connected to the database as the +superuser. How to connect as a superuser might change depending on your +PostgreSQL installation (this is **not** the same thing as the OS superuser +known as *root* on Linux systems). + +On a Debian-based system you can connect as a superuser by invoking +:command:`psql` while being logged in as the *postgres* user. The following +commands will install the ``citext`` extension on the database named *supysonic* +assuming you are currently logged as *root*. :: + + # su - postgres + $ psql supysonic + supysonic=# CREATE EXTENSION citext; diff --git a/docs/setup/index.rst b/docs/setup/index.rst index 58231fc2..f3473057 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -8,3 +8,4 @@ start serving your music. :maxdepth: 2 install + database diff --git a/docs/setup/install.rst b/docs/setup/install.rst index 58b2705b..b297f1d8 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -21,7 +21,7 @@ run. you're using the *stable* release it might not be available in the packages yet. -If you plan on using it with a MySQL or PostgreSQL you also need the +If you plan on using it with a MySQL or PostgreSQL database you also need the corresponding Python package, ``python-pymysql`` for MySQL or ``python-psycopg2`` for PostgreSQL. @@ -95,7 +95,7 @@ simply installing directly via :command:`pip`. This will install Supysonic along with the minimal dependencies it needs to run. -If you plan on using it with a MySQL or PostgreSQL you also need the +If you plan on using it with a MySQL or PostgreSQL database you also need the corresponding package, ``pymysql`` for MySQL or ``psycopg2-binary`` for PostgreSQL. From 33d0739c4e663883661f8c41db00dfdadd0d195a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 9 Jan 2021 17:10:56 +0100 Subject: [PATCH 047/237] Moving the configuration doc --- docs/{ => setup}/configuration.rst | 18 +++++++++++------- docs/setup/database.rst | 2 +- docs/setup/index.rst | 1 + 3 files changed, 13 insertions(+), 8 deletions(-) rename docs/{ => setup}/configuration.rst (90%) diff --git a/docs/configuration.rst b/docs/setup/configuration.rst similarity index 90% rename from docs/configuration.rst rename to docs/setup/configuration.rst index 11d91db9..5670ae51 100644 --- a/docs/configuration.rst +++ b/docs/setup/configuration.rst @@ -1,15 +1,19 @@ Configuration ============= -*Supysonic* looks for four files for its configuration: ``/etc/supysonic``, -``~/.supysonic``, ``~/.config/supysonic/supysonic.conf`` and ``supysonic.conf`` -in the current folder, merging values from all files. +Supysonic looks for four files for its configuration: :file:`/etc/supysonic`, +:file:`~/.supysonic`, :file:`~/.config/supysonic/supysonic.conf` and +:file:`supysonic.conf` in the current working directory, in this order, merging +values from all files. Configuration files must respect a structure similar to Windows INI file, with ``[section]`` headers and using a ``KEY = VALUE`` or ``KEY: VALUE`` syntax. -You'll find a roughly documented configuration sample file at the root of the -project, file conveniently named ``config.sample``. More details below. +If you cloned Supysonic from its `GitHub repository`__ you'll find a roughly +documented configuration sample file at the root of the project, file +conveniently named :file:`config.sample`. More details below. + +__ http://github.com/spl0k/supysonic ``[base]`` section ------------------ @@ -197,7 +201,7 @@ library folders and providing the jukebox feature. ``jukebox_command`` Command used by the jukebox mode to play a single file. - See the :doc:`jukebox documentation ` for more details. + See the :doc:`jukebox documentation <../jukebox>` for more details. ``log_file`` Rotating file where events generated by the file watcher are logged. @@ -277,7 +281,7 @@ This section defines command-line programs to be used to convert an audio file to another format or change its bitrate. All configurations in the sample below have **not** been thoroughly tested. For more details, please refer to the -:doc:`transcoding configuration `. +:doc:`transcoding configuration <../transcoding>`. .. code-block:: ini diff --git a/docs/setup/database.rst b/docs/setup/database.rst index cd089faa..71dc9e98 100644 --- a/docs/setup/database.rst +++ b/docs/setup/database.rst @@ -6,7 +6,7 @@ or PostgreSQL database. If you absolutely have no clue about databases, you can go with SQLite as it doesn't need any setup other than specifying a path for the database in the -:doc:`configuration <../configuration>`. +:doc:`configuration `. .. note:: diff --git a/docs/setup/index.rst b/docs/setup/index.rst index f3473057..3abbb73c 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -9,3 +9,4 @@ start serving your music. install database + configuration From 2469698261717d7ca1fc08238a735388db9bef22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 10 Jan 2021 18:01:21 +0100 Subject: [PATCH 048/237] Some deployment docs --- docs/setup/deploying/apache.rst | 54 +++++++++++++++++++++ docs/setup/deploying/index.rst | 19 ++++++++ docs/setup/deploying/wsgi-standalone.rst | 61 ++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 docs/setup/deploying/apache.rst create mode 100644 docs/setup/deploying/index.rst create mode 100644 docs/setup/deploying/wsgi-standalone.rst diff --git a/docs/setup/deploying/apache.rst b/docs/setup/deploying/apache.rst new file mode 100644 index 00000000..eba8af7b --- /dev/null +++ b/docs/setup/deploying/apache.rst @@ -0,0 +1,54 @@ +Apache and mod_wsgi +=================== + +If you are using the `Apache`__ webserver, you can use it to run Supysonic with +the help of `mod_wsgi`__. + +__ https://httpd.apache.org/ +__ https://github.com/GrahamDumpleton/mod_wsgi + +Installing `mod_wsgi` +--------------------- + +If you don't have `mod_wsgi` installed yet you have to install it and enable it +first as follows:: + + # apt install libapache2-mod-wsgi-py3 + # a2enmod wsgi + +Creating a `.wsgi` file +----------------------- + +To run Supysonic within Apache you need a :file:`supysonic.wsgi` file. Create +one somewhere and fill it with the following content:: + + from supysonic.web import create_application + application = create_application() + +Store that file somewhere that you will find it again (e.g.: +:file:`/var/www/supysonic/supysonic.wsgi`). + +Configuring Apache +------------------ + +The last thing you have to do is to edit the Apache configuration to tell it to +load the application. Here's a basic example of what it looks like: + +.. code-block:: apache + + WSGIScriptAlias /supysonic /var/www/supysonic/supysonic.wsgi + + WSGIApplicationGroup %{GLOBAL} + WSGIPassAuthorization On + Require all granted + + +With that kind of configuration, the server address will look like +`http://server/supysonic/`. + +For more information consult the `mod_wsgi documentation`__. Note that the +``WSGIPassAuthorization`` directive is required for some clients as they provide +their credentials using the *basic access authentification* mechanism rather +than as URL query parameters. + +__ https://modwsgi.readthedocs.io/en/latest/ diff --git a/docs/setup/deploying/index.rst b/docs/setup/deploying/index.rst new file mode 100644 index 00000000..9ae7ddc9 --- /dev/null +++ b/docs/setup/deploying/index.rst @@ -0,0 +1,19 @@ +Running the web server +====================== + +Once Supysonic is installed and configured, you'll have to start its web server +for the clients to be able to access the music. Here you have several options, +whether you want to run it as independant process(es), then possibly putting it +behind a reverse proxy, or running it as a WSGI application within Apache. + +As Supysonic is a WSGI application, you have numerous deployment options +available to you. If you want to deploy it to a WSGI server not listed here, +look up the server documentation about how to use a WSGI app with it. When +setting one of those, you'll want to call the :func:`create_application` factory +function from module :mod:`supysonic.web`. + +.. toctree:: + :maxdepth: 2 + + wsgi-standalone + apache diff --git a/docs/setup/deploying/wsgi-standalone.rst b/docs/setup/deploying/wsgi-standalone.rst new file mode 100644 index 00000000..d7fab3df --- /dev/null +++ b/docs/setup/deploying/wsgi-standalone.rst @@ -0,0 +1,61 @@ +Standalone WSGI Containers +========================== + +There are popular servers written in Python that contain WSGI applications and +serve HTTP. These servers stand alone when they run; you can let your clients +access them directly or proxy to them from your web server such as Apache +or nginx. + +Gunicorn +-------- + +`Gunicorn`__ "Green Unicorn" is a WSGI HTTP Server for UNIX. It's a pre-fork +worker model. Running Supysonic on this server is quite simple. First install +Gunicorn with either :command:`pip install gunicorn` or +:command:`apt install gunicorn3` (the ``gunicorn`` package in this case is +for Python 2 which isn't supported anymore). Then:: + + $ gunicorn "supysonic.web:create_application()" + +But this will only listen on the loopback interface, which isn't really useful. + +Gunicorn provides many command-line options -- see :command:`gunicorn -h`. +For example, to run Supysonic with 4 worker processes (``-w 4``) binding to all +IPv4 interfaces on port 5000 (``-b 0.0.0.0:5000``):: + + $ gunicorn -w 4 -b 0.0.0.0:5000 "supysonic.web:create_application()" + +__ https://gunicorn.org/ + +uWSGI +----- + +`uWSGI`__ is a fast application server written in C. It is very configurable +which makes it more complicated to setup than gunicorn. + +To use it, install the package ``uwsgi`` with either :command:`pip` or +:command:`apt`. Using the later, wou might also need the additional package +``uwsgi-plugin-python3``. + +Then to run Supysonic in uWSGI:: + + $ uwsgi --http-socket 0.0.0.0:5000 --module "supysonic.web:create_application()" + +If it complains about an unknown ``--module`` option, try adding +``--plugin python3``:: + + $ uwsgi --http-socket 0.0.0.0:5000 --plugin python3 --module "supysonic.web:create_application()" + +As uWSGI is highly configurable there are several options you could use to tweak +it to your liking. Detailing all it can do is way beyond the scope of this +documentation, if you're interested please refer to its documentation. + +If you plan on using uWSGI behind a nginx reverse proxy, note that nginx +provides options to integrate directly with uWSGI. You'll find an example +configuration in `Flask's documentation`__ (the framework Supysonic is built +upon). Replace the ``myapp:app`` in their example by +``supysonic.web:create_application()`` (you might need to enclose it in +double-quotes). + +__ https://uwsgi-docs.readthedocs.io/en/latest/ +__ https://flask.palletsprojects.com/en/1.1.x/deploying/uwsgi/ From 22c37277843993058bdf86c996289ce6b9774781 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 13 Jan 2021 21:05:15 +0100 Subject: [PATCH 049/237] modify route management --- supysonic/api/__init__.py | 14 +++++++++++++- supysonic/api/albums_songs.py | 16 ++++++++-------- supysonic/api/annotation.py | 10 +++++----- supysonic/api/browse.py | 18 +++++++++--------- supysonic/api/chat.py | 6 +++--- supysonic/api/jukebox.py | 4 ++-- supysonic/api/media.py | 10 +++++----- supysonic/api/playlists.py | 12 ++++++------ supysonic/api/radio.py | 10 +++++----- supysonic/api/scan.py | 6 +++--- supysonic/api/search.py | 8 ++++---- supysonic/api/system.py | 6 +++--- supysonic/api/user.py | 14 +++++++------- 13 files changed, 73 insertions(+), 61 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 7662166f..a1e8bc18 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -9,7 +9,7 @@ import binascii import uuid - +import functools from flask import request from flask import Blueprint from pony.orm import ObjectNotFound @@ -24,6 +24,18 @@ api = Blueprint("api", __name__) +def api_routing(endpoint): + def decorator(func): + viewendpoint="{}.view".format(endpoint) + @api.route(endpoint, methods=["GET", "POST"]) + @api.route(viewendpoint, methods=["GET", "POST"]) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args,**kwargs) + return wrapper + return decorator + + @api.before_request def set_formatter(): """Return a function to create the response.""" diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 72ddae13..6fa2f963 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -21,11 +21,11 @@ ) from ..db import now -from . import api +from . import api, api_routing from .exceptions import GenericError, NotFound -@api.route("/getRandomSongs.view", methods=["GET", "POST"]) +@api_routing("/getRandomSongs") def rand_songs(): size = request.values.get("size", "10") genre, fromYear, toYear, musicFolderId = map( @@ -66,7 +66,7 @@ def rand_songs(): ) -@api.route("/getAlbumList.view", methods=["GET", "POST"]) +@api_routing("/getAlbumList") def album_list(): ltype = request.values["type"] @@ -129,7 +129,7 @@ def album_list(): ) -@api.route("/getAlbumList2.view", methods=["GET", "POST"]) +@api_routing("/getAlbumList2") def album_list_id3(): ltype = request.values["type"] @@ -183,7 +183,7 @@ def album_list_id3(): ) -@api.route("/getSongsByGenre.view", methods=["GET", "POST"]) +@api_routing("/getSongsByGenre") def songs_by_genre(): genre = request.values["genre"] @@ -198,7 +198,7 @@ def songs_by_genre(): ) -@api.route("/getNowPlaying.view", methods=["GET", "POST"]) +@api_routing("/getNowPlaying") def now_playing(): query = User.select( lambda u: u.last_play is not None @@ -221,7 +221,7 @@ def now_playing(): ) -@api.route("/getStarred.view", methods=["GET", "POST"]) +@api_routing("/getStarred") def get_starred(): folders = select(s.starred for s in StarredFolder if s.user.id == request.user.id) @@ -246,7 +246,7 @@ def get_starred(): ) -@api.route("/getStarred2.view", methods=["GET", "POST"]) +@api_routing("/getStarred2") def get_starred_id3(): return request.formatter( "starred2", diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index 3d44876d..529aaaeb 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -16,7 +16,7 @@ from ..db import RatingTrack, RatingFolder from ..lastfm import LastFm -from . import api, get_entity, get_entity_id +from . import api, get_entity, get_entity_id, api_routing from .exceptions import AggregateException, GenericError, MissingParameter, NotFound @@ -108,17 +108,17 @@ def handle_star_request(func): return request.formatter.empty -@api.route("/star.view", methods=["GET", "POST"]) +@api_routing("/star") def star(): return handle_star_request(star_single) -@api.route("/unstar.view", methods=["GET", "POST"]) +@api_routing("/unstar") def unstar(): return handle_star_request(unstar_single) -@api.route("/setRating.view", methods=["GET", "POST"]) +@api_routing("/setRating") def rate(): id = request.values["id"] rating = request.values["rating"] @@ -172,7 +172,7 @@ def rate(): return request.formatter.empty -@api.route("/scrobble.view", methods=["GET", "POST"]) +@api_routing("/scrobble") def scrobble(): res = get_entity(Track) t, submission = map(request.values.get, ["time", "submission"]) diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index 728ea9b3..a3a649d9 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -13,10 +13,10 @@ from ..db import Folder, Artist, Album, Track -from . import api, get_entity, get_entity_id +from . import api, get_entity, get_entity_id, api_routing -@api.route("/getMusicFolders.view", methods=["GET", "POST"]) +@api_routing("/getMusicFolders") def list_folders(): return request.formatter( "musicFolders", @@ -49,7 +49,7 @@ def ignored_articles_str(): return " ".join(articles.split()) -@api.route("/getIndexes.view", methods=["GET", "POST"]) +@api_routing("/getIndexes") def list_indexes(): musicFolderId = request.values.get("musicFolderId") ifModifiedSince = request.values.get("ifModifiedSince") @@ -122,7 +122,7 @@ def list_indexes(): ) -@api.route("/getMusicDirectory.view", methods=["GET", "POST"]) +@api_routing("/getMusicDirectory") def show_directory(): res = get_entity(Folder) return request.formatter( @@ -130,7 +130,7 @@ def show_directory(): ) -@api.route("/getGenres.view", methods=["GET", "POST"]) +@api_routing("/getGenres") def list_genres(): return request.formatter( "genres", @@ -145,7 +145,7 @@ def list_genres(): ) -@api.route("/getArtists.view", methods=["GET", "POST"]) +@api_routing("/getArtists") def list_artists(): # According to the API page, there are no parameters? indexes = dict() @@ -183,7 +183,7 @@ def list_artists(): ) -@api.route("/getArtist.view", methods=["GET", "POST"]) +@api_routing("/getArtist") def artist_info(): res = get_entity(Artist) info = res.as_subsonic_artist(request.user) @@ -197,7 +197,7 @@ def artist_info(): return request.formatter("artist", info) -@api.route("/getAlbum.view", methods=["GET", "POST"]) +@api_routing("/getAlbum") def album_info(): res = get_entity(Album) info = res.as_subsonic_album(request.user) @@ -209,7 +209,7 @@ def album_info(): return request.formatter("album", info) -@api.route("/getSong.view", methods=["GET", "POST"]) +@api_routing("/getSong") def track_info(): res = get_entity(Track) return request.formatter( diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index 486ed8cc..1f519d70 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -8,10 +8,10 @@ from flask import request from ..db import ChatMessage -from . import api +from . import api, api_routing -@api.route("/getChatMessages.view", methods=["GET", "POST"]) +@api_routing("/getChatMessages") def get_chat(): since = request.values.get("since") since = int(since) / 1000 if since else None @@ -25,7 +25,7 @@ def get_chat(): ) -@api.route("/addChatMessage.view", methods=["GET", "POST"]) +@api_routing("/addChatMessage") def add_chat_message(): msg = request.values["message"] ChatMessage(user=request.user, message=msg) diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 63276d6e..9269b2ff 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -14,11 +14,11 @@ from ..daemon.exceptions import DaemonUnavailableError from ..db import Track -from . import api +from . import api, api_routing from .exceptions import GenericError, MissingParameter, Forbidden -@api.route("/jukeboxControl.view", methods=["GET", "POST"]) +@api_routing("/jukeboxControl") def jukebox_control(): if not request.user.jukebox and not request.user.admin: raise Forbidden() diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 3271d315..32c6263c 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -28,7 +28,7 @@ from ..cache import CacheMiss from ..db import Track, Album, Folder, now -from . import api, get_entity, get_entity_id +from . import api, get_entity, get_entity_id, api_routing from .exceptions import ( GenericError, NotFound, @@ -63,7 +63,7 @@ def prepare_transcoding_cmdline( return ret -@api.route("/stream.view", methods=["GET", "POST"]) +@api_routing("/stream") def stream_media(): res = get_entity(Track) @@ -218,7 +218,7 @@ def handle_transcoding(): return response -@api.route("/download.view", methods=["GET", "POST"]) +@api_routing("/download") def download_media(): id = request.values["id"] @@ -257,7 +257,7 @@ def download_media(): return resp -@api.route("/getCoverArt.view", methods=["GET", "POST"]) +@api_routing("/getCoverArt") def cover_art(): cache = current_app.cache @@ -316,7 +316,7 @@ def cover_art(): return send_file(cache.get(cache_key), mimetype=mimetype) -@api.route("/getLyrics.view", methods=["GET", "POST"]) +@api_routing("/getLyrics") def lyrics(): artist = request.values["artist"] title = request.values["title"] diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index bb0b60f6..46643c7a 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -11,11 +11,11 @@ from ..db import Playlist, User, Track -from . import api, get_entity +from . import api, get_entity, api_routing from .exceptions import Forbidden, MissingParameter, NotFound -@api.route("/getPlaylists.view", methods=["GET", "POST"]) +@api_routing("/getPlaylists") def list_playlists(): query = Playlist.select( lambda p: p.user.id == request.user.id or p.public @@ -40,7 +40,7 @@ def list_playlists(): ) -@api.route("/getPlaylist.view", methods=["GET", "POST"]) +@api_routing("/getPlaylist") def show_playlist(): res = get_entity(Playlist) if res.user.id != request.user.id and not res.public and not request.user.admin: @@ -53,7 +53,7 @@ def show_playlist(): return request.formatter("playlist", info) -@api.route("/createPlaylist.view", methods=["GET", "POST"]) +@api_routing("/createPlaylist") def create_playlist(): playlist_id, name = map(request.values.get, ["playlistId", "name"]) # songId actually doesn't seem to be required @@ -82,7 +82,7 @@ def create_playlist(): return request.formatter.empty -@api.route("/deletePlaylist.view", methods=["GET", "POST"]) +@api_routing("/deletePlaylist") def delete_playlist(): res = get_entity(Playlist) if res.user.id != request.user.id and not request.user.admin: @@ -92,7 +92,7 @@ def delete_playlist(): return request.formatter.empty -@api.route("/updatePlaylist.view", methods=["GET", "POST"]) +@api_routing("/updatePlaylist") def update_playlist(): res = get_entity(Playlist, "playlistId") if res.user.id != request.user.id and not request.user.admin: diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index 4bb58d85..c4647c52 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -9,11 +9,11 @@ from ..db import RadioStation -from . import api, get_entity +from . import api, get_entity, api_routing from .exceptions import Forbidden, MissingParameter -@api.route("/getInternetRadioStations.view", methods=["GET", "POST"]) +@api_routing("/getInternetRadioStations") def get_radio_stations(): query = RadioStation.select().sort_by(RadioStation.name) return request.formatter( @@ -22,7 +22,7 @@ def get_radio_stations(): ) -@api.route("/createInternetRadioStation.view", methods=["GET", "POST"]) +@api_routing("/createInternetRadioStation") def create_radio_station(): if not request.user.admin: raise Forbidden() @@ -39,7 +39,7 @@ def create_radio_station(): return request.formatter.empty -@api.route("/updateInternetRadioStation.view", methods=["GET", "POST"]) +@api_routing("/updateInternetRadioStation") def update_radio_station(): if not request.user.admin: raise Forbidden() @@ -61,7 +61,7 @@ def update_radio_station(): return request.formatter.empty -@api.route("/deleteInternetRadioStation.view", methods=["GET", "POST"]) +@api_routing("/deleteInternetRadioStation") def delete_radio_station(): if not request.user.admin: raise Forbidden() diff --git a/supysonic/api/scan.py b/supysonic/api/scan.py index 7537269e..d0c704c9 100644 --- a/supysonic/api/scan.py +++ b/supysonic/api/scan.py @@ -12,12 +12,12 @@ from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError -from . import api +from . import api, api_routing from .user import admin_only from .exceptions import ServerError -@api.route("/startScan.view", methods=["GET", "POST"]) +@api_routing("/startScan") @admin_only def startScan(): try: @@ -35,7 +35,7 @@ def startScan(): ) -@api.route("/getScanStatus.view", methods=["GET", "POST"]) +@api_routing("/getScanStatus") @admin_only def getScanStatus(): try: diff --git a/supysonic/api/search.py b/supysonic/api/search.py index a15a6b6f..d289d8a3 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -12,11 +12,11 @@ from ..db import Folder, Track, Artist, Album -from . import api +from . import api, api_routing from .exceptions import MissingParameter -@api.route("/search.view", methods=["GET", "POST"]) +@api_routing("/search") def old_search(): artist, album, title, anyf, count, offset, newer_than = map( request.values.get, @@ -83,7 +83,7 @@ def old_search(): ) -@api.route("/search2.view", methods=["GET", "POST"]) +@api_routing("/search2") def new_search(): query = request.values["query"] ( @@ -135,7 +135,7 @@ def new_search(): ) -@api.route("/search3.view", methods=["GET", "POST"]) +@api_routing("/search3") def search_id3(): query = request.values["query"] ( diff --git a/supysonic/api/system.py b/supysonic/api/system.py index b8c724a8..0a0b440d 100644 --- a/supysonic/api/system.py +++ b/supysonic/api/system.py @@ -8,14 +8,14 @@ from flask import request -from . import api +from . import api, api_routing -@api.route("/ping.view", methods=["GET", "POST"]) +@api_routing("/ping") def ping(): return request.formatter.empty -@api.route("/getLicense.view", methods=["GET", "POST"]) +@api_routing("/getLicense") def license(): return request.formatter("license", dict(valid=True)) diff --git a/supysonic/api/user.py b/supysonic/api/user.py index 19546d1b..18a402c0 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -11,7 +11,7 @@ from ..db import User from ..managers.user import UserManager -from . import api, decode_password +from . import api, decode_password, api_routing from .exceptions import Forbidden, NotFound @@ -25,7 +25,7 @@ def decorated(*args, **kwargs): return decorated -@api.route("/getUser.view", methods=["GET", "POST"]) +@api_routing("/getUser") def user_info(): username = request.values["username"] @@ -39,7 +39,7 @@ def user_info(): return request.formatter("user", user.as_subsonic_user()) -@api.route("/getUsers.view", methods=["GET", "POST"]) +@api_routing("/getUsers") @admin_only def users_info(): return request.formatter( @@ -57,7 +57,7 @@ def get_roles_dict(): return roles -@api.route("/createUser.view", methods=["GET", "POST"]) +@api_routing("/createUser") @admin_only def user_add(): username = request.values["username"] @@ -71,7 +71,7 @@ def user_add(): return request.formatter.empty -@api.route("/deleteUser.view", methods=["GET", "POST"]) +@api_routing("/deleteUser") @admin_only def user_del(): username = request.values["username"] @@ -80,7 +80,7 @@ def user_del(): return request.formatter.empty -@api.route("/changePassword.view", methods=["GET", "POST"]) +@api_routing("/changePassword") def user_changepass(): username = request.values["username"] password = request.values["password"] @@ -94,7 +94,7 @@ def user_changepass(): return request.formatter.empty -@api.route("/updateUser.view", methods=["GET", "POST"]) +@api_routing("/updateUser") @admin_only def user_edit(): username = request.values["username"] From e22620b1478f3cd92ebe60898dac064ed457d0a2 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 16 Jan 2021 09:44:06 +0100 Subject: [PATCH 050/237] change route call method --- supysonic/api/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index a1e8bc18..1f0abe04 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -27,12 +27,9 @@ def api_routing(endpoint): def decorator(func): viewendpoint="{}.view".format(endpoint) - @api.route(endpoint, methods=["GET", "POST"]) - @api.route(viewendpoint, methods=["GET", "POST"]) - @functools.wraps(func) - def wrapper(*args, **kwargs): - return func(*args,**kwargs) - return wrapper + api.add_url_rule(endpoint,view_func=func, methods=["GET", "POST"]) + api.add_url_rule(viewendpoint,view_func=func, methods=["GET", "POST"]) + return func return decorator From bba254f33991997747fcd4a239c2ef73d69070f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 16 Jan 2021 12:27:11 +0100 Subject: [PATCH 051/237] Import cleanup --- supysonic/api/__init__.py | 10 +++++----- supysonic/api/albums_songs.py | 2 +- supysonic/api/annotation.py | 2 +- supysonic/api/browse.py | 2 +- supysonic/api/chat.py | 2 +- supysonic/api/jukebox.py | 2 +- supysonic/api/media.py | 2 +- supysonic/api/playlists.py | 2 +- supysonic/api/radio.py | 2 +- supysonic/api/scan.py | 2 +- supysonic/api/search.py | 2 +- supysonic/api/system.py | 2 +- supysonic/api/unsupported.py | 3 +++ supysonic/api/user.py | 2 +- 14 files changed, 20 insertions(+), 17 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 1f0abe04..bbc09985 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -9,7 +9,6 @@ import binascii import uuid -import functools from flask import request from flask import Blueprint from pony.orm import ObjectNotFound @@ -26,13 +25,14 @@ def api_routing(endpoint): def decorator(func): - viewendpoint="{}.view".format(endpoint) - api.add_url_rule(endpoint,view_func=func, methods=["GET", "POST"]) - api.add_url_rule(viewendpoint,view_func=func, methods=["GET", "POST"]) + viewendpoint = "{}.view".format(endpoint) + api.add_url_rule(endpoint, view_func=func, methods=["GET", "POST"]) + api.add_url_rule(viewendpoint, view_func=func, methods=["GET", "POST"]) return func + return decorator - + @api.before_request def set_formatter(): """Return a function to create the response.""" diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 6fa2f963..2fc5e583 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -21,7 +21,7 @@ ) from ..db import now -from . import api, api_routing +from . import api_routing from .exceptions import GenericError, NotFound diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index 529aaaeb..d49c03a9 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -16,7 +16,7 @@ from ..db import RatingTrack, RatingFolder from ..lastfm import LastFm -from . import api, get_entity, get_entity_id, api_routing +from . import get_entity, get_entity_id, api_routing from .exceptions import AggregateException, GenericError, MissingParameter, NotFound diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index a3a649d9..6e83c271 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -13,7 +13,7 @@ from ..db import Folder, Artist, Album, Track -from . import api, get_entity, get_entity_id, api_routing +from . import get_entity, get_entity_id, api_routing @api_routing("/getMusicFolders") diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index 1f519d70..dbe5e244 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -8,7 +8,7 @@ from flask import request from ..db import ChatMessage -from . import api, api_routing +from . import api_routing @api_routing("/getChatMessages") diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 9269b2ff..28a2255a 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -14,7 +14,7 @@ from ..daemon.exceptions import DaemonUnavailableError from ..db import Track -from . import api, api_routing +from . import api_routing from .exceptions import GenericError, MissingParameter, Forbidden diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 32c6263c..27d7be1a 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -28,7 +28,7 @@ from ..cache import CacheMiss from ..db import Track, Album, Folder, now -from . import api, get_entity, get_entity_id, api_routing +from . import get_entity, get_entity_id, api_routing from .exceptions import ( GenericError, NotFound, diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index 46643c7a..174a7858 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -11,7 +11,7 @@ from ..db import Playlist, User, Track -from . import api, get_entity, api_routing +from . import get_entity, api_routing from .exceptions import Forbidden, MissingParameter, NotFound diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index c4647c52..8b5aca6b 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -9,7 +9,7 @@ from ..db import RadioStation -from . import api, get_entity, api_routing +from . import get_entity, api_routing from .exceptions import Forbidden, MissingParameter diff --git a/supysonic/api/scan.py b/supysonic/api/scan.py index d0c704c9..3e5ae95c 100644 --- a/supysonic/api/scan.py +++ b/supysonic/api/scan.py @@ -12,7 +12,7 @@ from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError -from . import api, api_routing +from . import api_routing from .user import admin_only from .exceptions import ServerError diff --git a/supysonic/api/search.py b/supysonic/api/search.py index d289d8a3..18a0444d 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -12,7 +12,7 @@ from ..db import Folder, Track, Artist, Album -from . import api, api_routing +from . import api_routing from .exceptions import MissingParameter diff --git a/supysonic/api/system.py b/supysonic/api/system.py index 0a0b440d..8bea25d3 100644 --- a/supysonic/api/system.py +++ b/supysonic/api/system.py @@ -8,7 +8,7 @@ from flask import request -from . import api, api_routing +from . import api_routing @api_routing("/ping") diff --git a/supysonic/api/unsupported.py b/supysonic/api/unsupported.py index 99c01746..ee5a4151 100644 --- a/supysonic/api/unsupported.py +++ b/supysonic/api/unsupported.py @@ -23,6 +23,9 @@ def unsupported(): for m in methods: + api.add_url_rule( + "/{}".format(m), "unsupported", unsupported, methods=["GET", "POST"] + ) api.add_url_rule( "/{}.view".format(m), "unsupported", unsupported, methods=["GET", "POST"] ) diff --git a/supysonic/api/user.py b/supysonic/api/user.py index 18a402c0..ace7c577 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -11,7 +11,7 @@ from ..db import User from ..managers.user import UserManager -from . import api, decode_password, api_routing +from . import decode_password, api_routing from .exceptions import Forbidden, NotFound From 6bb551a32a30e74bada0e2b295ff1cf581c5ca9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 16 Jan 2021 16:09:53 +0100 Subject: [PATCH 052/237] FastCGI/CGI deployment docs --- docs/setup/deploying/index.rst | 13 +++-- docs/setup/deploying/other.rst | 96 ++++++++++++++++++++++++++++++++++ docs/setup/index.rst | 1 + docs/setup/install.rst | 9 ++-- 4 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 docs/setup/deploying/other.rst diff --git a/docs/setup/deploying/index.rst b/docs/setup/deploying/index.rst index 9ae7ddc9..3d65ffd4 100644 --- a/docs/setup/deploying/index.rst +++ b/docs/setup/deploying/index.rst @@ -6,14 +6,17 @@ for the clients to be able to access the music. Here you have several options, whether you want to run it as independant process(es), then possibly putting it behind a reverse proxy, or running it as a WSGI application within Apache. -As Supysonic is a WSGI application, you have numerous deployment options -available to you. If you want to deploy it to a WSGI server not listed here, -look up the server documentation about how to use a WSGI app with it. When -setting one of those, you'll want to call the :func:`create_application` factory -function from module :mod:`supysonic.web`. +You'll find some common (and less common) deployment option below: .. toctree:: :maxdepth: 2 wsgi-standalone apache + other + +As Supysonic is a WSGI application, you have numerous deployment options +available to you. If you want to deploy it to a WSGI server not listed here, +look up the server documentation about how to use a WSGI app with it. When +setting one of those, you'll want to call the :func:`create_application` factory +function from module :mod:`supysonic.web`. diff --git a/docs/setup/deploying/other.rst b/docs/setup/deploying/other.rst new file mode 100644 index 00000000..28a9e03b --- /dev/null +++ b/docs/setup/deploying/other.rst @@ -0,0 +1,96 @@ +Other options +============= + +FastCGI +------- + +FastCGI is a deployment option on servers like `nginx`__ or `lighttpd`__; see +:doc:`wsgi-standalone` for other options. +To use Supysonic with any of them you will need a FastCGI server first. The most +popular one is `flup`__ which we will use for this guide. Make sure to have it +installed (with eith :command:`pip` or :command:`apt`) to follow along. + +__ https://nginx.org/ +__ https://www.lighttpd.net/ +__ https://pypi.org/project/flup/ + +Creating a `.fcgi` file +^^^^^^^^^^^^^^^^^^^^^^^ + +First you need to create the FastCGI server file. Let's call it +:file:`supysonic.fcgi`:: + + #!/usr/bin/python3 + + from flup.server.fcgi import WSGIServer + from supysonic.web import create_application + + if __name__ == '__main__': + app = create_application() + WSGIServer(app).run() + +This should be enough for Apache to work, however nginx and older versions of +lighttpd need a socket to be explicitly passed to communicate with the +FastCGI server. For that to work you need to pass the path to the socket +to the :class:`~flup.server.fcgi.WSGIServer`:: + + WSGIServer(app, bindAddress='/path/to/fcgi.sock').run() + +The path has to be the exact same path you define in the server +config. + +Save the :file:`supysonic.fcgi` file somewhere you will find it again. +It makes sense to have that in :file:`/var/www/supysonic` or something +similar. + +Make sure to set the executable bit on that file so that the servers +can execute it:: + + $ chmod +x /var/www/supysonic/supysonic.fcgi + +Configuring the web server +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The example above is good enough for a basic Apache deployment but your +`.fcgi` file will appear in your application URL e.g. +``example.com/supysonic.fcgi/``. If that bothers you or you wish to load it in +another web server, Flask's documentation details how to do it for `Apache`__, +`lighttpd`__ or `nginx`__. + +__ https://flask.palletsprojects.com/en/1.1.x/deploying/fastcgi/#configuring-apache +__ https://flask.palletsprojects.com/en/1.1.x/deploying/fastcgi/#configuring-lighttpd +__ https://flask.palletsprojects.com/en/1.1.x/deploying/fastcgi/#configuring-nginx + +CGI +--- + +If all other deployment methods do not work, CGI will work for sure. +CGI is supported by all major servers but usually has a sub-optimal +performance. + +Creating a `.cgi` file +^^^^^^^^^^^^^^^^^^^^^^ + +First you need to create the CGI application file. Let's call it +:file:`supysonic.cgi`:: + + #!/usr/bin/python3 + + from wsgiref.handlers import CGIHandler + from supysonic.web import create_application + + app = create_application() + CGIHandler().run(app) + +Server Setup +^^^^^^^^^^^^ + +Usually there are two ways to configure the server. Either just copy the +``.cgi`` into a :file:`cgi-bin` (and use `mod_rewrite` or something similar to +rewrite the URL) or let the server point to the file directly. + +In Apache for example you can put something like this into the config: + +.. sourcecode:: apache + + ScriptAlias /supysonic /path/to/the/supysonic.cgi diff --git a/docs/setup/index.rst b/docs/setup/index.rst index 3abbb73c..4cb79c9e 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -10,3 +10,4 @@ start serving your music. install database configuration + deploying/index diff --git a/docs/setup/install.rst b/docs/setup/install.rst index b297f1d8..2762595d 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -7,10 +7,9 @@ Linux ----- Currently, only Debian-based distributions might provide Supysonic in their -package repositories. Install the package ``supysonic`` using either -:command:`apt` or :command:`apt-get`:: +package repositories. Install the package ``supysonic`` using :command:`apt`:: - $ apt-get install supysonic + $ apt install supysonic This will install Supysonic along with the minimal dependencies it needs to run. @@ -27,11 +26,11 @@ corresponding Python package, ``python-pymysql`` for MySQL or :: - $ apt-get install python-pymysql + $ apt install python-pymysql :: - $ apt-get install python-psycopg2 + $ apt install python-psycopg2 For other distributions, you might consider installing from `docker`_ images or from `source`_. From 2599f1ae37d2e9e37f13979b0ac9a995f3680bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 16 Jan 2021 17:03:42 +0100 Subject: [PATCH 053/237] Daemon doc --- docs/jukebox.rst | 11 ++++---- docs/setup/daemon.rst | 59 +++++++++++++++++++++++++++++++++++++++++++ docs/setup/index.rst | 13 +++++----- 3 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 docs/setup/daemon.rst diff --git a/docs/jukebox.rst b/docs/jukebox.rst index 3066b4b0..f2417306 100644 --- a/docs/jukebox.rst +++ b/docs/jukebox.rst @@ -2,17 +2,16 @@ Jukebox mode ============ The jukebox mode allow playing audio files on the hardware of the machine -running *Supysonic*, using regular clients that support it as a remote control. +running Supysonic, using regular clients that support it as a remote control. -The daemon must be running in order to be able to use the jukebox mode. So be -sure to start the ``supysonic-daemon`` command and keep it running. A basic -*systemd* service file can be found at the root of the project folder. +:doc:`setup/daemon` must be running in order to be able to use the jukebox mode. +So be sure to start the :doc:`man/supysonic-daemon` command and keep it running. Setting the player program -------------------------- -Jukebox mode in *Supysonic* works through the use of third-party command-line -programs. *Supysonic* isn't bundled with such programs, and you are left to +Jukebox mode in Supysonic works through the use of third-party command-line +programs. Supysonic isn't bundled with such programs, and you are left to choose which one you want to use. The chosen program should be able to play a single audio file from a path specified on its command-line. diff --git a/docs/setup/daemon.rst b/docs/setup/daemon.rst new file mode 100644 index 00000000..30d60035 --- /dev/null +++ b/docs/setup/daemon.rst @@ -0,0 +1,59 @@ +The daemon +========== + +Supysonic comes with an optional daemon service that currently provides the +following features: + +- background scans +- library changes detection +- jukebox mode + +Background scans +---------------- + +First of all, the daemon allows running backgrounds scans, meaning you can start +scans from the :doc:`command-line interface <../man/supysonic-cli>` and do +something else while it's scanning (otherwise the scan will block the CLI until +it's done). Background scans also enable the web UI to run scans, while you have +to use the CLI to do so if you don't run the daemon. + +Library watching +---------------- + +Instead of manually running a scan every time your library changes, the daemon +can listen to any library change and update the database accordingly. This +watcher is started along with the daemon but can be disabled to only keep +background scans. Please refer to :ref:`conf-daemon` of the configuration to +enable or disable it. + +Jukebox +------- + +Finally, the daemon acts as a backend for the jukebox mode, allowing to play +audio on the machine running Supysonic. More details on the :doc:`../jukebox` +page. + +Running it +---------- + +The daemon is :doc:`../man/supysonic-daemon`, it is a non-exiting process. +If you want to keep it running in background, either use the old +:command:`nohup` or :command:`screen` methods, or start it as a systemd unit. + +Below is a basic service file to load it through systemd. Modify it to match +your installation and save it as +:file:`/etc/systemd/system/supysonic-daemon.service`. + +.. code-block:: ini + + [Unit] + Description=Supysonic Daemon + + [Service] + User=someuser + Group=somegroup + WorkingDirectory=/home/supysonic + ExecStart=/usr/bin/python3 -m supysonic.daemon + + [Install] + WantedBy=multi-user.target diff --git a/docs/setup/index.rst b/docs/setup/index.rst index 4cb79c9e..a2a75bc9 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -1,13 +1,14 @@ Supysonic setup =============== -This guide details the required steps to get a *Supysonic* instance ready to +This guide details the required steps to get a Supysonic instance ready to start serving your music. .. toctree:: - :maxdepth: 2 + :maxdepth: 2 - install - database - configuration - deploying/index + install + database + configuration + deploying/index + daemon From 6d56b56dd5d3ebd4302ba0920c0cefbb5c6479f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 17 Jan 2021 12:29:37 +0100 Subject: [PATCH 054/237] Some doc touches --- docs/api.rst | 10 ++++++---- docs/index.rst | 30 +++++++++++++++++++++++++++++ docs/jukebox.rst | 1 + docs/man/index.rst | 20 +++++++++++++++++++ docs/man/supysonic-cli-folder.rst | 2 +- docs/man/supysonic-cli-user.rst | 2 +- docs/setup/configuration.rst | 23 +++++++++++----------- docs/setup/index.rst | 32 +++++++++++++++++++++++++++++++ docs/setup/install.rst | 23 ++++------------------ docs/transcoding.rst | 31 ++++++++++++++++++++---------- 10 files changed, 127 insertions(+), 47 deletions(-) create mode 100644 docs/index.rst create mode 100644 docs/man/index.rst diff --git a/docs/api.rst b/docs/api.rst index 73b075b5..0ac05989 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,12 +3,14 @@ Subsonic API breakdown This page lists all the API methods and their parameters up to the version 1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features -*Supysonic* support, plan on supporting, or won't. +Supysonic support, plan on supporting, or won't. At the moment, the current target API version is 1.10.2. The following information was gathered by *diff*-ing various snapshots of the -`Subsonic API page `_. +`Subsonic API page`__. + +__ http://www.subsonic.org/pages/api.jsp Methods and parameters listing ------------------------------ @@ -57,7 +59,7 @@ getSongsByGenre_ 1.9.0 ✔️ getNowPlaying_ ✔️ getStarred_ ✔️ getStarred2_ ✔️ -search_ ✔️ +:ref:`search ` ✔️ search2_ ✔️ search3_ ✔️ getPlaylists_ ✔️ @@ -449,7 +451,7 @@ Album/song lists Searching ^^^^^^^^^ -.. _search: +.. _search-: ``search`` ✔️ diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..571fa557 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,30 @@ +Welcome to Supysonic's documentation! +===================================== + +Supysonic is a Python implementation of the `Subsonic`__ server API. + +Current supported features are: + +* browsing (by folders or tags) +* streaming of various audio file formats +* transcoding +* user or random playlists +* cover arts (as image files in the same folder as music files) +* starred tracks/albums and ratings +* `Last.FM`__ scrobbling +* Jukebox mode + +__ http://www.subsonic.org/ +__ https://www.last.fm/ + +User's guide +------------ + +.. toctree:: + :maxdepth: 2 + + setup/index + transcoding + jukebox + man/index + api diff --git a/docs/jukebox.rst b/docs/jukebox.rst index f2417306..30f9bf7a 100644 --- a/docs/jukebox.rst +++ b/docs/jukebox.rst @@ -21,6 +21,7 @@ following fields: ``%path`` absolute path of the file to be played + ``%offset`` time in seconds where to start playing (used for seeking) diff --git a/docs/man/index.rst b/docs/man/index.rst new file mode 100644 index 00000000..b4b52290 --- /dev/null +++ b/docs/man/index.rst @@ -0,0 +1,20 @@ +Man pages +========= + +Command-line interface +---------------------- + +.. toctree:: + :maxdepth: 2 + + supysonic-cli + supysonic-cli-user + supysonic-cli-folder + +Daemon +------ + +.. toctree:: + :maxdepth: 2 + + supysonic-daemon diff --git a/docs/man/supysonic-cli-folder.rst b/docs/man/supysonic-cli-folder.rst index 869fbd59..d41dca00 100644 --- a/docs/man/supysonic-cli-folder.rst +++ b/docs/man/supysonic-cli-folder.rst @@ -45,7 +45,7 @@ Options -f, --force Force scan of already known files even if they haven't changed. Might be - useful if an update to *Supysonic* adds new metadata to audio files. + useful if an update to Supysonic adds new metadata to audio files. --background Scan in the background. Requires the ``supysonic-daemon`` to be running diff --git a/docs/man/supysonic-cli-user.rst b/docs/man/supysonic-cli-user.rst index c25df9ea..b4f51d50 100644 --- a/docs/man/supysonic-cli-user.rst +++ b/docs/man/supysonic-cli-user.rst @@ -82,4 +82,4 @@ To add a new admin user named `MyUserName` having password `MyAwesomePassword`:: See Also ======== -supysonic-cli(1), supysonic-cli-folder(1) +``supysonic-cli``\ (1), ``supysonic-cli-folder``\ (1) diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index 5670ae51..02c091c9 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -13,7 +13,7 @@ If you cloned Supysonic from its `GitHub repository`__ you'll find a roughly documented configuration sample file at the root of the project, file conveniently named :file:`config.sample`. More details below. -__ http://github.com/spl0k/supysonic +__ https://github.com/spl0k/supysonic ``[base]`` section ------------------ @@ -22,7 +22,7 @@ This sections defines the database and additional scanning config. ``database_uri`` The most important configuration, defines the type and - parameters of the database *Supysonic* should connect to. It usually includes + parameters of the database Supysonic should connect to. It usually includes username, password, hostname and database name. The typical form of a database URI is:: @@ -59,7 +59,7 @@ This sections defines the database and additional scanning config. ``utf8mb4`` regardless of what's set on your MySQL installation. If ``database_uri`` isn't provided, it defaults to a SQLite database stored - in ``/tmp/supysonic/supysonic.db``. + in :file:`/tmp/supysonic/supysonic.db`. ``scanner_extensions`` A space separated list of file extensions the scanner is restricted to. @@ -77,8 +77,7 @@ Sample configuration: .. code-block:: ini [base] - ; A database URI. See the 'schema' folder for schema creation scripts - ; Default: sqlite:////tmp/supysonic/supysonic.db + ; A database URI. Default: sqlite:////tmp/supysonic/supysonic.db database_uri = sqlite:////var/supysonic/supysonic.db ;database_uri = mysql://supysonic:supysonic@localhost/supysonic ;database_uri = postgres://supysonic:supysonic@localhost/supysonic @@ -96,7 +95,7 @@ Configuration relative to the HTTP server. ``cache_dir`` Directory used to store generated files, such as resized cover art or - transcoded files. Defaults to ``/tmp/supysonic``. + transcoded files. Defaults to :file:`/tmp/supysonic`. ``cache_size`` Maximum size (in megabytes) of the cache (except for trancodes). @@ -123,8 +122,8 @@ Configuration relative to the HTTP server. Defaults to ``WARNING``. ``mount_api`` (``on`` or ``off``) - Enable or disable the Subsonic REST API. Should be kept on or *Supysonic* - would be quite useless. Exists mostly for testing purposes. + Enable or disable the Subsonic REST API. Should be kept on or Supysonic would + be quite useless. Exists mostly for testing purposes. Defaults to ``on``. ``mount_webui`` (``on`` or ``off``) @@ -187,7 +186,7 @@ library folders and providing the jukebox feature. Unix domain socket file (or named pipe on Windows) used to communicate between the daemon and clients that rely on it (eg. CLI, folder admin web page, etc.). Note that using an IP address here isn't supported. - Default: /tmp/supysonic/supysonic.sock + Default: :file:`/tmp/supysonic/supysonic.sock` ``run_watcher`` Whether or not to start the watcher that will listen for library changes. @@ -248,13 +247,13 @@ Sample configuration: -------------------- This section allow defining API keys to enable Last.FM integration in -*Supysonic*. Currently it is only used to *scrobble* played tracks and update +Supysonic. Currently it is only used to *scrobble* played tracks and update the *now playing* information. See https://www.last.fm/api to obtain such keys. Once keys are set, users have to link their account by visiting their profile -page on *Supysonic*'s administrative UI. +page on Supysonic's administrative UI. ``api_key`` Last.FM API key @@ -298,7 +297,7 @@ For more details, please refer to the ``[mimetypes]`` section ----------------------- -Use this section if the system *Supysonic* is installed on has trouble guessing +Use this section if the system Supysonic is installed on has trouble guessing the mimetype of some files. This might only be useful in some rare cases. See the following links for a list of examples: diff --git a/docs/setup/index.rst b/docs/setup/index.rst index a2a75bc9..cc7e4cb0 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -4,6 +4,25 @@ Supysonic setup This guide details the required steps to get a Supysonic instance ready to start serving your music. +TL;DR +----- + +For the impatient, here's a quick summary to get Supysonic installed and ready +to start serving (but this doesn't create any user nor specifies where your +music is located 😏). This uses `gunicorn`__, but there are +:doc:`other options `. + +:: + + pip install git+https://github.com/spl0k/supysonic.git + pip install gunicorn + gunicorn -b 0.0.0.0:5000 "supysonic.web:create_application()" + +__ https://gunicorn.org/ + +Table of contents +----------------- + .. toctree:: :maxdepth: 2 @@ -12,3 +31,16 @@ start serving your music. configuration deploying/index daemon + +.. _docker: + +Docker +------ + +Another solution rather than going through the whole setup process yourself is +to use a ready-to-use Docker image. While we don't provide images for Supysonic, +that didn't keep the community from creating some. Take a look on the +`Docker Hub`__ and pick one you like. For more details on their usage, please +refer to the readme of said images. + +__ https://hub.docker.com/search?q=supysonic&type=image diff --git a/docs/setup/install.rst b/docs/setup/install.rst index 2762595d..9482c9e2 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -32,8 +32,8 @@ corresponding Python package, ``python-pymysql`` for MySQL or $ apt install python-psycopg2 -For other distributions, you might consider installing from `docker`_ images or -from `source`_. +For other distributions, you might consider installing from :ref:`docker` images +or from `source`_. Windows ------- @@ -60,34 +60,19 @@ to the `source installation instructions `_ below for more information. __ https://docs.python-guide.org/ __ https://docs.python-guide.org/starting/install3/win/ -.. _docker: - -Docker ------- - -While we don't provide Docker images for Supysonic, that didn't keep the -community from creating some. Take a look on the `Docker Hub`__ and pick one you -like. For more details on their usage, please refer to the readme of said -images. - -__ https://hub.docker.com/search?q=supysonic&type=image - .. _source: Source ------ You can install Supysonic directly from a clone of the `Git repository`__. This -can be done either by cloning the repo and installing from the local clone, or -simply installing directly via :command:`pip`. - -:: +can be done either by cloning the repo and installing from the local clone:: $ git clone https://github.com/spl0k/supysonic.git $ cd supysonic $ pip install . -:: +or simply installing directly via :command:`pip`:: $ pip install git+https://github.com/spl0k/supysonic.git diff --git a/docs/transcoding.rst b/docs/transcoding.rst index ba45744c..34105a7c 100644 --- a/docs/transcoding.rst +++ b/docs/transcoding.rst @@ -6,12 +6,12 @@ allows for streaming of formats that wouldn't be streamable otherwise, or reducing the quality of an audio file to allow a decent streaming for clients with limited bandwidth, such as the ones running on a mobile connection. -Transcoding in *Supysonic* is achieved through the use of third-party -command-line programs. *Supysonic* isn't bundled with such programs, and you are -left to choose which one you want to use. +Transcoding in Supysonic is achieved through the use of third-party command-line +programs. Supysonic isn't bundled with such programs, and you are left to choose +which one you want to use. If you want to use transcoding but your client doesn't allow you to do so, you -can force *Supysonic* to transcode for that client by going to your profile page +can force Supysonic to transcode for that client by going to your profile page on the web interface. Configuration @@ -45,7 +45,7 @@ versions. The programs defined with these variables should be able to transcode/decode/encode any format. For that reason, we suggest you don't use these if you want to keep control over the available transcoders. -*Supysonic* will take the first available transcoding configuration in the +Supysonic will take the first available transcoding configuration in the following order: #. specific transcoder @@ -59,26 +59,37 @@ program. The command-lines can include the following fields: ``%srcpath`` path to the original file to transcode + ``%srcfmt`` extension of the original file + ``%outfmt`` extension of the resulting file + ``%outrate`` bitrate of the resulting file + ``%title`` title of the file to transcode + ``%album`` album name of the file to transcode + ``%artist`` artist name of the file to transcode + ``%tracknumber`` track number of the file to transcode + ``%totaltracks`` number of tracks in the album of the file to transcode + ``%discnumber`` disc number of the file to transcode + ``%genre`` genre of the file to transcode (not always available, defaults to "") + ``%year`` year of the file to transcode (not always available, defaults to "") @@ -126,14 +137,14 @@ Enabling transcoding Once the transcoding configuration has been set, most clients will require the user to specify that they want to transcode files. This might be done on the -client itself, but most importantly it should be done on *Supysonic* web +client itself, but most importantly it should be done on Supysonic web interface. Not doing so might prevent some clients to properly request transcoding. To enable transcoding with the web interface, you should first start using the client you want to set transcoding for. Only browsing the library should suffice. Then open your browser of choice and navigate to the URL of your -*Supysonic* instance. Log in with your credentials and the click on your -username in the top bar. There you should be presented with a list of clients -you used to connect to *Supysonic* and be able to set you preferred streaming -format and bitrate. +Supysonic instance. Log in with your credentials and the click on your username +in the top bar. There you should be presented with a list of clients you used to +connect to Supysonic and be able to set your preferred streaming format +and bitrate. From 78337265bede08f62e4f2e41fd8a6a6e0b34edb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 17 Jan 2021 17:06:44 +0100 Subject: [PATCH 055/237] Sphinx config, sidebar, etc. --- docs/Makefile | 20 ++++++++ docs/conf.py | 88 +++++++++++++++++++++++++++++++++ docs/index.rst | 3 +- docs/man/index.rst | 6 +-- docs/setup/configuration.rst | 24 ++++----- docs/setup/deploying/apache.rst | 4 +- docs/setup/deploying/index.rst | 4 +- docs/setup/deploying/other.rst | 18 ++++--- docs/setup/index.rst | 9 ++-- docs/transcoding.rst | 8 ++- 10 files changed, 143 insertions(+), 41 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/conf.py diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..fd69c425 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,88 @@ +# -- Project information ----------------------------------------------------- + +project = "Supysonic" +author = "Alban Féron" +copyright = "2013-2021, " + author + +version = "0.6.2" +release = "0.6.2" + + +# -- General configuration --------------------------------------------------- + +extensions = [] +templates_path = [] +source_suffix = ".rst" +master_doc = "index" +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +primary_domain = None +highlight_language = "none" + +language = None + + +# -- Options for HTML output ------------------------------------------------- + +html_theme = "alabaster" +html_theme_options = { + "description": "A Python implementation of the Subsonic server API", + "github_user": "spl0k", + "github_repo": "supysonic", +} +html_static_path = [] + +# Default alabaseter sidebars + localtoc +html_sidebars = { + "**": [ + "about.html", + "localtoc.html", + "navigation.html", + "relations.html", + "searchbox.html", + "donate.html", + ] +} + +html_domain_indices = False + + +# -- Options for manual page output ------------------------------------------ + +_man_authors = ["Louis-Philippe Véronneau", author] + +# Man pages, they are writter to be generated directly by `rst2man` so using +# Sphinx to build them will give weird sections, but if we ever need it it's +# there + +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + "man/supysonic-cli", + "supysonic-cli", + "Python implementation of the Subsonic server API", + _man_authors, + 1, + ), + ( + "man/supysonic-cli-user", + "supysonic-cli-user", + "Supysonic user management commands", + _man_authors, + 1, + ), + ( + "man/supysonic-cli-folder", + "supysonic-cli-folder", + "Supysonic folder management commands", + _man_authors, + 1, + ), + ( + "man/supysonic-daemon", + "supysonic-daemon", + "Supysonic background daemon", + _man_authors, + 1, + ), +] diff --git a/docs/index.rst b/docs/index.rst index 571fa557..7ec46f8d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,8 +17,7 @@ Current supported features are: __ http://www.subsonic.org/ __ https://www.last.fm/ -User's guide ------------- +.. rubric:: User's guide .. toctree:: :maxdepth: 2 diff --git a/docs/man/index.rst b/docs/man/index.rst index b4b52290..a5d55bc2 100644 --- a/docs/man/index.rst +++ b/docs/man/index.rst @@ -1,8 +1,7 @@ Man pages ========= -Command-line interface ----------------------- +.. rubric:: Command-line interface .. toctree:: :maxdepth: 2 @@ -11,8 +10,7 @@ Command-line interface supysonic-cli-user supysonic-cli-folder -Daemon ------- +.. rubric:: Daemon .. toctree:: :maxdepth: 2 diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index 02c091c9..8f536bad 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -41,7 +41,9 @@ This sections defines the database and additional scanning config. path, it requires three slashes, for absolute paths it's also three slashes followed by the absolute path, meaning actually four slashes on Unix systems. - .. code-block:: ini + .. highlight:: ini + + :: ; Relative path database_uri = sqlite:///relative-file.db @@ -72,9 +74,7 @@ This sections defines the database and additional scanning config. Disabled by default, enable it only if you trust your file system as nothing is done to handle broken links or loops. -Sample configuration: - -.. code-block:: ini +Sample configuration:: [base] ; A database URI. Default: sqlite:////tmp/supysonic/supysonic.db @@ -142,9 +142,7 @@ Configuration relative to the HTTP server. case insensitive. Defaults to ``El La Le Las Les Los The``. -Sample configuration: - -.. code-block:: ini +Sample configuration:: [webapp] ; Optional cache directory. Default: /tmp/supysonic @@ -218,9 +216,7 @@ library folders and providing the jukebox feature. Defaults to ``WARNING``. -Sample configuration: - -.. code-block:: ini +Sample configuration:: [daemon] ; Socket file the daemon will listen on for incoming management commands @@ -261,9 +257,7 @@ page on Supysonic's administrative UI. ``secret`` secret key associated to the API key -Sample configuration: - -.. code-block:: ini +Sample configuration:: [lastfm] ; API and secret key to enable scrobbling. http://www.last.fm/api/accounts @@ -282,7 +276,7 @@ have **not** been thoroughly tested. For more details, please refer to the :doc:`transcoding configuration <../transcoding>`. -.. code-block:: ini +:: [transcoding] ; Programs used to convert from one format/bitrate to another. Defaults: none @@ -305,7 +299,7 @@ See the following links for a list of examples: * https://en.wikipedia.org/wiki/Media_type#Common_examples * https://www.iana.org/assignments/media-types/media-types.xhtml -.. code-block:: ini +:: [mimetypes] ; Extension to mimetype mappings in case your system has some trouble guessing diff --git a/docs/setup/deploying/apache.rst b/docs/setup/deploying/apache.rst index eba8af7b..2f8eebbe 100644 --- a/docs/setup/deploying/apache.rst +++ b/docs/setup/deploying/apache.rst @@ -20,7 +20,9 @@ Creating a `.wsgi` file ----------------------- To run Supysonic within Apache you need a :file:`supysonic.wsgi` file. Create -one somewhere and fill it with the following content:: +one somewhere and fill it with the following content: + +.. code-block:: python3 from supysonic.web import create_application application = create_application() diff --git a/docs/setup/deploying/index.rst b/docs/setup/deploying/index.rst index 3d65ffd4..79b45020 100644 --- a/docs/setup/deploying/index.rst +++ b/docs/setup/deploying/index.rst @@ -18,5 +18,5 @@ You'll find some common (and less common) deployment option below: As Supysonic is a WSGI application, you have numerous deployment options available to you. If you want to deploy it to a WSGI server not listed here, look up the server documentation about how to use a WSGI app with it. When -setting one of those, you'll want to call the :func:`create_application` factory -function from module :mod:`supysonic.web`. +setting one of those, you'll want to call the :py:func:`create_application` +factory function from module :py:mod:`supysonic.web`. diff --git a/docs/setup/deploying/other.rst b/docs/setup/deploying/other.rst index 28a9e03b..90fa449d 100644 --- a/docs/setup/deploying/other.rst +++ b/docs/setup/deploying/other.rst @@ -18,23 +18,27 @@ Creating a `.fcgi` file ^^^^^^^^^^^^^^^^^^^^^^^ First you need to create the FastCGI server file. Let's call it -:file:`supysonic.fcgi`:: +:file:`supysonic.fcgi`: + +.. code-block:: python3 #!/usr/bin/python3 from flup.server.fcgi import WSGIServer from supysonic.web import create_application - if __name__ == '__main__': + if __name__ == "__main__": app = create_application() WSGIServer(app).run() This should be enough for Apache to work, however nginx and older versions of lighttpd need a socket to be explicitly passed to communicate with the FastCGI server. For that to work you need to pass the path to the socket -to the :class:`~flup.server.fcgi.WSGIServer`:: +to the :py:class:`~flup.server.fcgi.WSGIServer`: + +.. code-block:: python3 - WSGIServer(app, bindAddress='/path/to/fcgi.sock').run() + WSGIServer(app, bindAddress="/path/to/fcgi.sock").run() The path has to be the exact same path you define in the server config. @@ -72,7 +76,9 @@ Creating a `.cgi` file ^^^^^^^^^^^^^^^^^^^^^^ First you need to create the CGI application file. Let's call it -:file:`supysonic.cgi`:: +:file:`supysonic.cgi`: + +.. code-block:: python3 #!/usr/bin/python3 @@ -91,6 +97,6 @@ rewrite the URL) or let the server point to the file directly. In Apache for example you can put something like this into the config: -.. sourcecode:: apache +.. code-block:: apache ScriptAlias /supysonic /path/to/the/supysonic.cgi diff --git a/docs/setup/index.rst b/docs/setup/index.rst index cc7e4cb0..610c25fd 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -4,8 +4,7 @@ Supysonic setup This guide details the required steps to get a Supysonic instance ready to start serving your music. -TL;DR ------ +.. rubric:: TL;DR For the impatient, here's a quick summary to get Supysonic installed and ready to start serving (but this doesn't create any user nor specifies where your @@ -20,8 +19,7 @@ music is located 😏). This uses `gunicorn`__, but there are __ https://gunicorn.org/ -Table of contents ------------------ +.. rubric:: Table of contents .. toctree:: :maxdepth: 2 @@ -34,8 +32,7 @@ Table of contents .. _docker: -Docker ------- +.. rubric:: Docker Another solution rather than going through the whole setup process yourself is to use a ready-to-use Docker image. While we don't provide images for Supysonic, diff --git a/docs/transcoding.rst b/docs/transcoding.rst index 34105a7c..fe534104 100644 --- a/docs/transcoding.rst +++ b/docs/transcoding.rst @@ -104,9 +104,9 @@ Suggested configuration Here is an example configuration that you could use. This is provided as-is, and some configurations haven't been tested. -Basic configuration: +.. highlight:: ini -.. code-block:: ini +Basic configuration:: [transcoding] transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - @@ -118,9 +118,7 @@ Basic configuration: encoder_ogg = oggenc2 -Q -M %outrate - default_transcode_target = mp3 -To include track metadata in the transcoded stream: - -.. code-block:: ini +To include track metadata in the transcoded stream:: [transcoding] transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 %srcpath - From 11887d07d293dd03d38b558bf394773204574f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 17 Jan 2021 19:33:31 +0100 Subject: [PATCH 056/237] README update to match doc paths --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4b6dd370..1d4fac71 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ _Supysonic_ currently targets the version 1.10.2 of the _Subsonic_ API. For more details, go check the [API implementation status][docs-api]. [subsonic]: http://www.subsonic.org/ -[transcoding]: docs/transcoding.md +[transcoding]: docs/transcoding.rst [lastfm]: https://last.fm/ -[docs-api]: docs/api.md +[docs-api]: docs/api.rst ## Table of contents @@ -108,7 +108,7 @@ database_uri = sqlite:////some/path/to/a/supysonic.db For a more details on the configuration, please refer to [documentation][docs-config]. -[docs-config]: docs/configuration.md +[docs-config]: docs/setup/configuration.rst ## Running the application @@ -198,7 +198,7 @@ You should now be able to enjoy your music with the client of your choice! For more details on the command-line usage, take a look at the [documentation][docs-cli]. -[docs-cli]: docs/cli.md +[docs-cli]: docs/man/supysonic-cli.rst ## Client authentication From 4735003bc4ef567160716c7c91a783e985c80394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 23 Jan 2021 15:40:32 +0100 Subject: [PATCH 057/237] API doc: try to unify table, left align --- docs/_static/custom.css | 4 + docs/api.rst | 1269 ++++++++++++++++++++++----------------- docs/conf.py | 2 +- 3 files changed, 737 insertions(+), 538 deletions(-) create mode 100644 docs/_static/custom.css diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 00000000..7914b7ae --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,4 @@ +table.align-default { + margin-left: 0; + margin-right: 0; +} diff --git a/docs/api.rst b/docs/api.rst index 0ac05989..60d5f00c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -30,119 +30,128 @@ or with version 1.8.0. All methods / pseudo-TOC ^^^^^^^^^^^^^^^^^^^^^^^^ -=========================== ====== = -Method Vers. -=========================== ====== = -ping_ ✔️ -getLicense_ ✔️ -getMusicFolders_ ✔️ -getIndexes_ ✔️ -getMusicDirectory_ ✔️ -getGenres_ 1.9.0 ✔️ -getArtists_ ✔️ -getArtist_ ✔️ -getAlbum_ ✔️ -getSong_ ✔️ -getVideos_ ❌ -getVideoInfo_ 1.15.0 🔴 -getArtistInfo_ 1.11.0 📅 -getArtistInfo2_ 1.11.0 📅 -getAlbumInfo_ 1.14.0 📅 -getAlbumInfo2_ 1.14.0 📅 -getSimilarSongs_ 1.11.0 ❔ -getSimilarSongs2_ 1.11.0 ❔ -getTopSongs_ 1.13.0 ❔ -getAlbumList_ ✔️ -getAlbumList2_ ✔️ -getRandomSongs_ ✔️ -getSongsByGenre_ 1.9.0 ✔️ -getNowPlaying_ ✔️ -getStarred_ ✔️ -getStarred2_ ✔️ -:ref:`search ` ✔️ -search2_ ✔️ -search3_ ✔️ -getPlaylists_ ✔️ -getPlaylist_ ✔️ -createPlaylist_ ✔️ -updatePlaylist_ ✔️ -deletePlaylist_ ✔️ -stream_ ✔️ -download_ ✔️ -hls_ 1.9.0 🔴 -getCaptions_ 1.15.0 🔴 -getCoverArt_ ✔️ -getLyrics_ ✔️ -getAvatar_ ❌ -star_ ✔️ -unstar_ ✔️ -setRating_ ✔️ -scrobble_ ✔️ -getShares_ ❌ -createShare_ ❌ -updateShare_ ❌ -deleteShare_ ❌ -getPodcasts_ ❔ -getNewestPodcasts_ 1.14.0 ❔ -refreshPodcasts_ 1.9.0 ❔ -createPodcastChannel_ 1.9.0 ❔ -deletePodcastChannel_ 1.9.0 ❔ -deletePodcastEpisode_ 1.9.0 ❔ -downloadPodcastEpisode_ 1.9.0 ❔ -jukeboxControl_ ✔️ -getInternetRadioStations_ 1.9.0 ✔️ -createInternetRadioStation_ 1.16.0 ✔️ -updateInternetRadioStation_ 1.16.0 ✔️ -deleteInternetRadioStation_ 1.16.0 ✔️ -getChatMessages_ ✔️ -addChatMessage_ ✔️ -getUser_ ✔️ -getUsers_ 1.9.0 ✔️ -createUser_ ✔️ -updateUser_ 1.10.2 ✔️ -deleteUser_ ✔️ -changePassword_ ✔️ -getBookmarks_ 1.9.0 ❔ -createBookmark_ 1.9.0 ❔ -deleteBookmark_ 1.9.0 ❔ -getPlayQueue_ 1.12.0 ❔ -savePlayQueue_ 1.12.0 ❔ -getScanStatus_ 1.15.0 ✔️ -startScan_ 1.15.0 ✔️ -=========================== ====== = +.. table:: + :widths: 55 30 15 + + =========================== ====== = + Method Vers. + =========================== ====== = + ping_ ✔️ + getLicense_ ✔️ + getMusicFolders_ ✔️ + getIndexes_ ✔️ + getMusicDirectory_ ✔️ + getGenres_ 1.9.0 ✔️ + getArtists_ ✔️ + getArtist_ ✔️ + getAlbum_ ✔️ + getSong_ ✔️ + getVideos_ ❌ + getVideoInfo_ 1.15.0 🔴 + getArtistInfo_ 1.11.0 📅 + getArtistInfo2_ 1.11.0 📅 + getAlbumInfo_ 1.14.0 📅 + getAlbumInfo2_ 1.14.0 📅 + getSimilarSongs_ 1.11.0 ❔ + getSimilarSongs2_ 1.11.0 ❔ + getTopSongs_ 1.13.0 ❔ + getAlbumList_ ✔️ + getAlbumList2_ ✔️ + getRandomSongs_ ✔️ + getSongsByGenre_ 1.9.0 ✔️ + getNowPlaying_ ✔️ + getStarred_ ✔️ + getStarred2_ ✔️ + :ref:`search ` ✔️ + search2_ ✔️ + search3_ ✔️ + getPlaylists_ ✔️ + getPlaylist_ ✔️ + createPlaylist_ ✔️ + updatePlaylist_ ✔️ + deletePlaylist_ ✔️ + stream_ ✔️ + download_ ✔️ + hls_ 1.9.0 🔴 + getCaptions_ 1.15.0 🔴 + getCoverArt_ ✔️ + getLyrics_ ✔️ + getAvatar_ ❌ + star_ ✔️ + unstar_ ✔️ + setRating_ ✔️ + scrobble_ ✔️ + getShares_ ❌ + createShare_ ❌ + updateShare_ ❌ + deleteShare_ ❌ + getPodcasts_ ❔ + getNewestPodcasts_ 1.14.0 ❔ + refreshPodcasts_ 1.9.0 ❔ + createPodcastChannel_ 1.9.0 ❔ + deletePodcastChannel_ 1.9.0 ❔ + deletePodcastEpisode_ 1.9.0 ❔ + downloadPodcastEpisode_ 1.9.0 ❔ + jukeboxControl_ ✔️ + getInternetRadioStations_ 1.9.0 ✔️ + createInternetRadioStation_ 1.16.0 ✔️ + updateInternetRadioStation_ 1.16.0 ✔️ + deleteInternetRadioStation_ 1.16.0 ✔️ + getChatMessages_ ✔️ + addChatMessage_ ✔️ + getUser_ ✔️ + getUsers_ 1.9.0 ✔️ + createUser_ ✔️ + updateUser_ 1.10.2 ✔️ + deleteUser_ ✔️ + changePassword_ ✔️ + getBookmarks_ 1.9.0 ❔ + createBookmark_ 1.9.0 ❔ + deleteBookmark_ 1.9.0 ❔ + getPlayQueue_ 1.12.0 ❔ + savePlayQueue_ 1.12.0 ❔ + getScanStatus_ 1.15.0 ✔️ + startScan_ 1.15.0 ✔️ + =========================== ====== = Global ^^^^^^ Parameters used for any request -===== ====== = -P. Vers. -===== ====== = -``u`` ✔️ -``p`` ✔️ -``t`` 1.13.0 🔴 -``s`` 1.13.0 🔴 -``v`` ✔️ -``c`` ✔️ -``f`` ✔️ -===== ====== = +.. table:: + :widths: 55 30 15 + + ===== ====== = + P. Vers. + ===== ====== = + ``u`` ✔️ + ``p`` ✔️ + ``t`` 1.13.0 🔴 + ``s`` 1.13.0 🔴 + ``v`` ✔️ + ``c`` ✔️ + ``f`` ✔️ + ===== ====== = Error codes -== ====== = -# Vers. -== ====== = -0 ✔️ -10 ✔️ -20 ✔️ -30 ✔️ -40 ✔️ -41 1.15.0 📅 -50 ✔️ -60 ✔️ -70 ✔️ -== ====== = +.. table:: + :widths: 55 30 15 + + == ====== = + # Vers. + == ====== = + 0 ✔️ + 10 ✔️ + 20 ✔️ + 30 ✔️ + 40 ✔️ + 41 1.15.0 📅 + 50 ✔️ + 60 ✔️ + 70 ✔️ + == ====== = System ^^^^^^ @@ -176,23 +185,29 @@ Browsing ``getIndexes`` ✔️ - =================== ===== = - Parameter Vers. - =================== ===== = - ``musicFolderId`` ✔️ - ``ifModifiedSince`` ✔️ - =================== ===== = + .. table:: + :widths: 55 30 15 + + =================== ===== = + Parameter Vers. + =================== ===== = + ``musicFolderId`` ✔️ + ``ifModifiedSince`` ✔️ + =================== ===== = .. _getMusicDirectory: ``getMusicDirectory`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = .. _getGenres: @@ -206,44 +221,56 @@ Browsing ``getArtists`` ✔️ - ================= ====== = - Parameter Vers. - ================= ====== = - ``musicFolderId`` 1.14.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.14.0 📅 + ================= ====== = .. _getArtist: ``getArtist`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = .. _getAlbum: ``getAlbum`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = .. _getSong: ``getSong`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = .. _getVideos: @@ -257,95 +284,119 @@ Browsing ``getVideoInfo`` 🔴 1.15.0 - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.15.0 🔴 - ========= ====== = + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.15.0 🔴 + ========= ====== = .. _getArtistInfo: ``getArtistInfo`` 📅 1.11.0 - ===================== ====== = - Parameter Vers. - ===================== ====== = - ``id`` 1.11.0 📅 - ``count`` 1.11.0 📅 - ``includeNotPresent`` 1.11.0 📅 - ===================== ====== = + .. table:: + :widths: 55 30 15 + + ===================== ====== = + Parameter Vers. + ===================== ====== = + ``id`` 1.11.0 📅 + ``count`` 1.11.0 📅 + ``includeNotPresent`` 1.11.0 📅 + ===================== ====== = .. _getArtistInfo2: ``getArtistInfo2`` 📅 1.11.0 - ===================== ====== = - Parameter Vers. - ===================== ====== = - ``id`` 1.11.0 📅 - ``count`` 1.11.0 📅 - ``includeNotPresent`` 1.11.0 📅 - ===================== ====== = + .. table:: + :widths: 55 30 15 + + ===================== ====== = + Parameter Vers. + ===================== ====== = + ``id`` 1.11.0 📅 + ``count`` 1.11.0 📅 + ``includeNotPresent`` 1.11.0 📅 + ===================== ====== = .. _getAlbumInfo: ``getAlbumInfo`` 📅 1.14.0 - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.14.0 📅 - ========= ====== = + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.14.0 📅 + ========= ====== = .. _getAlbumInfo2: ``getAlbumInfo2`` 📅 1.14.0 - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.14.0 📅 - ========= ====== = + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.14.0 📅 + ========= ====== = .. _getSimilarSongs: ``getSimilarSongs`` ❔ 1.11.0 - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.11.0 ❔ - ``count`` 1.11.0 ❔ - ========= ====== = + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.11.0 ❔ + ``count`` 1.11.0 ❔ + ========= ====== = .. _getSimilarSongs2: ``getSimilarSongs2`` ❔ 1.11.0 - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.11.0 ❔ - ``count`` 1.11.0 ❔ - ========= ====== = + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.11.0 ❔ + ``count`` 1.11.0 ❔ + ========= ====== = .. _getTopSongs: ``getTopSongs`` ❔ 1.13.0 - ========== ====== = - Parameter Vers. - ========== ====== = - ``artist`` 1.13.0 ❔ - ``count`` 1.13.0 ❔ - ========== ====== = + .. table:: + :widths: 55 30 15 + + ========== ====== = + Parameter Vers. + ========== ====== = + ``artist`` 1.13.0 ❔ + ``count`` 1.13.0 ❔ + ========== ====== = Album/song lists ^^^^^^^^^^^^^^^^ @@ -355,17 +406,20 @@ Album/song lists ``getAlbumList`` ✔️ - ================= ====== = - Parameter Vers. - ================= ====== = - ``type`` ✔️ - ``size`` ✔️ - ``offset`` ✔️ - ``fromYear`` ✔️ - ``toYear`` ✔️ - ``genre`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``type`` ✔️ + ``size`` ✔️ + ``offset`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``genre`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = .. versionadded:: 1.10.1 ``byYear`` and ``byGenre`` were added to ``type`` @@ -375,17 +429,20 @@ Album/song lists ``getAlbumList2`` ✔️ - ================= ====== = - Parameter Vers. - ================= ====== = - ``type`` ✔️ - ``size`` ✔️ - ``offset`` ✔️ - ``fromYear`` ✔️ - ``toYear`` ✔️ - ``genre`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``type`` ✔️ + ``size`` ✔️ + ``offset`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``genre`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = .. versionadded:: 1.10.1 ``byYear`` and ``byGenre`` were added to ``type`` @@ -395,29 +452,35 @@ Album/song lists ``getRandomSongs`` ✔️ - ================= ===== = - Parameter Vers. - ================= ===== = - ``size`` ✔️ - ``genre`` ✔️ - ``fromYear`` ✔️ - ``toYear`` ✔️ - ``musicFolderId`` ✔️ - ================= ===== = + .. table:: + :widths: 55 30 15 + + ================= ===== = + Parameter Vers. + ================= ===== = + ``size`` ✔️ + ``genre`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``musicFolderId`` ✔️ + ================= ===== = .. _getSongsByGenre: ``getSongsByGenre`` ✔️ 1.9.0 - ================= ====== = - Parameter Vers. - ================= ====== = - ``genre`` 1.9.0 ✔️ - ``count`` 1.9.0 ✔️ - ``offset`` 1.9.0 ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``genre`` 1.9.0 ✔️ + ``count`` 1.9.0 ✔️ + ``offset`` 1.9.0 ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = .. _getNowPlaying: @@ -431,22 +494,28 @@ Album/song lists ``getStarred`` ✔️ - ================= ====== = - Parameter Vers. - ================= ====== = - ``musicFolderId`` 1.12.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.12.0 📅 + ================= ====== = .. _getStarred2: ``getStarred2`` ✔️ - ================= ====== = - Parameter Vers. - ================= ====== = - ``musicFolderId`` 1.12.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.12.0 📅 + ================= ====== = Searching ^^^^^^^^^ @@ -456,53 +525,62 @@ Searching ``search`` ✔️ - ============= ===== = - Parameter Vers. - ============= ===== = - ``artist`` ✔️ - ``album`` ✔️ - ``title`` ✔️ - ``any`` ✔️ - ``count`` ✔️ - ``offset`` ✔️ - ``newerThan`` ✔️ - ============= ===== = + .. table:: + :widths: 55 30 15 + + ============= ===== = + Parameter Vers. + ============= ===== = + ``artist`` ✔️ + ``album`` ✔️ + ``title`` ✔️ + ``any`` ✔️ + ``count`` ✔️ + ``offset`` ✔️ + ``newerThan`` ✔️ + ============= ===== = .. _search2: ``search2`` ✔️ - ================= ====== = - Parameter Vers. - ================= ====== = - ``query`` ✔️ - ``artistCount`` ✔️ - ``artistOffset`` ✔️ - ``albumCount`` ✔️ - ``albumOffset`` ✔️ - ``songCount`` ✔️ - ``songOffset`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``query`` ✔️ + ``artistCount`` ✔️ + ``artistOffset`` ✔️ + ``albumCount`` ✔️ + ``albumOffset`` ✔️ + ``songCount`` ✔️ + ``songOffset`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = .. _search3: ``search3`` ✔️ - ================= ====== = - Parameter Vers. - ================= ====== = - ``query`` ✔️ - ``artistCount`` ✔️ - ``artistOffset`` ✔️ - ``albumCount`` ✔️ - ``albumOffset`` ✔️ - ``songCount`` ✔️ - ``songOffset`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``query`` ✔️ + ``artistCount`` ✔️ + ``artistOffset`` ✔️ + ``albumCount`` ✔️ + ``albumOffset`` ✔️ + ``songCount`` ✔️ + ``songOffset`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = Playlists ^^^^^^^^^ @@ -512,62 +590,77 @@ Playlists ``getPlaylists`` ✔️ - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = .. _getPlaylist: ``getPlaylist`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = .. _createPlaylist: ``createPlaylist`` ✔️ - ============== ===== = - Parameter Vers. - ============== ===== = - ``playlistId`` ✔️ - ``name`` ✔️ - ``songId`` ✔️ - ============== ===== = + .. table:: + :widths: 55 30 15 + + ============== ===== = + Parameter Vers. + ============== ===== = + ``playlistId`` ✔️ + ``name`` ✔️ + ``songId`` ✔️ + ============== ===== = .. _updatePlaylist: ``updatePlaylist`` ✔️ - ===================== ===== = - Parameter Vers. - ===================== ===== = - ``playlistId`` ✔️ - ``name`` ✔️ - ``comment`` ✔️ - ``public`` 1.9.0 ✔️ - ``songIdToAdd`` ✔️ - ``songIndexToRemove`` ✔️ - ===================== ===== = + .. table:: + :widths: 55 30 15 + + ===================== ===== = + Parameter Vers. + ===================== ===== = + ``playlistId`` ✔️ + ``name`` ✔️ + ``comment`` ✔️ + ``public`` 1.9.0 ✔️ + ``songIdToAdd`` ✔️ + ``songIndexToRemove`` ✔️ + ===================== ===== = .. _deletePlaylist: ``deletePlaylist`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = Media retrieval ^^^^^^^^^^^^^^^ @@ -577,88 +670,109 @@ Media retrieval ``stream`` ✔️ - ========================= ====== = - Parameter Vers. - ========================= ====== = - ``id`` ✔️ - ``maxBitRate`` ✔️ - ``format`` ✔️ - ``timeOffset`` ❌ - ``size`` ❌ - ``estimateContentLength`` ✔️ - ``converted`` 1.15.0 🔴 - ========================= ====== = + .. table:: + :widths: 55 30 15 + + ========================= ====== = + Parameter Vers. + ========================= ====== = + ``id`` ✔️ + ``maxBitRate`` ✔️ + ``format`` ✔️ + ``timeOffset`` ❌ + ``size`` ❌ + ``estimateContentLength`` ✔️ + ``converted`` 1.15.0 🔴 + ========================= ====== = .. _download: ``download`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = .. _hls: ``hls`` 🔴 1.9.0 - ============== ====== = - Parameter Vers. - ============== ====== = - ``id`` 1.9.0 🔴 - ``bitRate`` 1.9.0 🔴 - ``audioTrack`` 1.15.0 🔴 - ============== ====== = + .. table:: + :widths: 55 30 15 + + ============== ====== = + Parameter Vers. + ============== ====== = + ``id`` 1.9.0 🔴 + ``bitRate`` 1.9.0 🔴 + ``audioTrack`` 1.15.0 🔴 + ============== ====== = .. _getCaptions: ``getCaptions`` 🔴 1.15.0 - ========== ====== = - Parameter Vers. - ========== ====== = - ``id`` 1.15.0 🔴 - ``format`` 1.15.0 🔴 - ========== ====== = + .. table:: + :widths: 55 30 15 + + ========== ====== = + Parameter Vers. + ========== ====== = + ``id`` 1.15.0 🔴 + ``format`` 1.15.0 🔴 + ========== ====== = .. _getCoverArt: ``getCoverArt`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ``size`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ``size`` ✔️ + ========= ===== = .. _getLyrics: ``getLyrics`` ✔️ - ========== ===== = - Parameter Vers. - ========== ===== = - ``artist`` ✔️ - ``title`` ✔️ - ========== ===== = + .. table:: + :widths: 55 30 15 + + ========== ===== = + Parameter Vers. + ========== ===== = + ``artist`` ✔️ + ``title`` ✔️ + ========== ===== = .. _getAvatar: ``getAvatar`` ❌ - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ❌ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ❌ + ============ ===== = Media annotation ^^^^^^^^^^^^^^^^ @@ -668,51 +782,63 @@ Media annotation ``star`` ✔️ - ============ ===== = - Parameter Vers. - ============ ===== = - ``id`` ✔️ - ``albumId`` ✔️ - ``artistId`` ✔️ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` ✔️ + ``albumId`` ✔️ + ``artistId`` ✔️ + ============ ===== = .. _unstar: ``unstar`` ✔️ - ============ ===== = - Parameter Vers. - ============ ===== = - ``id`` ✔️ - ``albumId`` ✔️ - ``artistId`` ✔️ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` ✔️ + ``albumId`` ✔️ + ``artistId`` ✔️ + ============ ===== = .. _setRating: ``setRating`` ✔️ - ========== ===== = - Parameter Vers. - ========== ===== = - ``id`` ✔️ - ``rating`` ✔️ - ========== ===== = + .. table:: + :widths: 55 30 15 + + ========== ===== = + Parameter Vers. + ========== ===== = + ``id`` ✔️ + ``rating`` ✔️ + ========== ===== = .. _scrobble: ``scrobble`` ✔️ - ============== ===== = - Parameter Vers. - ============== ===== = - ``id`` ✔️ - ``time`` 1.9.0 ✔️ - ``submission`` ✔️ - ============== ===== = + .. table:: + :widths: 55 30 15 + + ============== ===== = + Parameter Vers. + ============== ===== = + ``id`` ✔️ + ``time`` 1.9.0 ✔️ + ``submission`` ✔️ + ============== ===== = Sharing ^^^^^^^ @@ -729,37 +855,46 @@ Sharing ``createShare`` ❌ - =============== ===== = - Parameter Vers. - =============== ===== = - ``id`` ❌ - ``description`` ❌ - ``expires`` ❌ - =============== ===== = + .. table:: + :widths: 55 30 15 + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` ❌ + ``description`` ❌ + ``expires`` ❌ + =============== ===== = .. _updateShare: ``updateShare`` ❌ - =============== ===== = - Parameter Vers. - =============== ===== = - ``id`` ❌ - ``description`` ❌ - ``expires`` ❌ - =============== ===== = + .. table:: + :widths: 55 30 15 + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` ❌ + ``description`` ❌ + ``expires`` ❌ + =============== ===== = .. _deleteShare: ``deleteShare`` ❌ - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ❌ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ❌ + ========= ===== = Podcast ^^^^^^^ @@ -769,23 +904,29 @@ Podcast ``getPodcasts`` ❔ - =================== ===== = - Parameter Vers. - =================== ===== = - ``includeEpisodes`` 1.9.0 ❔ - ``id`` 1.9.0 ❔ - =================== ===== = + .. table:: + :widths: 55 30 15 + + =================== ===== = + Parameter Vers. + =================== ===== = + ``includeEpisodes`` 1.9.0 ❔ + ``id`` 1.9.0 ❔ + =================== ===== = .. _getNewestPodcasts: ``getNewestPodcasts`` ❔ 1.14.0 - ========= ====== = - Parameter Vers. - ========= ====== = - ``count`` 1.14.0 ❔ - ========= ====== = + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``count`` 1.14.0 ❔ + ========= ====== = .. _refreshPodcasts: @@ -799,44 +940,56 @@ Podcast ``createPodcastChannel`` ❔ 1.9.0 - ========= ===== = - Parameter Vers. - ========= ===== = - ``url`` 1.9.0 ❔ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``url`` 1.9.0 ❔ + ========= ===== = .. _deletePodcastChannel: ``deletePodcastChannel`` ❔ 1.9.0 - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` 1.9.0 ❔ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = .. _deletePodcastEpisode: ``deletePodcastEpisode`` ❔ 1.9.0 - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` 1.9.0 ❔ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = .. _downloadPodcastEpisode: ``downloadPodcastEpisode`` ❔ 1.9.0 - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` 1.9.0 ❔ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = Jukebox ^^^^^^^ @@ -846,15 +999,18 @@ Jukebox ``jukeboxControl`` ✔️ - ========== ===== = - Parameter Vers. - ========== ===== = - ``action`` ✔️ - ``index`` ✔️ - ``offset`` ✔️ - ``id`` ✔️ - ``gain`` ❌ - ========== ===== = + .. table:: + :widths: 55 30 15 + + ========== ===== = + Parameter Vers. + ========== ===== = + ``action`` ✔️ + ``index`` ✔️ + ``offset`` ✔️ + ``id`` ✔️ + ``gain`` ❌ + ========== ===== = Internet radio ^^^^^^^^^^^^^^ @@ -871,38 +1027,47 @@ Internet radio ``createInternetRadioStation`` ❔ 1.16.0 - =============== ====== = - Parameter Vers. - =============== ====== = - ``streamUrl`` 1.16.0 ❔ - ``name`` 1.16.0 ❔ - ``homepageUrl`` 1.16.0 ❔ - =============== ====== = + .. table:: + :widths: 55 30 15 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``streamUrl`` 1.16.0 ❔ + ``name`` 1.16.0 ❔ + ``homepageUrl`` 1.16.0 ❔ + =============== ====== = .. _updateInternetRadioStation: ``updateInternetRadioStation`` ❔ 1.16.0 - =============== ====== = - Parameter Vers. - =============== ====== = - ``id`` 1.16.0 ❔ - ``streamUrl`` 1.16.0 ❔ - ``name`` 1.16.0 ❔ - ``homepageUrl`` 1.16.0 ❔ - =============== ====== = + .. table:: + :widths: 55 30 15 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``id`` 1.16.0 ❔ + ``streamUrl`` 1.16.0 ❔ + ``name`` 1.16.0 ❔ + ``homepageUrl`` 1.16.0 ❔ + =============== ====== = .. _deleteInternetRadioStation: ``deleteInternetRadioStation`` ❔ 1.16.0 - =============== ====== = - Parameter Vers. - =============== ====== = - ``id`` 1.16.0 ❔ - =============== ====== = + .. table:: + :widths: 55 30 15 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``id`` 1.16.0 ❔ + =============== ====== = Chat ^^^^ @@ -912,22 +1077,28 @@ Chat ``getChatMessages`` ✔️ - ========= ===== = - Parameter Vers. - ========= ===== = - ``since`` ✔️ - ========= ===== = + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``since`` ✔️ + ========= ===== = .. _addChatMessage: ``addChatMessage`` ✔️ - =========== ===== = - Parameter Vers. - =========== ===== = - ``message`` ✔️ - =========== ===== = + .. table:: + :widths: 55 30 15 + + =========== ===== = + Parameter Vers. + =========== ===== = + ``message`` ✔️ + =========== ===== = User management ^^^^^^^^^^^^^^^ @@ -937,11 +1108,14 @@ User management ``getUser`` ✔️ - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = .. _getUsers: @@ -955,77 +1129,89 @@ User management ``createUser`` ✔️ - ======================= ====== = - Parameter Vers. - ======================= ====== = - ``username`` ✔️ - ``password`` ✔️ - ``email`` ✔️ - ``ldapAuthenticated`` - ``adminRole`` ✔️ - ``settingsRole`` - ``streamRole`` - ``jukeboxRole`` ✔️ - ``downloadRole`` - ``uploadRole`` - ``playlistRole`` - ``coverArtRole`` - ``commentRole`` - ``podcastRole`` - ``shareRole`` - ``videoConversionRole`` 1.14.0 - ``musicFolderId`` 1.12.0 📅 - ======================= ====== = + .. table:: + :widths: 55 30 15 + + ======================= ====== = + Parameter Vers. + ======================= ====== = + ``username`` ✔️ + ``password`` ✔️ + ``email`` ✔️ + ``ldapAuthenticated`` + ``adminRole`` ✔️ + ``settingsRole`` + ``streamRole`` + ``jukeboxRole`` ✔️ + ``downloadRole`` + ``uploadRole`` + ``playlistRole`` + ``coverArtRole`` + ``commentRole`` + ``podcastRole`` + ``shareRole`` + ``videoConversionRole`` 1.14.0 + ``musicFolderId`` 1.12.0 📅 + ======================= ====== = .. _updateUser: ``updateUser`` ✔️ 1.10.2 - ======================= ====== = - Parameter Vers. - ======================= ====== = - ``username`` 1.10.2 ✔️ - ``password`` 1.10.2 ✔️ - ``email`` 1.10.2 ✔️ - ``ldapAuthenticated`` 1.10.2 - ``adminRole`` 1.10.2 ✔️ - ``settingsRole`` 1.10.2 - ``streamRole`` 1.10.2 - ``jukeboxRole`` 1.10.2 ✔️ - ``downloadRole`` 1.10.2 - ``uploadRole`` 1.10.2 - ``coverArtRole`` 1.10.2 - ``commentRole`` 1.10.2 - ``podcastRole`` 1.10.2 - ``shareRole`` 1.10.2 - ``videoConversionRole`` 1.14.0 - ``musicFolderId`` 1.12.0 📅 - ``maxBitRate`` 1.13.0 📅 - ======================= ====== = + .. table:: + :widths: 55 30 15 + + ======================= ====== = + Parameter Vers. + ======================= ====== = + ``username`` 1.10.2 ✔️ + ``password`` 1.10.2 ✔️ + ``email`` 1.10.2 ✔️ + ``ldapAuthenticated`` 1.10.2 + ``adminRole`` 1.10.2 ✔️ + ``settingsRole`` 1.10.2 + ``streamRole`` 1.10.2 + ``jukeboxRole`` 1.10.2 ✔️ + ``downloadRole`` 1.10.2 + ``uploadRole`` 1.10.2 + ``coverArtRole`` 1.10.2 + ``commentRole`` 1.10.2 + ``podcastRole`` 1.10.2 + ``shareRole`` 1.10.2 + ``videoConversionRole`` 1.14.0 + ``musicFolderId`` 1.12.0 📅 + ``maxBitRate`` 1.13.0 📅 + ======================= ====== = .. _deleteUser: ``deleteUser`` ✔️ - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = .. _changePassword: ``changePassword`` ✔️ - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ``password`` ✔️ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ``password`` ✔️ + ============ ===== = Bookmarks ^^^^^^^^^ @@ -1042,24 +1228,30 @@ Bookmarks ``createBookmark`` ❔ 1.9.0 - ============ ===== = - Parameter Vers. - ============ ===== = - ``id`` 1.9.0 ❔ - ``position`` 1.9.0 ❔ - ``comment`` 1.9.0 ❔ - ============ ===== = + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` 1.9.0 ❔ + ``position`` 1.9.0 ❔ + ``comment`` 1.9.0 ❔ + ============ ===== = .. _deleteBookmark: ``deleteBookmark`` ❔ 1.9.0 - =============== ===== = - Parameter Vers. - =============== ===== = - ``id`` 1.9.0 ❔ - =============== ===== = + .. table:: + :widths: 55 30 15 + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` 1.9.0 ❔ + =============== ===== = .. _getPlayQueue: @@ -1073,13 +1265,16 @@ Bookmarks ``savePlayQueue`` ❔ 1.12.0 - ============ ====== = - Parameter Vers. - ============ ====== = - ``id`` 1.12.0 ❔ - ``current`` 1.12.0 ❔ - ``position`` 1.12.0 ❔ - ============ ====== = + .. table:: + :widths: 55 30 15 + + ============ ====== = + Parameter Vers. + ============ ====== = + ``id`` 1.12.0 ❔ + ``current`` 1.12.0 ❔ + ``position`` 1.12.0 ❔ + ============ ====== = Library scanning ^^^^^^^^^^^^^^^^ diff --git a/docs/conf.py b/docs/conf.py index fd69c425..abea6040 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ "github_user": "spl0k", "github_repo": "supysonic", } -html_static_path = [] +html_static_path = ["_static"] # Default alabaseter sidebars + localtoc html_sidebars = { From 05b1a1123ff482a84ee8d09706068cc5384a59fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 23 Jan 2021 15:44:15 +0100 Subject: [PATCH 058/237] CRLF -> LF --- config.sample | 178 +- docs/api.rst | 2966 +++++++++++++++++----------------- docs/conf.py | 176 +- docs/index.rst | 58 +- docs/jukebox.rst | 90 +- docs/setup/configuration.rst | 616 +++---- docs/transcoding.rst | 296 ++-- 7 files changed, 2190 insertions(+), 2190 deletions(-) diff --git a/config.sample b/config.sample index d7788b9b..897cdd77 100644 --- a/config.sample +++ b/config.sample @@ -1,89 +1,89 @@ -[base] -; A database URI. See the 'schema' folder for schema creation scripts. Note that -; you don't have to run these scripts yourself. -; Default: sqlite:////tmp/supysonic/supysonic.db -;database_uri = sqlite:////var/supysonic/supysonic.db -;database_uri = mysql://supysonic:supysonic@localhost/supysonic -;database_uri = postgres://supysonic:supysonic@localhost/supysonic - -; Optional, restrict scanner to these extensions. Default: none -;scanner_extensions = mp3 ogg - -; Should the scanner follow symbolic links? Default: no -follow_symlinks = no - -[webapp] -; Optional cache directory. Default: /tmp/supysonic -cache_dir = /var/supysonic/cache - -; Main cache max size in MB. Default: 512 -cache_size = 512 - -; Transcode cache max size in MB. Default: 1024 (1GB) -transcode_cache_size = 1024 - -; Optional rotating log file. Default: none -log_file = /var/supysonic/supysonic.log - -; Log level. Possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL. -; Default: WARNING -log_level = WARNING - -; Enable the Subsonic REST API. You'll most likely want to keep this on, here -; for testing purposes. Default: on -;mount_api = on - -; Enable the administrative web interface. Default: on -;mount_webui = on - -; Space separated list of prefixes that should be ignored on index endpoints -; Default: El La Le Las Les Los The -index_ignored_prefixes = El La Le Las Les Los The - -[daemon] -; Socket file the daemon will listen on for incoming management commands -; Default: /tmp/supysonic/supysonic.sock -socket = /var/run/supysonic.sock - -; Defines if the file watcher should be started. Default: yes -run_watcher = yes - -; Delay in seconds before triggering scanning operation after a change have been -; detected. -; This prevents running too many scans when multiple changes are detected for a -; single file over a short time span. Default: 5 -wait_delay = 5 - -; Command used by the jukebox -jukebox_command = mplayer -ss %offset %path - -; Optional rotating log file for the scanner daemon. Logs to stderr if empty -log_file = /var/supysonic/supysonic-daemon.log -log_level = INFO - -[lastfm] -; API and secret key to enable scrobbling. http://www.last.fm/api/accounts -; Defaults: none -;api_key = -;secret = - -[transcoding] -; Programs used to convert from one format/bitrate to another. Defaults: none -transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - -transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - -decoder_mp3 = mpg123 --quiet -w - %srcpath -decoder_ogg = oggdec -o %srcpath -decoder_flac = flac -d -c -s %srcpath -encoder_mp3 = lame --quiet -b %outrate - - -encoder_ogg = oggenc2 -Q -M %outrate - - -; Default format, used when a client requests a bitrate lower than the original -; file and no specific format -default_transcode_target = mp3 - -[mimetypes] -; Extension to mimetype mappings in case your system has some trouble guessing -; Default: none -;mp3 = audio/mpeg -;ogg = audio/vorbis - +[base] +; A database URI. See the 'schema' folder for schema creation scripts. Note that +; you don't have to run these scripts yourself. +; Default: sqlite:////tmp/supysonic/supysonic.db +;database_uri = sqlite:////var/supysonic/supysonic.db +;database_uri = mysql://supysonic:supysonic@localhost/supysonic +;database_uri = postgres://supysonic:supysonic@localhost/supysonic + +; Optional, restrict scanner to these extensions. Default: none +;scanner_extensions = mp3 ogg + +; Should the scanner follow symbolic links? Default: no +follow_symlinks = no + +[webapp] +; Optional cache directory. Default: /tmp/supysonic +cache_dir = /var/supysonic/cache + +; Main cache max size in MB. Default: 512 +cache_size = 512 + +; Transcode cache max size in MB. Default: 1024 (1GB) +transcode_cache_size = 1024 + +; Optional rotating log file. Default: none +log_file = /var/supysonic/supysonic.log + +; Log level. Possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL. +; Default: WARNING +log_level = WARNING + +; Enable the Subsonic REST API. You'll most likely want to keep this on, here +; for testing purposes. Default: on +;mount_api = on + +; Enable the administrative web interface. Default: on +;mount_webui = on + +; Space separated list of prefixes that should be ignored on index endpoints +; Default: El La Le Las Les Los The +index_ignored_prefixes = El La Le Las Les Los The + +[daemon] +; Socket file the daemon will listen on for incoming management commands +; Default: /tmp/supysonic/supysonic.sock +socket = /var/run/supysonic.sock + +; Defines if the file watcher should be started. Default: yes +run_watcher = yes + +; Delay in seconds before triggering scanning operation after a change have been +; detected. +; This prevents running too many scans when multiple changes are detected for a +; single file over a short time span. Default: 5 +wait_delay = 5 + +; Command used by the jukebox +jukebox_command = mplayer -ss %offset %path + +; Optional rotating log file for the scanner daemon. Logs to stderr if empty +log_file = /var/supysonic/supysonic-daemon.log +log_level = INFO + +[lastfm] +; API and secret key to enable scrobbling. http://www.last.fm/api/accounts +; Defaults: none +;api_key = +;secret = + +[transcoding] +; Programs used to convert from one format/bitrate to another. Defaults: none +transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - +transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - +decoder_mp3 = mpg123 --quiet -w - %srcpath +decoder_ogg = oggdec -o %srcpath +decoder_flac = flac -d -c -s %srcpath +encoder_mp3 = lame --quiet -b %outrate - - +encoder_ogg = oggenc2 -Q -M %outrate - + +; Default format, used when a client requests a bitrate lower than the original +; file and no specific format +default_transcode_target = mp3 + +[mimetypes] +; Extension to mimetype mappings in case your system has some trouble guessing +; Default: none +;mp3 = audio/mpeg +;ogg = audio/vorbis + diff --git a/docs/api.rst b/docs/api.rst index 60d5f00c..dd3e40ee 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,1483 +1,1483 @@ -Subsonic API breakdown -====================== - -This page lists all the API methods and their parameters up to the version -1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features -Supysonic support, plan on supporting, or won't. - -At the moment, the current target API version is 1.10.2. - -The following information was gathered by *diff*-ing various snapshots of the -`Subsonic API page`__. - -__ http://www.subsonic.org/pages/api.jsp - -Methods and parameters listing ------------------------------- - -Statuses explanation: - -* 📅: planned -* ✔️: done -* ❌: done as not supported -* 🔴: won't be implemented -* ❔: not decided yet - -The version column specifies the API version which added the related method or -parameter. When no version is given, it means the item was introduced prior to -or with version 1.8.0. - -All methods / pseudo-TOC -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. table:: - :widths: 55 30 15 - - =========================== ====== = - Method Vers. - =========================== ====== = - ping_ ✔️ - getLicense_ ✔️ - getMusicFolders_ ✔️ - getIndexes_ ✔️ - getMusicDirectory_ ✔️ - getGenres_ 1.9.0 ✔️ - getArtists_ ✔️ - getArtist_ ✔️ - getAlbum_ ✔️ - getSong_ ✔️ - getVideos_ ❌ - getVideoInfo_ 1.15.0 🔴 - getArtistInfo_ 1.11.0 📅 - getArtistInfo2_ 1.11.0 📅 - getAlbumInfo_ 1.14.0 📅 - getAlbumInfo2_ 1.14.0 📅 - getSimilarSongs_ 1.11.0 ❔ - getSimilarSongs2_ 1.11.0 ❔ - getTopSongs_ 1.13.0 ❔ - getAlbumList_ ✔️ - getAlbumList2_ ✔️ - getRandomSongs_ ✔️ - getSongsByGenre_ 1.9.0 ✔️ - getNowPlaying_ ✔️ - getStarred_ ✔️ - getStarred2_ ✔️ - :ref:`search ` ✔️ - search2_ ✔️ - search3_ ✔️ - getPlaylists_ ✔️ - getPlaylist_ ✔️ - createPlaylist_ ✔️ - updatePlaylist_ ✔️ - deletePlaylist_ ✔️ - stream_ ✔️ - download_ ✔️ - hls_ 1.9.0 🔴 - getCaptions_ 1.15.0 🔴 - getCoverArt_ ✔️ - getLyrics_ ✔️ - getAvatar_ ❌ - star_ ✔️ - unstar_ ✔️ - setRating_ ✔️ - scrobble_ ✔️ - getShares_ ❌ - createShare_ ❌ - updateShare_ ❌ - deleteShare_ ❌ - getPodcasts_ ❔ - getNewestPodcasts_ 1.14.0 ❔ - refreshPodcasts_ 1.9.0 ❔ - createPodcastChannel_ 1.9.0 ❔ - deletePodcastChannel_ 1.9.0 ❔ - deletePodcastEpisode_ 1.9.0 ❔ - downloadPodcastEpisode_ 1.9.0 ❔ - jukeboxControl_ ✔️ - getInternetRadioStations_ 1.9.0 ✔️ - createInternetRadioStation_ 1.16.0 ✔️ - updateInternetRadioStation_ 1.16.0 ✔️ - deleteInternetRadioStation_ 1.16.0 ✔️ - getChatMessages_ ✔️ - addChatMessage_ ✔️ - getUser_ ✔️ - getUsers_ 1.9.0 ✔️ - createUser_ ✔️ - updateUser_ 1.10.2 ✔️ - deleteUser_ ✔️ - changePassword_ ✔️ - getBookmarks_ 1.9.0 ❔ - createBookmark_ 1.9.0 ❔ - deleteBookmark_ 1.9.0 ❔ - getPlayQueue_ 1.12.0 ❔ - savePlayQueue_ 1.12.0 ❔ - getScanStatus_ 1.15.0 ✔️ - startScan_ 1.15.0 ✔️ - =========================== ====== = - -Global -^^^^^^ - -Parameters used for any request - -.. table:: - :widths: 55 30 15 - - ===== ====== = - P. Vers. - ===== ====== = - ``u`` ✔️ - ``p`` ✔️ - ``t`` 1.13.0 🔴 - ``s`` 1.13.0 🔴 - ``v`` ✔️ - ``c`` ✔️ - ``f`` ✔️ - ===== ====== = - -Error codes - -.. table:: - :widths: 55 30 15 - - == ====== = - # Vers. - == ====== = - 0 ✔️ - 10 ✔️ - 20 ✔️ - 30 ✔️ - 40 ✔️ - 41 1.15.0 📅 - 50 ✔️ - 60 ✔️ - 70 ✔️ - == ====== = - -System -^^^^^^ - -.. _ping: - -``ping`` - ✔️ - - No parameter - -.. _getLicense: - -``getLicense`` - ✔️ - - No parameter - -Browsing -^^^^^^^^ - -.. _getMusicFolders: - -``getMusicFolders`` - ✔️ - - No parameter - -.. _getIndexes: - -``getIndexes`` - ✔️ - - .. table:: - :widths: 55 30 15 - - =================== ===== = - Parameter Vers. - =================== ===== = - ``musicFolderId`` ✔️ - ``ifModifiedSince`` ✔️ - =================== ===== = - -.. _getMusicDirectory: - -``getMusicDirectory`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = - -.. _getGenres: - -``getGenres`` - ✔️ 1.9.0 - - No parameter - -.. _getArtists: - -``getArtists`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``musicFolderId`` 1.14.0 📅 - ================= ====== = - -.. _getArtist: - -``getArtist`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = - -.. _getAlbum: - -``getAlbum`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = - -.. _getSong: - -``getSong`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = - -.. _getVideos: - -``getVideos`` - ❌ - - No parameter - -.. _getVideoInfo: - -``getVideoInfo`` - 🔴 1.15.0 - - .. table:: - :widths: 55 30 15 - - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.15.0 🔴 - ========= ====== = - -.. _getArtistInfo: - -``getArtistInfo`` - 📅 1.11.0 - - .. table:: - :widths: 55 30 15 - - ===================== ====== = - Parameter Vers. - ===================== ====== = - ``id`` 1.11.0 📅 - ``count`` 1.11.0 📅 - ``includeNotPresent`` 1.11.0 📅 - ===================== ====== = - -.. _getArtistInfo2: - -``getArtistInfo2`` - 📅 1.11.0 - - .. table:: - :widths: 55 30 15 - - ===================== ====== = - Parameter Vers. - ===================== ====== = - ``id`` 1.11.0 📅 - ``count`` 1.11.0 📅 - ``includeNotPresent`` 1.11.0 📅 - ===================== ====== = - -.. _getAlbumInfo: - -``getAlbumInfo`` - 📅 1.14.0 - - .. table:: - :widths: 55 30 15 - - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.14.0 📅 - ========= ====== = - -.. _getAlbumInfo2: - -``getAlbumInfo2`` - 📅 1.14.0 - - .. table:: - :widths: 55 30 15 - - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.14.0 📅 - ========= ====== = - -.. _getSimilarSongs: - -``getSimilarSongs`` - ❔ 1.11.0 - - .. table:: - :widths: 55 30 15 - - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.11.0 ❔ - ``count`` 1.11.0 ❔ - ========= ====== = - -.. _getSimilarSongs2: - -``getSimilarSongs2`` - ❔ 1.11.0 - - .. table:: - :widths: 55 30 15 - - ========= ====== = - Parameter Vers. - ========= ====== = - ``id`` 1.11.0 ❔ - ``count`` 1.11.0 ❔ - ========= ====== = - -.. _getTopSongs: - -``getTopSongs`` - ❔ 1.13.0 - - .. table:: - :widths: 55 30 15 - - ========== ====== = - Parameter Vers. - ========== ====== = - ``artist`` 1.13.0 ❔ - ``count`` 1.13.0 ❔ - ========== ====== = - -Album/song lists -^^^^^^^^^^^^^^^^ - -.. _getAlbumList: - -``getAlbumList`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``type`` ✔️ - ``size`` ✔️ - ``offset`` ✔️ - ``fromYear`` ✔️ - ``toYear`` ✔️ - ``genre`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = - - .. versionadded:: 1.10.1 - ``byYear`` and ``byGenre`` were added to ``type`` - -.. _getAlbumList2: - -``getAlbumList2`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``type`` ✔️ - ``size`` ✔️ - ``offset`` ✔️ - ``fromYear`` ✔️ - ``toYear`` ✔️ - ``genre`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = - - .. versionadded:: 1.10.1 - ``byYear`` and ``byGenre`` were added to ``type`` - -.. _getRandomSongs: - -``getRandomSongs`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ===== = - Parameter Vers. - ================= ===== = - ``size`` ✔️ - ``genre`` ✔️ - ``fromYear`` ✔️ - ``toYear`` ✔️ - ``musicFolderId`` ✔️ - ================= ===== = - -.. _getSongsByGenre: - -``getSongsByGenre`` - ✔️ 1.9.0 - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``genre`` 1.9.0 ✔️ - ``count`` 1.9.0 ✔️ - ``offset`` 1.9.0 ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = - -.. _getNowPlaying: - -``getNowPlaying`` - ✔️ - - No parameter - -.. _getStarred: - -``getStarred`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``musicFolderId`` 1.12.0 📅 - ================= ====== = - -.. _getStarred2: - -``getStarred2`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``musicFolderId`` 1.12.0 📅 - ================= ====== = - -Searching -^^^^^^^^^ - -.. _search-: - -``search`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============= ===== = - Parameter Vers. - ============= ===== = - ``artist`` ✔️ - ``album`` ✔️ - ``title`` ✔️ - ``any`` ✔️ - ``count`` ✔️ - ``offset`` ✔️ - ``newerThan`` ✔️ - ============= ===== = - -.. _search2: - -``search2`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``query`` ✔️ - ``artistCount`` ✔️ - ``artistOffset`` ✔️ - ``albumCount`` ✔️ - ``albumOffset`` ✔️ - ``songCount`` ✔️ - ``songOffset`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = - -.. _search3: - -``search3`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ================= ====== = - Parameter Vers. - ================= ====== = - ``query`` ✔️ - ``artistCount`` ✔️ - ``artistOffset`` ✔️ - ``albumCount`` ✔️ - ``albumOffset`` ✔️ - ``songCount`` ✔️ - ``songOffset`` ✔️ - ``musicFolderId`` 1.12.0 📅 - ================= ====== = - -Playlists -^^^^^^^^^ - -.. _getPlaylists: - -``getPlaylists`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ============ ===== = - -.. _getPlaylist: - -``getPlaylist`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = - -.. _createPlaylist: - -``createPlaylist`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============== ===== = - Parameter Vers. - ============== ===== = - ``playlistId`` ✔️ - ``name`` ✔️ - ``songId`` ✔️ - ============== ===== = - -.. _updatePlaylist: - -``updatePlaylist`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ===================== ===== = - Parameter Vers. - ===================== ===== = - ``playlistId`` ✔️ - ``name`` ✔️ - ``comment`` ✔️ - ``public`` 1.9.0 ✔️ - ``songIdToAdd`` ✔️ - ``songIndexToRemove`` ✔️ - ===================== ===== = - -.. _deletePlaylist: - -``deletePlaylist`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = - -Media retrieval -^^^^^^^^^^^^^^^ - -.. _stream: - -``stream`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========================= ====== = - Parameter Vers. - ========================= ====== = - ``id`` ✔️ - ``maxBitRate`` ✔️ - ``format`` ✔️ - ``timeOffset`` ❌ - ``size`` ❌ - ``estimateContentLength`` ✔️ - ``converted`` 1.15.0 🔴 - ========================= ====== = - -.. _download: - -``download`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ========= ===== = - -.. _hls: - -``hls`` - 🔴 1.9.0 - - .. table:: - :widths: 55 30 15 - - ============== ====== = - Parameter Vers. - ============== ====== = - ``id`` 1.9.0 🔴 - ``bitRate`` 1.9.0 🔴 - ``audioTrack`` 1.15.0 🔴 - ============== ====== = - -.. _getCaptions: - -``getCaptions`` - 🔴 1.15.0 - - .. table:: - :widths: 55 30 15 - - ========== ====== = - Parameter Vers. - ========== ====== = - ``id`` 1.15.0 🔴 - ``format`` 1.15.0 🔴 - ========== ====== = - -.. _getCoverArt: - -``getCoverArt`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ✔️ - ``size`` ✔️ - ========= ===== = - -.. _getLyrics: - -``getLyrics`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========== ===== = - Parameter Vers. - ========== ===== = - ``artist`` ✔️ - ``title`` ✔️ - ========== ===== = - -.. _getAvatar: - -``getAvatar`` - ❌ - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ❌ - ============ ===== = - -Media annotation -^^^^^^^^^^^^^^^^ - -.. _star: - -``star`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``id`` ✔️ - ``albumId`` ✔️ - ``artistId`` ✔️ - ============ ===== = - -.. _unstar: - -``unstar`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``id`` ✔️ - ``albumId`` ✔️ - ``artistId`` ✔️ - ============ ===== = - -.. _setRating: - -``setRating`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========== ===== = - Parameter Vers. - ========== ===== = - ``id`` ✔️ - ``rating`` ✔️ - ========== ===== = - -.. _scrobble: - -``scrobble`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============== ===== = - Parameter Vers. - ============== ===== = - ``id`` ✔️ - ``time`` 1.9.0 ✔️ - ``submission`` ✔️ - ============== ===== = - -Sharing -^^^^^^^ - -.. _getShares: - -``getShares`` - ❌ - - No parameter - -.. _createShare: - -``createShare`` - ❌ - - .. table:: - :widths: 55 30 15 - - =============== ===== = - Parameter Vers. - =============== ===== = - ``id`` ❌ - ``description`` ❌ - ``expires`` ❌ - =============== ===== = - -.. _updateShare: - -``updateShare`` - ❌ - - .. table:: - :widths: 55 30 15 - - =============== ===== = - Parameter Vers. - =============== ===== = - ``id`` ❌ - ``description`` ❌ - ``expires`` ❌ - =============== ===== = - -.. _deleteShare: - -``deleteShare`` - ❌ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` ❌ - ========= ===== = - -Podcast -^^^^^^^ - -.. _getPodcasts: - -``getPodcasts`` - ❔ - - .. table:: - :widths: 55 30 15 - - =================== ===== = - Parameter Vers. - =================== ===== = - ``includeEpisodes`` 1.9.0 ❔ - ``id`` 1.9.0 ❔ - =================== ===== = - -.. _getNewestPodcasts: - -``getNewestPodcasts`` - ❔ 1.14.0 - - .. table:: - :widths: 55 30 15 - - ========= ====== = - Parameter Vers. - ========= ====== = - ``count`` 1.14.0 ❔ - ========= ====== = - -.. _refreshPodcasts: - -``refreshPodcasts`` - ❔ 1.9.0 - - No parameter - -.. _createPodcastChannel: - -``createPodcastChannel`` - ❔ 1.9.0 - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``url`` 1.9.0 ❔ - ========= ===== = - -.. _deletePodcastChannel: - -``deletePodcastChannel`` - ❔ 1.9.0 - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` 1.9.0 ❔ - ========= ===== = - -.. _deletePodcastEpisode: - -``deletePodcastEpisode`` - ❔ 1.9.0 - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` 1.9.0 ❔ - ========= ===== = - -.. _downloadPodcastEpisode: - -``downloadPodcastEpisode`` - ❔ 1.9.0 - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``id`` 1.9.0 ❔ - ========= ===== = - -Jukebox -^^^^^^^ - -.. _jukeboxControl: - -``jukeboxControl`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========== ===== = - Parameter Vers. - ========== ===== = - ``action`` ✔️ - ``index`` ✔️ - ``offset`` ✔️ - ``id`` ✔️ - ``gain`` ❌ - ========== ===== = - -Internet radio -^^^^^^^^^^^^^^ - -.. _getInternetRadioStations: - -``getInternetRadioStations`` - ❔ 1.9.0 - - No parameter - -.. _createInternetRadioStation: - -``createInternetRadioStation`` - ❔ 1.16.0 - - .. table:: - :widths: 55 30 15 - - =============== ====== = - Parameter Vers. - =============== ====== = - ``streamUrl`` 1.16.0 ❔ - ``name`` 1.16.0 ❔ - ``homepageUrl`` 1.16.0 ❔ - =============== ====== = - -.. _updateInternetRadioStation: - -``updateInternetRadioStation`` - ❔ 1.16.0 - - .. table:: - :widths: 55 30 15 - - =============== ====== = - Parameter Vers. - =============== ====== = - ``id`` 1.16.0 ❔ - ``streamUrl`` 1.16.0 ❔ - ``name`` 1.16.0 ❔ - ``homepageUrl`` 1.16.0 ❔ - =============== ====== = - -.. _deleteInternetRadioStation: - -``deleteInternetRadioStation`` - ❔ 1.16.0 - - .. table:: - :widths: 55 30 15 - - =============== ====== = - Parameter Vers. - =============== ====== = - ``id`` 1.16.0 ❔ - =============== ====== = - -Chat -^^^^ - -.. _getChatMessages: - -``getChatMessages`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ========= ===== = - Parameter Vers. - ========= ===== = - ``since`` ✔️ - ========= ===== = - -.. _addChatMessage: - -``addChatMessage`` - ✔️ - - .. table:: - :widths: 55 30 15 - - =========== ===== = - Parameter Vers. - =========== ===== = - ``message`` ✔️ - =========== ===== = - -User management -^^^^^^^^^^^^^^^ - -.. _getUser: - -``getUser`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ============ ===== = - -.. _getUsers: - -``getUsers`` - ✔️ 1.9.0 - - No parameter - -.. _createUser: - -``createUser`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ======================= ====== = - Parameter Vers. - ======================= ====== = - ``username`` ✔️ - ``password`` ✔️ - ``email`` ✔️ - ``ldapAuthenticated`` - ``adminRole`` ✔️ - ``settingsRole`` - ``streamRole`` - ``jukeboxRole`` ✔️ - ``downloadRole`` - ``uploadRole`` - ``playlistRole`` - ``coverArtRole`` - ``commentRole`` - ``podcastRole`` - ``shareRole`` - ``videoConversionRole`` 1.14.0 - ``musicFolderId`` 1.12.0 📅 - ======================= ====== = - -.. _updateUser: - -``updateUser`` - ✔️ 1.10.2 - - .. table:: - :widths: 55 30 15 - - ======================= ====== = - Parameter Vers. - ======================= ====== = - ``username`` 1.10.2 ✔️ - ``password`` 1.10.2 ✔️ - ``email`` 1.10.2 ✔️ - ``ldapAuthenticated`` 1.10.2 - ``adminRole`` 1.10.2 ✔️ - ``settingsRole`` 1.10.2 - ``streamRole`` 1.10.2 - ``jukeboxRole`` 1.10.2 ✔️ - ``downloadRole`` 1.10.2 - ``uploadRole`` 1.10.2 - ``coverArtRole`` 1.10.2 - ``commentRole`` 1.10.2 - ``podcastRole`` 1.10.2 - ``shareRole`` 1.10.2 - ``videoConversionRole`` 1.14.0 - ``musicFolderId`` 1.12.0 📅 - ``maxBitRate`` 1.13.0 📅 - ======================= ====== = - -.. _deleteUser: - -``deleteUser`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ============ ===== = - -.. _changePassword: - -``changePassword`` - ✔️ - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``username`` ✔️ - ``password`` ✔️ - ============ ===== = - -Bookmarks -^^^^^^^^^ - -.. _getBookmarks: - -``getBookmarks`` - ❔ 1.9.0 - - No parameter - -.. _createBookmark: - -``createBookmark`` - ❔ 1.9.0 - - .. table:: - :widths: 55 30 15 - - ============ ===== = - Parameter Vers. - ============ ===== = - ``id`` 1.9.0 ❔ - ``position`` 1.9.0 ❔ - ``comment`` 1.9.0 ❔ - ============ ===== = - -.. _deleteBookmark: - -``deleteBookmark`` - ❔ 1.9.0 - - .. table:: - :widths: 55 30 15 - - =============== ===== = - Parameter Vers. - =============== ===== = - ``id`` 1.9.0 ❔ - =============== ===== = - -.. _getPlayQueue: - -``getPlayQueue`` - ❔ 1.12.0 - - No parameter - -.. _savePlayQueue: - -``savePlayQueue`` - ❔ 1.12.0 - - .. table:: - :widths: 55 30 15 - - ============ ====== = - Parameter Vers. - ============ ====== = - ``id`` 1.12.0 ❔ - ``current`` 1.12.0 ❔ - ``position`` 1.12.0 ❔ - ============ ====== = - -Library scanning -^^^^^^^^^^^^^^^^ - -.. _getScanStatus: - -``getScanStatus`` - ✔️ 1.15.0 - - No parameter - -.. _startScan: - -``startScan`` - ✔️ 1.15.0 - - No parameter - -Changes by version ------------------- - -Version 1.9.0 -^^^^^^^^^^^^^ - -Added methods: - -* getGenres_ -* getSongsByGenre_ -* hls_ -* refreshPodcasts_ -* createPodcastChannel_ -* deletePodcastChannel_ -* deletePodcastEpisode_ -* downloadPodcastEpisode_ -* getInternetRadioStations_ -* getUsers_ -* getBookmarks_ -* createBookmark_ -* deleteBookmark_ - -Added method parameters: - -* updatePlaylist_ - - * ``public`` - -* scrobble_ - - * ``time`` - -* getPodcasts_ - - * ``includeEpisodes`` - * ``id`` - -Version 1.10.1 -^^^^^^^^^^^^^^ - -Added method parameters: - -* getAlbumList_ - - * ``fromYear`` - * ``toYear`` - * ``genre`` - -* getAlbumList2_ - - * ``fromYear`` - * ``toYear`` - * ``genre`` - -Version 1.10.2 -^^^^^^^^^^^^^^ - -Added methods: - -* updateUser_ - -Version 1.11.0 -^^^^^^^^^^^^^^ - -Added methods: - -* getArtistInfo_ -* getArtistInfo2_ -* getSimilarSongs_ -* getSimilarSongs2_ - -Version 1.12.0 -^^^^^^^^^^^^^^ - -Added methods: - -* getPlayQueue_ -* savePlayQueue_ - -Added method parameters: - -* getAlbumList_ - - * ``musicFolderId`` - -* getAlbumList2_ - - * ``musicFolderId`` - -* getSongsByGenre_ - - * ``musicFolderId`` - -* getStarred_ - - * ``musicFolderId`` - -* getStarred2_ - - * ``musicFolderId`` - -* search2_ - - * ``musicFolderId`` - -* search3_ - - * ``musicFolderId`` - -* createUser_ - - * ``musicFolderId`` - -* updateUser_ - - * ``musicFolderId`` - -Version 1.13.0 -^^^^^^^^^^^^^^ - -Added global parameters: - -* ``t`` -* ``s`` - -Added methods: - -* getTopSongs_ - -Added method parameters: - -* updateUser_ - - * ``maxBitRate`` - -Version 1.14.0 -^^^^^^^^^^^^^^ - -Added methods: - -* getAlbumInfo_ -* getAlbumInfo2_ -* getNewestPodcasts_ - -Added method parameters: - -* getArtists_ - - * ``musicFolderId`` - -* createUser_ - - * ``videoConversionRole`` - -* updateUser_ - - * ``videoConversionRole`` - -Version 1.15.0 -^^^^^^^^^^^^^^ - -Added error code ``41`` - -Added methods: - -* getVideoInfo_ -* getCaptions_ -* getScanStatus_ -* startScan_ - -Added method parameters: - -* stream_ - - * ``converted`` - -* hls_ - - * ``audioTrack`` - -Version 1.16.0 -^^^^^^^^^^^^^^ - -Added methods: - -* createInternetRadioStation_ -* updateInternetRadioStation_ -* deleteInternetRadioStation_ +Subsonic API breakdown +====================== + +This page lists all the API methods and their parameters up to the version +1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features +Supysonic support, plan on supporting, or won't. + +At the moment, the current target API version is 1.10.2. + +The following information was gathered by *diff*-ing various snapshots of the +`Subsonic API page`__. + +__ http://www.subsonic.org/pages/api.jsp + +Methods and parameters listing +------------------------------ + +Statuses explanation: + +* 📅: planned +* ✔️: done +* ❌: done as not supported +* 🔴: won't be implemented +* ❔: not decided yet + +The version column specifies the API version which added the related method or +parameter. When no version is given, it means the item was introduced prior to +or with version 1.8.0. + +All methods / pseudo-TOC +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. table:: + :widths: 55 30 15 + + =========================== ====== = + Method Vers. + =========================== ====== = + ping_ ✔️ + getLicense_ ✔️ + getMusicFolders_ ✔️ + getIndexes_ ✔️ + getMusicDirectory_ ✔️ + getGenres_ 1.9.0 ✔️ + getArtists_ ✔️ + getArtist_ ✔️ + getAlbum_ ✔️ + getSong_ ✔️ + getVideos_ ❌ + getVideoInfo_ 1.15.0 🔴 + getArtistInfo_ 1.11.0 📅 + getArtistInfo2_ 1.11.0 📅 + getAlbumInfo_ 1.14.0 📅 + getAlbumInfo2_ 1.14.0 📅 + getSimilarSongs_ 1.11.0 ❔ + getSimilarSongs2_ 1.11.0 ❔ + getTopSongs_ 1.13.0 ❔ + getAlbumList_ ✔️ + getAlbumList2_ ✔️ + getRandomSongs_ ✔️ + getSongsByGenre_ 1.9.0 ✔️ + getNowPlaying_ ✔️ + getStarred_ ✔️ + getStarred2_ ✔️ + :ref:`search ` ✔️ + search2_ ✔️ + search3_ ✔️ + getPlaylists_ ✔️ + getPlaylist_ ✔️ + createPlaylist_ ✔️ + updatePlaylist_ ✔️ + deletePlaylist_ ✔️ + stream_ ✔️ + download_ ✔️ + hls_ 1.9.0 🔴 + getCaptions_ 1.15.0 🔴 + getCoverArt_ ✔️ + getLyrics_ ✔️ + getAvatar_ ❌ + star_ ✔️ + unstar_ ✔️ + setRating_ ✔️ + scrobble_ ✔️ + getShares_ ❌ + createShare_ ❌ + updateShare_ ❌ + deleteShare_ ❌ + getPodcasts_ ❔ + getNewestPodcasts_ 1.14.0 ❔ + refreshPodcasts_ 1.9.0 ❔ + createPodcastChannel_ 1.9.0 ❔ + deletePodcastChannel_ 1.9.0 ❔ + deletePodcastEpisode_ 1.9.0 ❔ + downloadPodcastEpisode_ 1.9.0 ❔ + jukeboxControl_ ✔️ + getInternetRadioStations_ 1.9.0 ✔️ + createInternetRadioStation_ 1.16.0 ✔️ + updateInternetRadioStation_ 1.16.0 ✔️ + deleteInternetRadioStation_ 1.16.0 ✔️ + getChatMessages_ ✔️ + addChatMessage_ ✔️ + getUser_ ✔️ + getUsers_ 1.9.0 ✔️ + createUser_ ✔️ + updateUser_ 1.10.2 ✔️ + deleteUser_ ✔️ + changePassword_ ✔️ + getBookmarks_ 1.9.0 ❔ + createBookmark_ 1.9.0 ❔ + deleteBookmark_ 1.9.0 ❔ + getPlayQueue_ 1.12.0 ❔ + savePlayQueue_ 1.12.0 ❔ + getScanStatus_ 1.15.0 ✔️ + startScan_ 1.15.0 ✔️ + =========================== ====== = + +Global +^^^^^^ + +Parameters used for any request + +.. table:: + :widths: 55 30 15 + + ===== ====== = + P. Vers. + ===== ====== = + ``u`` ✔️ + ``p`` ✔️ + ``t`` 1.13.0 🔴 + ``s`` 1.13.0 🔴 + ``v`` ✔️ + ``c`` ✔️ + ``f`` ✔️ + ===== ====== = + +Error codes + +.. table:: + :widths: 55 30 15 + + == ====== = + # Vers. + == ====== = + 0 ✔️ + 10 ✔️ + 20 ✔️ + 30 ✔️ + 40 ✔️ + 41 1.15.0 📅 + 50 ✔️ + 60 ✔️ + 70 ✔️ + == ====== = + +System +^^^^^^ + +.. _ping: + +``ping`` + ✔️ + + No parameter + +.. _getLicense: + +``getLicense`` + ✔️ + + No parameter + +Browsing +^^^^^^^^ + +.. _getMusicFolders: + +``getMusicFolders`` + ✔️ + + No parameter + +.. _getIndexes: + +``getIndexes`` + ✔️ + + .. table:: + :widths: 55 30 15 + + =================== ===== = + Parameter Vers. + =================== ===== = + ``musicFolderId`` ✔️ + ``ifModifiedSince`` ✔️ + =================== ===== = + +.. _getMusicDirectory: + +``getMusicDirectory`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getGenres: + +``getGenres`` + ✔️ 1.9.0 + + No parameter + +.. _getArtists: + +``getArtists`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.14.0 📅 + ================= ====== = + +.. _getArtist: + +``getArtist`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getAlbum: + +``getAlbum`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getSong: + +``getSong`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _getVideos: + +``getVideos`` + ❌ + + No parameter + +.. _getVideoInfo: + +``getVideoInfo`` + 🔴 1.15.0 + + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.15.0 🔴 + ========= ====== = + +.. _getArtistInfo: + +``getArtistInfo`` + 📅 1.11.0 + + .. table:: + :widths: 55 30 15 + + ===================== ====== = + Parameter Vers. + ===================== ====== = + ``id`` 1.11.0 📅 + ``count`` 1.11.0 📅 + ``includeNotPresent`` 1.11.0 📅 + ===================== ====== = + +.. _getArtistInfo2: + +``getArtistInfo2`` + 📅 1.11.0 + + .. table:: + :widths: 55 30 15 + + ===================== ====== = + Parameter Vers. + ===================== ====== = + ``id`` 1.11.0 📅 + ``count`` 1.11.0 📅 + ``includeNotPresent`` 1.11.0 📅 + ===================== ====== = + +.. _getAlbumInfo: + +``getAlbumInfo`` + 📅 1.14.0 + + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.14.0 📅 + ========= ====== = + +.. _getAlbumInfo2: + +``getAlbumInfo2`` + 📅 1.14.0 + + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.14.0 📅 + ========= ====== = + +.. _getSimilarSongs: + +``getSimilarSongs`` + ❔ 1.11.0 + + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.11.0 ❔ + ``count`` 1.11.0 ❔ + ========= ====== = + +.. _getSimilarSongs2: + +``getSimilarSongs2`` + ❔ 1.11.0 + + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``id`` 1.11.0 ❔ + ``count`` 1.11.0 ❔ + ========= ====== = + +.. _getTopSongs: + +``getTopSongs`` + ❔ 1.13.0 + + .. table:: + :widths: 55 30 15 + + ========== ====== = + Parameter Vers. + ========== ====== = + ``artist`` 1.13.0 ❔ + ``count`` 1.13.0 ❔ + ========== ====== = + +Album/song lists +^^^^^^^^^^^^^^^^ + +.. _getAlbumList: + +``getAlbumList`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``type`` ✔️ + ``size`` ✔️ + ``offset`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``genre`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + + .. versionadded:: 1.10.1 + ``byYear`` and ``byGenre`` were added to ``type`` + +.. _getAlbumList2: + +``getAlbumList2`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``type`` ✔️ + ``size`` ✔️ + ``offset`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``genre`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + + .. versionadded:: 1.10.1 + ``byYear`` and ``byGenre`` were added to ``type`` + +.. _getRandomSongs: + +``getRandomSongs`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ===== = + Parameter Vers. + ================= ===== = + ``size`` ✔️ + ``genre`` ✔️ + ``fromYear`` ✔️ + ``toYear`` ✔️ + ``musicFolderId`` ✔️ + ================= ===== = + +.. _getSongsByGenre: + +``getSongsByGenre`` + ✔️ 1.9.0 + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``genre`` 1.9.0 ✔️ + ``count`` 1.9.0 ✔️ + ``offset`` 1.9.0 ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +.. _getNowPlaying: + +``getNowPlaying`` + ✔️ + + No parameter + +.. _getStarred: + +``getStarred`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +.. _getStarred2: + +``getStarred2`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +Searching +^^^^^^^^^ + +.. _search-: + +``search`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============= ===== = + Parameter Vers. + ============= ===== = + ``artist`` ✔️ + ``album`` ✔️ + ``title`` ✔️ + ``any`` ✔️ + ``count`` ✔️ + ``offset`` ✔️ + ``newerThan`` ✔️ + ============= ===== = + +.. _search2: + +``search2`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``query`` ✔️ + ``artistCount`` ✔️ + ``artistOffset`` ✔️ + ``albumCount`` ✔️ + ``albumOffset`` ✔️ + ``songCount`` ✔️ + ``songOffset`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +.. _search3: + +``search3`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ================= ====== = + Parameter Vers. + ================= ====== = + ``query`` ✔️ + ``artistCount`` ✔️ + ``artistOffset`` ✔️ + ``albumCount`` ✔️ + ``albumOffset`` ✔️ + ``songCount`` ✔️ + ``songOffset`` ✔️ + ``musicFolderId`` 1.12.0 📅 + ================= ====== = + +Playlists +^^^^^^^^^ + +.. _getPlaylists: + +``getPlaylists`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = + +.. _getPlaylist: + +``getPlaylist`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _createPlaylist: + +``createPlaylist`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============== ===== = + Parameter Vers. + ============== ===== = + ``playlistId`` ✔️ + ``name`` ✔️ + ``songId`` ✔️ + ============== ===== = + +.. _updatePlaylist: + +``updatePlaylist`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ===================== ===== = + Parameter Vers. + ===================== ===== = + ``playlistId`` ✔️ + ``name`` ✔️ + ``comment`` ✔️ + ``public`` 1.9.0 ✔️ + ``songIdToAdd`` ✔️ + ``songIndexToRemove`` ✔️ + ===================== ===== = + +.. _deletePlaylist: + +``deletePlaylist`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +Media retrieval +^^^^^^^^^^^^^^^ + +.. _stream: + +``stream`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========================= ====== = + Parameter Vers. + ========================= ====== = + ``id`` ✔️ + ``maxBitRate`` ✔️ + ``format`` ✔️ + ``timeOffset`` ❌ + ``size`` ❌ + ``estimateContentLength`` ✔️ + ``converted`` 1.15.0 🔴 + ========================= ====== = + +.. _download: + +``download`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ========= ===== = + +.. _hls: + +``hls`` + 🔴 1.9.0 + + .. table:: + :widths: 55 30 15 + + ============== ====== = + Parameter Vers. + ============== ====== = + ``id`` 1.9.0 🔴 + ``bitRate`` 1.9.0 🔴 + ``audioTrack`` 1.15.0 🔴 + ============== ====== = + +.. _getCaptions: + +``getCaptions`` + 🔴 1.15.0 + + .. table:: + :widths: 55 30 15 + + ========== ====== = + Parameter Vers. + ========== ====== = + ``id`` 1.15.0 🔴 + ``format`` 1.15.0 🔴 + ========== ====== = + +.. _getCoverArt: + +``getCoverArt`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ✔️ + ``size`` ✔️ + ========= ===== = + +.. _getLyrics: + +``getLyrics`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========== ===== = + Parameter Vers. + ========== ===== = + ``artist`` ✔️ + ``title`` ✔️ + ========== ===== = + +.. _getAvatar: + +``getAvatar`` + ❌ + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ❌ + ============ ===== = + +Media annotation +^^^^^^^^^^^^^^^^ + +.. _star: + +``star`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` ✔️ + ``albumId`` ✔️ + ``artistId`` ✔️ + ============ ===== = + +.. _unstar: + +``unstar`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` ✔️ + ``albumId`` ✔️ + ``artistId`` ✔️ + ============ ===== = + +.. _setRating: + +``setRating`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========== ===== = + Parameter Vers. + ========== ===== = + ``id`` ✔️ + ``rating`` ✔️ + ========== ===== = + +.. _scrobble: + +``scrobble`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============== ===== = + Parameter Vers. + ============== ===== = + ``id`` ✔️ + ``time`` 1.9.0 ✔️ + ``submission`` ✔️ + ============== ===== = + +Sharing +^^^^^^^ + +.. _getShares: + +``getShares`` + ❌ + + No parameter + +.. _createShare: + +``createShare`` + ❌ + + .. table:: + :widths: 55 30 15 + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` ❌ + ``description`` ❌ + ``expires`` ❌ + =============== ===== = + +.. _updateShare: + +``updateShare`` + ❌ + + .. table:: + :widths: 55 30 15 + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` ❌ + ``description`` ❌ + ``expires`` ❌ + =============== ===== = + +.. _deleteShare: + +``deleteShare`` + ❌ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` ❌ + ========= ===== = + +Podcast +^^^^^^^ + +.. _getPodcasts: + +``getPodcasts`` + ❔ + + .. table:: + :widths: 55 30 15 + + =================== ===== = + Parameter Vers. + =================== ===== = + ``includeEpisodes`` 1.9.0 ❔ + ``id`` 1.9.0 ❔ + =================== ===== = + +.. _getNewestPodcasts: + +``getNewestPodcasts`` + ❔ 1.14.0 + + .. table:: + :widths: 55 30 15 + + ========= ====== = + Parameter Vers. + ========= ====== = + ``count`` 1.14.0 ❔ + ========= ====== = + +.. _refreshPodcasts: + +``refreshPodcasts`` + ❔ 1.9.0 + + No parameter + +.. _createPodcastChannel: + +``createPodcastChannel`` + ❔ 1.9.0 + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``url`` 1.9.0 ❔ + ========= ===== = + +.. _deletePodcastChannel: + +``deletePodcastChannel`` + ❔ 1.9.0 + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = + +.. _deletePodcastEpisode: + +``deletePodcastEpisode`` + ❔ 1.9.0 + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = + +.. _downloadPodcastEpisode: + +``downloadPodcastEpisode`` + ❔ 1.9.0 + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``id`` 1.9.0 ❔ + ========= ===== = + +Jukebox +^^^^^^^ + +.. _jukeboxControl: + +``jukeboxControl`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========== ===== = + Parameter Vers. + ========== ===== = + ``action`` ✔️ + ``index`` ✔️ + ``offset`` ✔️ + ``id`` ✔️ + ``gain`` ❌ + ========== ===== = + +Internet radio +^^^^^^^^^^^^^^ + +.. _getInternetRadioStations: + +``getInternetRadioStations`` + ❔ 1.9.0 + + No parameter + +.. _createInternetRadioStation: + +``createInternetRadioStation`` + ❔ 1.16.0 + + .. table:: + :widths: 55 30 15 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``streamUrl`` 1.16.0 ❔ + ``name`` 1.16.0 ❔ + ``homepageUrl`` 1.16.0 ❔ + =============== ====== = + +.. _updateInternetRadioStation: + +``updateInternetRadioStation`` + ❔ 1.16.0 + + .. table:: + :widths: 55 30 15 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``id`` 1.16.0 ❔ + ``streamUrl`` 1.16.0 ❔ + ``name`` 1.16.0 ❔ + ``homepageUrl`` 1.16.0 ❔ + =============== ====== = + +.. _deleteInternetRadioStation: + +``deleteInternetRadioStation`` + ❔ 1.16.0 + + .. table:: + :widths: 55 30 15 + + =============== ====== = + Parameter Vers. + =============== ====== = + ``id`` 1.16.0 ❔ + =============== ====== = + +Chat +^^^^ + +.. _getChatMessages: + +``getChatMessages`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ========= ===== = + Parameter Vers. + ========= ===== = + ``since`` ✔️ + ========= ===== = + +.. _addChatMessage: + +``addChatMessage`` + ✔️ + + .. table:: + :widths: 55 30 15 + + =========== ===== = + Parameter Vers. + =========== ===== = + ``message`` ✔️ + =========== ===== = + +User management +^^^^^^^^^^^^^^^ + +.. _getUser: + +``getUser`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = + +.. _getUsers: + +``getUsers`` + ✔️ 1.9.0 + + No parameter + +.. _createUser: + +``createUser`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ======================= ====== = + Parameter Vers. + ======================= ====== = + ``username`` ✔️ + ``password`` ✔️ + ``email`` ✔️ + ``ldapAuthenticated`` + ``adminRole`` ✔️ + ``settingsRole`` + ``streamRole`` + ``jukeboxRole`` ✔️ + ``downloadRole`` + ``uploadRole`` + ``playlistRole`` + ``coverArtRole`` + ``commentRole`` + ``podcastRole`` + ``shareRole`` + ``videoConversionRole`` 1.14.0 + ``musicFolderId`` 1.12.0 📅 + ======================= ====== = + +.. _updateUser: + +``updateUser`` + ✔️ 1.10.2 + + .. table:: + :widths: 55 30 15 + + ======================= ====== = + Parameter Vers. + ======================= ====== = + ``username`` 1.10.2 ✔️ + ``password`` 1.10.2 ✔️ + ``email`` 1.10.2 ✔️ + ``ldapAuthenticated`` 1.10.2 + ``adminRole`` 1.10.2 ✔️ + ``settingsRole`` 1.10.2 + ``streamRole`` 1.10.2 + ``jukeboxRole`` 1.10.2 ✔️ + ``downloadRole`` 1.10.2 + ``uploadRole`` 1.10.2 + ``coverArtRole`` 1.10.2 + ``commentRole`` 1.10.2 + ``podcastRole`` 1.10.2 + ``shareRole`` 1.10.2 + ``videoConversionRole`` 1.14.0 + ``musicFolderId`` 1.12.0 📅 + ``maxBitRate`` 1.13.0 📅 + ======================= ====== = + +.. _deleteUser: + +``deleteUser`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ============ ===== = + +.. _changePassword: + +``changePassword`` + ✔️ + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``username`` ✔️ + ``password`` ✔️ + ============ ===== = + +Bookmarks +^^^^^^^^^ + +.. _getBookmarks: + +``getBookmarks`` + ❔ 1.9.0 + + No parameter + +.. _createBookmark: + +``createBookmark`` + ❔ 1.9.0 + + .. table:: + :widths: 55 30 15 + + ============ ===== = + Parameter Vers. + ============ ===== = + ``id`` 1.9.0 ❔ + ``position`` 1.9.0 ❔ + ``comment`` 1.9.0 ❔ + ============ ===== = + +.. _deleteBookmark: + +``deleteBookmark`` + ❔ 1.9.0 + + .. table:: + :widths: 55 30 15 + + =============== ===== = + Parameter Vers. + =============== ===== = + ``id`` 1.9.0 ❔ + =============== ===== = + +.. _getPlayQueue: + +``getPlayQueue`` + ❔ 1.12.0 + + No parameter + +.. _savePlayQueue: + +``savePlayQueue`` + ❔ 1.12.0 + + .. table:: + :widths: 55 30 15 + + ============ ====== = + Parameter Vers. + ============ ====== = + ``id`` 1.12.0 ❔ + ``current`` 1.12.0 ❔ + ``position`` 1.12.0 ❔ + ============ ====== = + +Library scanning +^^^^^^^^^^^^^^^^ + +.. _getScanStatus: + +``getScanStatus`` + ✔️ 1.15.0 + + No parameter + +.. _startScan: + +``startScan`` + ✔️ 1.15.0 + + No parameter + +Changes by version +------------------ + +Version 1.9.0 +^^^^^^^^^^^^^ + +Added methods: + +* getGenres_ +* getSongsByGenre_ +* hls_ +* refreshPodcasts_ +* createPodcastChannel_ +* deletePodcastChannel_ +* deletePodcastEpisode_ +* downloadPodcastEpisode_ +* getInternetRadioStations_ +* getUsers_ +* getBookmarks_ +* createBookmark_ +* deleteBookmark_ + +Added method parameters: + +* updatePlaylist_ + + * ``public`` + +* scrobble_ + + * ``time`` + +* getPodcasts_ + + * ``includeEpisodes`` + * ``id`` + +Version 1.10.1 +^^^^^^^^^^^^^^ + +Added method parameters: + +* getAlbumList_ + + * ``fromYear`` + * ``toYear`` + * ``genre`` + +* getAlbumList2_ + + * ``fromYear`` + * ``toYear`` + * ``genre`` + +Version 1.10.2 +^^^^^^^^^^^^^^ + +Added methods: + +* updateUser_ + +Version 1.11.0 +^^^^^^^^^^^^^^ + +Added methods: + +* getArtistInfo_ +* getArtistInfo2_ +* getSimilarSongs_ +* getSimilarSongs2_ + +Version 1.12.0 +^^^^^^^^^^^^^^ + +Added methods: + +* getPlayQueue_ +* savePlayQueue_ + +Added method parameters: + +* getAlbumList_ + + * ``musicFolderId`` + +* getAlbumList2_ + + * ``musicFolderId`` + +* getSongsByGenre_ + + * ``musicFolderId`` + +* getStarred_ + + * ``musicFolderId`` + +* getStarred2_ + + * ``musicFolderId`` + +* search2_ + + * ``musicFolderId`` + +* search3_ + + * ``musicFolderId`` + +* createUser_ + + * ``musicFolderId`` + +* updateUser_ + + * ``musicFolderId`` + +Version 1.13.0 +^^^^^^^^^^^^^^ + +Added global parameters: + +* ``t`` +* ``s`` + +Added methods: + +* getTopSongs_ + +Added method parameters: + +* updateUser_ + + * ``maxBitRate`` + +Version 1.14.0 +^^^^^^^^^^^^^^ + +Added methods: + +* getAlbumInfo_ +* getAlbumInfo2_ +* getNewestPodcasts_ + +Added method parameters: + +* getArtists_ + + * ``musicFolderId`` + +* createUser_ + + * ``videoConversionRole`` + +* updateUser_ + + * ``videoConversionRole`` + +Version 1.15.0 +^^^^^^^^^^^^^^ + +Added error code ``41`` + +Added methods: + +* getVideoInfo_ +* getCaptions_ +* getScanStatus_ +* startScan_ + +Added method parameters: + +* stream_ + + * ``converted`` + +* hls_ + + * ``audioTrack`` + +Version 1.16.0 +^^^^^^^^^^^^^^ + +Added methods: + +* createInternetRadioStation_ +* updateInternetRadioStation_ +* deleteInternetRadioStation_ diff --git a/docs/conf.py b/docs/conf.py index abea6040..01a4cd1b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,88 +1,88 @@ -# -- Project information ----------------------------------------------------- - -project = "Supysonic" -author = "Alban Féron" -copyright = "2013-2021, " + author - -version = "0.6.2" -release = "0.6.2" - - -# -- General configuration --------------------------------------------------- - -extensions = [] -templates_path = [] -source_suffix = ".rst" -master_doc = "index" -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -primary_domain = None -highlight_language = "none" - -language = None - - -# -- Options for HTML output ------------------------------------------------- - -html_theme = "alabaster" -html_theme_options = { - "description": "A Python implementation of the Subsonic server API", - "github_user": "spl0k", - "github_repo": "supysonic", -} -html_static_path = ["_static"] - -# Default alabaseter sidebars + localtoc -html_sidebars = { - "**": [ - "about.html", - "localtoc.html", - "navigation.html", - "relations.html", - "searchbox.html", - "donate.html", - ] -} - -html_domain_indices = False - - -# -- Options for manual page output ------------------------------------------ - -_man_authors = ["Louis-Philippe Véronneau", author] - -# Man pages, they are writter to be generated directly by `rst2man` so using -# Sphinx to build them will give weird sections, but if we ever need it it's -# there - -# (source start file, name, description, authors, manual section). -man_pages = [ - ( - "man/supysonic-cli", - "supysonic-cli", - "Python implementation of the Subsonic server API", - _man_authors, - 1, - ), - ( - "man/supysonic-cli-user", - "supysonic-cli-user", - "Supysonic user management commands", - _man_authors, - 1, - ), - ( - "man/supysonic-cli-folder", - "supysonic-cli-folder", - "Supysonic folder management commands", - _man_authors, - 1, - ), - ( - "man/supysonic-daemon", - "supysonic-daemon", - "Supysonic background daemon", - _man_authors, - 1, - ), -] +# -- Project information ----------------------------------------------------- + +project = "Supysonic" +author = "Alban Féron" +copyright = "2013-2021, " + author + +version = "0.6.2" +release = "0.6.2" + + +# -- General configuration --------------------------------------------------- + +extensions = [] +templates_path = [] +source_suffix = ".rst" +master_doc = "index" +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +primary_domain = None +highlight_language = "none" + +language = None + + +# -- Options for HTML output ------------------------------------------------- + +html_theme = "alabaster" +html_theme_options = { + "description": "A Python implementation of the Subsonic server API", + "github_user": "spl0k", + "github_repo": "supysonic", +} +html_static_path = ["_static"] + +# Default alabaseter sidebars + localtoc +html_sidebars = { + "**": [ + "about.html", + "localtoc.html", + "navigation.html", + "relations.html", + "searchbox.html", + "donate.html", + ] +} + +html_domain_indices = False + + +# -- Options for manual page output ------------------------------------------ + +_man_authors = ["Louis-Philippe Véronneau", author] + +# Man pages, they are writter to be generated directly by `rst2man` so using +# Sphinx to build them will give weird sections, but if we ever need it it's +# there + +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + "man/supysonic-cli", + "supysonic-cli", + "Python implementation of the Subsonic server API", + _man_authors, + 1, + ), + ( + "man/supysonic-cli-user", + "supysonic-cli-user", + "Supysonic user management commands", + _man_authors, + 1, + ), + ( + "man/supysonic-cli-folder", + "supysonic-cli-folder", + "Supysonic folder management commands", + _man_authors, + 1, + ), + ( + "man/supysonic-daemon", + "supysonic-daemon", + "Supysonic background daemon", + _man_authors, + 1, + ), +] diff --git a/docs/index.rst b/docs/index.rst index 7ec46f8d..3782e132 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,29 +1,29 @@ -Welcome to Supysonic's documentation! -===================================== - -Supysonic is a Python implementation of the `Subsonic`__ server API. - -Current supported features are: - -* browsing (by folders or tags) -* streaming of various audio file formats -* transcoding -* user or random playlists -* cover arts (as image files in the same folder as music files) -* starred tracks/albums and ratings -* `Last.FM`__ scrobbling -* Jukebox mode - -__ http://www.subsonic.org/ -__ https://www.last.fm/ - -.. rubric:: User's guide - -.. toctree:: - :maxdepth: 2 - - setup/index - transcoding - jukebox - man/index - api +Welcome to Supysonic's documentation! +===================================== + +Supysonic is a Python implementation of the `Subsonic`__ server API. + +Current supported features are: + +* browsing (by folders or tags) +* streaming of various audio file formats +* transcoding +* user or random playlists +* cover arts (as image files in the same folder as music files) +* starred tracks/albums and ratings +* `Last.FM`__ scrobbling +* Jukebox mode + +__ http://www.subsonic.org/ +__ https://www.last.fm/ + +.. rubric:: User's guide + +.. toctree:: + :maxdepth: 2 + + setup/index + transcoding + jukebox + man/index + api diff --git a/docs/jukebox.rst b/docs/jukebox.rst index 30f9bf7a..896064b4 100644 --- a/docs/jukebox.rst +++ b/docs/jukebox.rst @@ -1,45 +1,45 @@ -Jukebox mode -============ - -The jukebox mode allow playing audio files on the hardware of the machine -running Supysonic, using regular clients that support it as a remote control. - -:doc:`setup/daemon` must be running in order to be able to use the jukebox mode. -So be sure to start the :doc:`man/supysonic-daemon` command and keep it running. - -Setting the player program --------------------------- - -Jukebox mode in Supysonic works through the use of third-party command-line -programs. Supysonic isn't bundled with such programs, and you are left to -choose which one you want to use. The chosen program should be able to play a -single audio file from a path specified on its command-line. - -The configuration is done in the :ref:`conf-daemon` of the configuration file, -with the ``jukebox_command`` variable. This variable should include the -following fields: - -``%path`` - absolute path of the file to be played - -``%offset`` - time in seconds where to start playing (used for seeking) - -Here's an example using ``mplayer``:: - - jukebox_command = mplayer -ss %offset %path - -Or using ``mpv``:: - - jukebox_command = mpv --start=%offset %path - -Setting the output volume isn't currently supported. - -Allowing users to act on the jukebox ------------------------------------- - -The jukebox mode is only accessible to chosen users. Granting (or revoking) -jukebox usage rights to a specific user is done with the -:doc:`command line interface `:: - - $ supysonic-cli user setroles --jukebox +Jukebox mode +============ + +The jukebox mode allow playing audio files on the hardware of the machine +running Supysonic, using regular clients that support it as a remote control. + +:doc:`setup/daemon` must be running in order to be able to use the jukebox mode. +So be sure to start the :doc:`man/supysonic-daemon` command and keep it running. + +Setting the player program +-------------------------- + +Jukebox mode in Supysonic works through the use of third-party command-line +programs. Supysonic isn't bundled with such programs, and you are left to +choose which one you want to use. The chosen program should be able to play a +single audio file from a path specified on its command-line. + +The configuration is done in the :ref:`conf-daemon` of the configuration file, +with the ``jukebox_command`` variable. This variable should include the +following fields: + +``%path`` + absolute path of the file to be played + +``%offset`` + time in seconds where to start playing (used for seeking) + +Here's an example using ``mplayer``:: + + jukebox_command = mplayer -ss %offset %path + +Or using ``mpv``:: + + jukebox_command = mpv --start=%offset %path + +Setting the output volume isn't currently supported. + +Allowing users to act on the jukebox +------------------------------------ + +The jukebox mode is only accessible to chosen users. Granting (or revoking) +jukebox usage rights to a specific user is done with the +:doc:`command line interface `:: + + $ supysonic-cli user setroles --jukebox diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index 8f536bad..0de5c152 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -1,308 +1,308 @@ -Configuration -============= - -Supysonic looks for four files for its configuration: :file:`/etc/supysonic`, -:file:`~/.supysonic`, :file:`~/.config/supysonic/supysonic.conf` and -:file:`supysonic.conf` in the current working directory, in this order, merging -values from all files. - -Configuration files must respect a structure similar to Windows INI file, with -``[section]`` headers and using a ``KEY = VALUE`` or ``KEY: VALUE`` syntax. - -If you cloned Supysonic from its `GitHub repository`__ you'll find a roughly -documented configuration sample file at the root of the project, file -conveniently named :file:`config.sample`. More details below. - -__ https://github.com/spl0k/supysonic - -``[base]`` section ------------------- - -This sections defines the database and additional scanning config. - -``database_uri`` - The most important configuration, defines the type and - parameters of the database Supysonic should connect to. It usually includes - username, password, hostname and database name. The typical form of a - database URI is:: - - driver://username:password@host:port/database - - If the connection needs some additional parameters, they can be provided as a - query string, such as:: - - driver://username:password@host:port/database?param1=value1¶m2=value2 - - Supported drivers are ``sqlite``, ``mysql`` and ``postgres`` (or - ``postgresql``). - - As SQLite connects to local files, the format is slightly different. The - "file" portion of the URI is the filename of the database. For a relative - path, it requires three slashes, for absolute paths it's also three slashes - followed by the absolute path, meaning actually four slashes on Unix systems. - - .. highlight:: ini - - :: - - ; Relative path - database_uri = sqlite:///relative-file.db - ; Absolute path on Unix-based systems - database_uri = sqlite:////home/user/supysonic.db - ; Absolute path on Windows - database_uri = sqlite:///C:\Users\user\supysonic.db - - A MySQL-compatible database requires either ``MySQLdb`` or ``pymysql`` to be - installed. PostgreSQL needs ``psycopg2``. - - .. note:: - - For MySQL if no character set is defined on the URI it defaults to - ``utf8mb4`` regardless of what's set on your MySQL installation. - - If ``database_uri`` isn't provided, it defaults to a SQLite database stored - in :file:`/tmp/supysonic/supysonic.db`. - -``scanner_extensions`` - A space separated list of file extensions the scanner is restricted to. - Useful if you have multiple audio formats in your library but only want to - serve some. If left empty, the scanner will try to read every file it finds. - -``follow_symlinks`` - If set to ``yes``, allows the scanner to follow symbolic links. - - Disabled by default, enable it only if you trust your file system as nothing - is done to handle broken links or loops. - -Sample configuration:: - - [base] - ; A database URI. Default: sqlite:////tmp/supysonic/supysonic.db - database_uri = sqlite:////var/supysonic/supysonic.db - ;database_uri = mysql://supysonic:supysonic@localhost/supysonic - ;database_uri = postgres://supysonic:supysonic@localhost/supysonic - - ; Optional, restrict scanner to these extensions. Default: none - scanner_extensions = mp3 ogg - - ; Should the scanner follow symbolic links? Default: no - follow_symlinks = no - -``[webapp]`` section --------------------- - -Configuration relative to the HTTP server. - -``cache_dir`` - Directory used to store generated files, such as resized cover art or - transcoded files. Defaults to :file:`/tmp/supysonic`. - -``cache_size`` - Maximum size (in megabytes) of the cache (except for trancodes). - Defaults to 512 MB. - -``transcode_cache_size`` - Maximum size (in megabytes) of the transcode cache. - Defaults to 1024 MB (1 GB). - -``log_file`` - Rotating file where some events generated by the web server are - logged. Leave empty to disable logging. - -``log_level`` - Defines the minimum severity threshold of messages to be added to - ``log_file``. Possible values are: - - * ``DEBUG`` - * ``INFO`` - * ``WARNING`` - * ``ERROR`` - * ``CRITICAL`` - - Defaults to ``WARNING``. - -``mount_api`` (``on`` or ``off``) - Enable or disable the Subsonic REST API. Should be kept on or Supysonic would - be quite useless. Exists mostly for testing purposes. - Defaults to ``on``. - -``mount_webui`` (``on`` or ``off``) - Enable or disable the administrative web interface. - - .. note:: - Setting this off will prevent users from defining a preferred transcoding - format. - - Defaults to ``on``. - -``index_ignored_prefixes`` - Space-separated list of prefixes that should be ignored from artist names - when returning their index. Example: if the word *The* is in this list, - artist *The Rolling Stones* will be listed under the letter *R*. The match is - case insensitive. - Defaults to ``El La Le Las Les Los The``. - -Sample configuration:: - - [webapp] - ; Optional cache directory. Default: /tmp/supysonic - cache_dir = /var/supysonic/cache - - ; Main cache max size in MB. Default: 512 - cache_size = 512 - - ; Transcode cache max size in MB. Default: 1024 (1GB) - transcode_cache_size = 1024 - - ; Optional rotating log file. Default: none - log_file = /var/supysonic/supysonic.log - - ; Log level. Possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL. - ; Default: WARNING - log_level = WARNING - - ; Enable the Subsonic REST API. You'll most likely want to keep this on. - ; Here for testing purposes. Default: on - ;mount_api = on - - ; Enable the administrative web interface. Default: on - ;mount_webui = on - - ; Space separated list of prefixes that should be ignored on index endpoints - ; Default: El La Le Las Les Los The - index_ignored_prefixes = El La Le Las Les Los The - -.. _conf-daemon: - -``[daemon]`` section --------------------- - -Configuration for the daemon process that is used to watch for changes in the -library folders and providing the jukebox feature. - -``socket`` - Unix domain socket file (or named pipe on Windows) used to communicate - between the daemon and clients that rely on it (eg. CLI, folder admin web - page, etc.). Note that using an IP address here isn't supported. - Default: :file:`/tmp/supysonic/supysonic.sock` - -``run_watcher`` - Whether or not to start the watcher that will listen for library changes. - Default: yes - -``wait_delay`` - Delay (in seconds) before triggering the scanning operation after a change - have been detected. This prevents running too many scans when multiple - changes are detected for a single file over a short time span. - Default: 5 seconds. - -``jukebox_command`` - Command used by the jukebox mode to play a single file. - See the :doc:`jukebox documentation <../jukebox>` for more details. - -``log_file`` - Rotating file where events generated by the file watcher are logged. - If left empty, any logging will be sent to stderr. - -``log_level`` - Defines the minimum severity threshold of messages to be added to - ``log_file``. Possible values are: - - * ``DEBUG`` - * ``INFO`` - * ``WARNING`` - * ``ERROR`` - * ``CRITICAL`` - - Defaults to ``WARNING``. - -Sample configuration:: - - [daemon] - ; Socket file the daemon will listen on for incoming management commands - ; Default: /tmp/supysonic/supysonic.sock - socket = /var/run/supysonic.sock - - ; Defines if the file watcher should be started. Default: yes - run_watcher = yes - - ; Delay in seconds before triggering scanning operation after a change have been - ; detected. - ; This prevents running too many scans when multiple changes are detected for a - ; single file over a short time span. Default: 5 - wait_delay = 5 - - ; Command used by the jukebox - jukebox_command = mplayer -ss %offset %path - - ; Optional rotating log file for the scanner daemon. Logs to stderr if empty - log_file = /var/supysonic/supysonic-daemon.log - log_level = INFO - -``[lastfm]`` section --------------------- - -This section allow defining API keys to enable Last.FM integration in -Supysonic. Currently it is only used to *scrobble* played tracks and update -the *now playing* information. - -See https://www.last.fm/api to obtain such keys. - -Once keys are set, users have to link their account by visiting their profile -page on Supysonic's administrative UI. - -``api_key`` - Last.FM API key - -``secret`` - secret key associated to the API key - -Sample configuration:: - - [lastfm] - ; API and secret key to enable scrobbling. http://www.last.fm/api/accounts - ; Defaults: none - ;api_key = - ;secret = - -.. _conf-transcoding: - -``[transcoding]`` section -------------------------- - -This section defines command-line programs to be used to convert an audio file -to another format or change its bitrate. All configurations in the sample below -have **not** been thoroughly tested. -For more details, please refer to the -:doc:`transcoding configuration <../transcoding>`. - -:: - - [transcoding] - ; Programs used to convert from one format/bitrate to another. Defaults: none - transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - - transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - - decoder_mp3 = mpg123 --quiet -w - %srcpath - decoder_ogg = oggdec -o %srcpath - decoder_flac = flac -d -c -s %srcpath - encoder_mp3 = lame --quiet -b %outrate - - - encoder_ogg = oggenc2 -q -M %outrate - - -``[mimetypes]`` section ------------------------ - -Use this section if the system Supysonic is installed on has trouble guessing -the mimetype of some files. This might only be useful in some rare cases. - -See the following links for a list of examples: - -* https://en.wikipedia.org/wiki/Media_type#Common_examples -* https://www.iana.org/assignments/media-types/media-types.xhtml - -:: - - [mimetypes] - ; Extension to mimetype mappings in case your system has some trouble guessing - ; Default: none - ;mp3 = audio/mpeg - ;ogg = audio/vorbis +Configuration +============= + +Supysonic looks for four files for its configuration: :file:`/etc/supysonic`, +:file:`~/.supysonic`, :file:`~/.config/supysonic/supysonic.conf` and +:file:`supysonic.conf` in the current working directory, in this order, merging +values from all files. + +Configuration files must respect a structure similar to Windows INI file, with +``[section]`` headers and using a ``KEY = VALUE`` or ``KEY: VALUE`` syntax. + +If you cloned Supysonic from its `GitHub repository`__ you'll find a roughly +documented configuration sample file at the root of the project, file +conveniently named :file:`config.sample`. More details below. + +__ https://github.com/spl0k/supysonic + +``[base]`` section +------------------ + +This sections defines the database and additional scanning config. + +``database_uri`` + The most important configuration, defines the type and + parameters of the database Supysonic should connect to. It usually includes + username, password, hostname and database name. The typical form of a + database URI is:: + + driver://username:password@host:port/database + + If the connection needs some additional parameters, they can be provided as a + query string, such as:: + + driver://username:password@host:port/database?param1=value1¶m2=value2 + + Supported drivers are ``sqlite``, ``mysql`` and ``postgres`` (or + ``postgresql``). + + As SQLite connects to local files, the format is slightly different. The + "file" portion of the URI is the filename of the database. For a relative + path, it requires three slashes, for absolute paths it's also three slashes + followed by the absolute path, meaning actually four slashes on Unix systems. + + .. highlight:: ini + + :: + + ; Relative path + database_uri = sqlite:///relative-file.db + ; Absolute path on Unix-based systems + database_uri = sqlite:////home/user/supysonic.db + ; Absolute path on Windows + database_uri = sqlite:///C:\Users\user\supysonic.db + + A MySQL-compatible database requires either ``MySQLdb`` or ``pymysql`` to be + installed. PostgreSQL needs ``psycopg2``. + + .. note:: + + For MySQL if no character set is defined on the URI it defaults to + ``utf8mb4`` regardless of what's set on your MySQL installation. + + If ``database_uri`` isn't provided, it defaults to a SQLite database stored + in :file:`/tmp/supysonic/supysonic.db`. + +``scanner_extensions`` + A space separated list of file extensions the scanner is restricted to. + Useful if you have multiple audio formats in your library but only want to + serve some. If left empty, the scanner will try to read every file it finds. + +``follow_symlinks`` + If set to ``yes``, allows the scanner to follow symbolic links. + + Disabled by default, enable it only if you trust your file system as nothing + is done to handle broken links or loops. + +Sample configuration:: + + [base] + ; A database URI. Default: sqlite:////tmp/supysonic/supysonic.db + database_uri = sqlite:////var/supysonic/supysonic.db + ;database_uri = mysql://supysonic:supysonic@localhost/supysonic + ;database_uri = postgres://supysonic:supysonic@localhost/supysonic + + ; Optional, restrict scanner to these extensions. Default: none + scanner_extensions = mp3 ogg + + ; Should the scanner follow symbolic links? Default: no + follow_symlinks = no + +``[webapp]`` section +-------------------- + +Configuration relative to the HTTP server. + +``cache_dir`` + Directory used to store generated files, such as resized cover art or + transcoded files. Defaults to :file:`/tmp/supysonic`. + +``cache_size`` + Maximum size (in megabytes) of the cache (except for trancodes). + Defaults to 512 MB. + +``transcode_cache_size`` + Maximum size (in megabytes) of the transcode cache. + Defaults to 1024 MB (1 GB). + +``log_file`` + Rotating file where some events generated by the web server are + logged. Leave empty to disable logging. + +``log_level`` + Defines the minimum severity threshold of messages to be added to + ``log_file``. Possible values are: + + * ``DEBUG`` + * ``INFO`` + * ``WARNING`` + * ``ERROR`` + * ``CRITICAL`` + + Defaults to ``WARNING``. + +``mount_api`` (``on`` or ``off``) + Enable or disable the Subsonic REST API. Should be kept on or Supysonic would + be quite useless. Exists mostly for testing purposes. + Defaults to ``on``. + +``mount_webui`` (``on`` or ``off``) + Enable or disable the administrative web interface. + + .. note:: + Setting this off will prevent users from defining a preferred transcoding + format. + + Defaults to ``on``. + +``index_ignored_prefixes`` + Space-separated list of prefixes that should be ignored from artist names + when returning their index. Example: if the word *The* is in this list, + artist *The Rolling Stones* will be listed under the letter *R*. The match is + case insensitive. + Defaults to ``El La Le Las Les Los The``. + +Sample configuration:: + + [webapp] + ; Optional cache directory. Default: /tmp/supysonic + cache_dir = /var/supysonic/cache + + ; Main cache max size in MB. Default: 512 + cache_size = 512 + + ; Transcode cache max size in MB. Default: 1024 (1GB) + transcode_cache_size = 1024 + + ; Optional rotating log file. Default: none + log_file = /var/supysonic/supysonic.log + + ; Log level. Possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL. + ; Default: WARNING + log_level = WARNING + + ; Enable the Subsonic REST API. You'll most likely want to keep this on. + ; Here for testing purposes. Default: on + ;mount_api = on + + ; Enable the administrative web interface. Default: on + ;mount_webui = on + + ; Space separated list of prefixes that should be ignored on index endpoints + ; Default: El La Le Las Les Los The + index_ignored_prefixes = El La Le Las Les Los The + +.. _conf-daemon: + +``[daemon]`` section +-------------------- + +Configuration for the daemon process that is used to watch for changes in the +library folders and providing the jukebox feature. + +``socket`` + Unix domain socket file (or named pipe on Windows) used to communicate + between the daemon and clients that rely on it (eg. CLI, folder admin web + page, etc.). Note that using an IP address here isn't supported. + Default: :file:`/tmp/supysonic/supysonic.sock` + +``run_watcher`` + Whether or not to start the watcher that will listen for library changes. + Default: yes + +``wait_delay`` + Delay (in seconds) before triggering the scanning operation after a change + have been detected. This prevents running too many scans when multiple + changes are detected for a single file over a short time span. + Default: 5 seconds. + +``jukebox_command`` + Command used by the jukebox mode to play a single file. + See the :doc:`jukebox documentation <../jukebox>` for more details. + +``log_file`` + Rotating file where events generated by the file watcher are logged. + If left empty, any logging will be sent to stderr. + +``log_level`` + Defines the minimum severity threshold of messages to be added to + ``log_file``. Possible values are: + + * ``DEBUG`` + * ``INFO`` + * ``WARNING`` + * ``ERROR`` + * ``CRITICAL`` + + Defaults to ``WARNING``. + +Sample configuration:: + + [daemon] + ; Socket file the daemon will listen on for incoming management commands + ; Default: /tmp/supysonic/supysonic.sock + socket = /var/run/supysonic.sock + + ; Defines if the file watcher should be started. Default: yes + run_watcher = yes + + ; Delay in seconds before triggering scanning operation after a change have been + ; detected. + ; This prevents running too many scans when multiple changes are detected for a + ; single file over a short time span. Default: 5 + wait_delay = 5 + + ; Command used by the jukebox + jukebox_command = mplayer -ss %offset %path + + ; Optional rotating log file for the scanner daemon. Logs to stderr if empty + log_file = /var/supysonic/supysonic-daemon.log + log_level = INFO + +``[lastfm]`` section +-------------------- + +This section allow defining API keys to enable Last.FM integration in +Supysonic. Currently it is only used to *scrobble* played tracks and update +the *now playing* information. + +See https://www.last.fm/api to obtain such keys. + +Once keys are set, users have to link their account by visiting their profile +page on Supysonic's administrative UI. + +``api_key`` + Last.FM API key + +``secret`` + secret key associated to the API key + +Sample configuration:: + + [lastfm] + ; API and secret key to enable scrobbling. http://www.last.fm/api/accounts + ; Defaults: none + ;api_key = + ;secret = + +.. _conf-transcoding: + +``[transcoding]`` section +------------------------- + +This section defines command-line programs to be used to convert an audio file +to another format or change its bitrate. All configurations in the sample below +have **not** been thoroughly tested. +For more details, please refer to the +:doc:`transcoding configuration <../transcoding>`. + +:: + + [transcoding] + ; Programs used to convert from one format/bitrate to another. Defaults: none + transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - + transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - + decoder_mp3 = mpg123 --quiet -w - %srcpath + decoder_ogg = oggdec -o %srcpath + decoder_flac = flac -d -c -s %srcpath + encoder_mp3 = lame --quiet -b %outrate - - + encoder_ogg = oggenc2 -q -M %outrate - + +``[mimetypes]`` section +----------------------- + +Use this section if the system Supysonic is installed on has trouble guessing +the mimetype of some files. This might only be useful in some rare cases. + +See the following links for a list of examples: + +* https://en.wikipedia.org/wiki/Media_type#Common_examples +* https://www.iana.org/assignments/media-types/media-types.xhtml + +:: + + [mimetypes] + ; Extension to mimetype mappings in case your system has some trouble guessing + ; Default: none + ;mp3 = audio/mpeg + ;ogg = audio/vorbis diff --git a/docs/transcoding.rst b/docs/transcoding.rst index fe534104..e451d534 100644 --- a/docs/transcoding.rst +++ b/docs/transcoding.rst @@ -1,148 +1,148 @@ -Transcoding -=========== - -Transcoding is the process of converting from one audio format to another. This -allows for streaming of formats that wouldn't be streamable otherwise, or -reducing the quality of an audio file to allow a decent streaming for clients -with limited bandwidth, such as the ones running on a mobile connection. - -Transcoding in Supysonic is achieved through the use of third-party command-line -programs. Supysonic isn't bundled with such programs, and you are left to choose -which one you want to use. - -If you want to use transcoding but your client doesn't allow you to do so, you -can force Supysonic to transcode for that client by going to your profile page -on the web interface. - -Configuration -------------- - -Configuration of transcoders is done on the :ref:`conf-transcoding` of the -configuration file. - -Transcoding can be done by one single program which is able to convert from one -format directly to another one, or by two programs: a decoder and an encoder. -All these are defined by the following variables: - -* ``transcoder_EXT_EXT`` -* ``decoder_EXT`` -* ``encoder_EXT`` -* ``trancoder`` -* ``decoder`` -* ``encoder`` -* ``default_transcode_target`` - -where ``EXT`` is the lowercase file extension of the matching audio format. -``transcoder``\ s variables have two extensions: the first one is the source -extension, and the second one is the extension to convert to. The same way, -``decoder``\ s extension is the source extension, and ``encoder``\ s extension -is the extension to convert to. -The value of ``default_transcode_target`` will be used as output format when a -client requests a bitrate lower than the original file and no specific format. - -Notice that all of them have a version without extension. Those are generic -versions. The programs defined with these variables should be able to -transcode/decode/encode any format. For that reason, we suggest you don't use -these if you want to keep control over the available transcoders. - -Supysonic will take the first available transcoding configuration in the -following order: - -#. specific transcoder -#. specific decoder / specific encoder -#. generic decoder / generic encoder (with the possibility to use a generic - decoder with a specific encoder, and vice-versa) -#. generic transcoder - -All the variables should be set to the command-line used to run the converter -program. The command-lines can include the following fields: - -``%srcpath`` - path to the original file to transcode - -``%srcfmt`` - extension of the original file - -``%outfmt`` - extension of the resulting file - -``%outrate`` - bitrate of the resulting file - -``%title`` - title of the file to transcode - -``%album`` - album name of the file to transcode - -``%artist`` - artist name of the file to transcode - -``%tracknumber`` - track number of the file to transcode - -``%totaltracks`` - number of tracks in the album of the file to transcode - -``%discnumber`` - disc number of the file to transcode - -``%genre`` - genre of the file to transcode (not always available, defaults to "") - -``%year`` - year of the file to transcode (not always available, defaults to "") - -One final note: the original file should be provided as an argument of -transcoders and decoders. All transcoders, decoders and encoders should write -to standard output, and encoders should read from standard input (decoders -output being piped into encoders) - -Suggested configuration -^^^^^^^^^^^^^^^^^^^^^^^ - -Here is an example configuration that you could use. This is provided as-is, -and some configurations haven't been tested. - -.. highlight:: ini - -Basic configuration:: - - [transcoding] - transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - - transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - - decoder_mp3 = mpg123 --quiet -w - %srcpath - decoder_ogg = oggdec -o %srcpath - decoder_flac = flac -d -c -s %srcpath - encoder_mp3 = lame --quiet -b %outrate - - - encoder_ogg = oggenc2 -Q -M %outrate - - default_transcode_target = mp3 - -To include track metadata in the transcoded stream:: - - [transcoding] - transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 %srcpath - - transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -metadata title=%title -metadata album=%album -metadata author=%artist -metadata track=%tracknumber/%totaltracks -metadata disc=%discnumber -metadata genre=%genre -metadata date=%year -f %outfmt - - decoder_mp3 = mpg123 --quiet -w - %srcpath - decoder_ogg = oggdec -o %srcpath - decoder_flac = flac -d -c -s %srcpath - encoder_mp3 = lame --quiet -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 - - - encoder_ogg = oggenc2 -Q -M %outrate -t %title -l %album -a %artist -N %tracknumber -c TOTALTRACKS=%totaltracks -c DISCNUMBER=%discnumber -G %genre -d %year - - default_transcode_target = mp3 - -Enabling transcoding --------------------- - -Once the transcoding configuration has been set, most clients will require the -user to specify that they want to transcode files. This might be done on the -client itself, but most importantly it should be done on Supysonic web -interface. Not doing so might prevent some clients to properly request -transcoding. - -To enable transcoding with the web interface, you should first start using the -client you want to set transcoding for. Only browsing the library should -suffice. Then open your browser of choice and navigate to the URL of your -Supysonic instance. Log in with your credentials and the click on your username -in the top bar. There you should be presented with a list of clients you used to -connect to Supysonic and be able to set your preferred streaming format -and bitrate. +Transcoding +=========== + +Transcoding is the process of converting from one audio format to another. This +allows for streaming of formats that wouldn't be streamable otherwise, or +reducing the quality of an audio file to allow a decent streaming for clients +with limited bandwidth, such as the ones running on a mobile connection. + +Transcoding in Supysonic is achieved through the use of third-party command-line +programs. Supysonic isn't bundled with such programs, and you are left to choose +which one you want to use. + +If you want to use transcoding but your client doesn't allow you to do so, you +can force Supysonic to transcode for that client by going to your profile page +on the web interface. + +Configuration +------------- + +Configuration of transcoders is done on the :ref:`conf-transcoding` of the +configuration file. + +Transcoding can be done by one single program which is able to convert from one +format directly to another one, or by two programs: a decoder and an encoder. +All these are defined by the following variables: + +* ``transcoder_EXT_EXT`` +* ``decoder_EXT`` +* ``encoder_EXT`` +* ``trancoder`` +* ``decoder`` +* ``encoder`` +* ``default_transcode_target`` + +where ``EXT`` is the lowercase file extension of the matching audio format. +``transcoder``\ s variables have two extensions: the first one is the source +extension, and the second one is the extension to convert to. The same way, +``decoder``\ s extension is the source extension, and ``encoder``\ s extension +is the extension to convert to. +The value of ``default_transcode_target`` will be used as output format when a +client requests a bitrate lower than the original file and no specific format. + +Notice that all of them have a version without extension. Those are generic +versions. The programs defined with these variables should be able to +transcode/decode/encode any format. For that reason, we suggest you don't use +these if you want to keep control over the available transcoders. + +Supysonic will take the first available transcoding configuration in the +following order: + +#. specific transcoder +#. specific decoder / specific encoder +#. generic decoder / generic encoder (with the possibility to use a generic + decoder with a specific encoder, and vice-versa) +#. generic transcoder + +All the variables should be set to the command-line used to run the converter +program. The command-lines can include the following fields: + +``%srcpath`` + path to the original file to transcode + +``%srcfmt`` + extension of the original file + +``%outfmt`` + extension of the resulting file + +``%outrate`` + bitrate of the resulting file + +``%title`` + title of the file to transcode + +``%album`` + album name of the file to transcode + +``%artist`` + artist name of the file to transcode + +``%tracknumber`` + track number of the file to transcode + +``%totaltracks`` + number of tracks in the album of the file to transcode + +``%discnumber`` + disc number of the file to transcode + +``%genre`` + genre of the file to transcode (not always available, defaults to "") + +``%year`` + year of the file to transcode (not always available, defaults to "") + +One final note: the original file should be provided as an argument of +transcoders and decoders. All transcoders, decoders and encoders should write +to standard output, and encoders should read from standard input (decoders +output being piped into encoders) + +Suggested configuration +^^^^^^^^^^^^^^^^^^^^^^^ + +Here is an example configuration that you could use. This is provided as-is, +and some configurations haven't been tested. + +.. highlight:: ini + +Basic configuration:: + + [transcoding] + transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - + transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -f %outfmt - + decoder_mp3 = mpg123 --quiet -w - %srcpath + decoder_ogg = oggdec -o %srcpath + decoder_flac = flac -d -c -s %srcpath + encoder_mp3 = lame --quiet -b %outrate - - + encoder_ogg = oggenc2 -Q -M %outrate - + default_transcode_target = mp3 + +To include track metadata in the transcoded stream:: + + [transcoding] + transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 %srcpath - + transcoder = ffmpeg -i %srcpath -ab %outratek -v 0 -metadata title=%title -metadata album=%album -metadata author=%artist -metadata track=%tracknumber/%totaltracks -metadata disc=%discnumber -metadata genre=%genre -metadata date=%year -f %outfmt - + decoder_mp3 = mpg123 --quiet -w - %srcpath + decoder_ogg = oggdec -o %srcpath + decoder_flac = flac -d -c -s %srcpath + encoder_mp3 = lame --quiet -b %outrate --tt %title --tl %album --ta %artist --tn %tracknumber/%totaltracks --tv TPOS=%discnumber --tg %genre --ty %year --add-id3v2 - - + encoder_ogg = oggenc2 -Q -M %outrate -t %title -l %album -a %artist -N %tracknumber -c TOTALTRACKS=%totaltracks -c DISCNUMBER=%discnumber -G %genre -d %year - + default_transcode_target = mp3 + +Enabling transcoding +-------------------- + +Once the transcoding configuration has been set, most clients will require the +user to specify that they want to transcode files. This might be done on the +client itself, but most importantly it should be done on Supysonic web +interface. Not doing so might prevent some clients to properly request +transcoding. + +To enable transcoding with the web interface, you should first start using the +client you want to set transcoding for. Only browsing the library should +suffice. Then open your browser of choice and navigate to the URL of your +Supysonic instance. Log in with your credentials and the click on your username +in the top bar. There you should be presented with a list of clients you used to +connect to Supysonic and be able to set your preferred streaming format +and bitrate. From bd370f57ffbf6623a778c1d371b983f2ff475a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 23 Jan 2021 17:16:15 +0100 Subject: [PATCH 059/237] Read lyrics from metadata Closes #212 --- supysonic/api/media.py | 25 +++++++++++++++++------ tests/api/test_lyrics.py | 31 ++++++++++++++++++++++------- tests/assets/23bytes.txt | 1 - tests/assets/empty | 0 tests/assets/lyrics/empty.mp3 | Bin 0 -> 8567 bytes tests/assets/lyrics/empty.txt | 1 + tests/assets/lyrics/withlyrics.mp3 | Bin 0 -> 9659 bytes 7 files changed, 44 insertions(+), 14 deletions(-) delete mode 100644 tests/assets/23bytes.txt delete mode 100644 tests/assets/empty create mode 100644 tests/assets/lyrics/empty.mp3 create mode 100644 tests/assets/lyrics/empty.txt create mode 100644 tests/assets/lyrics/withlyrics.mp3 diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 27d7be1a..33a49bdf 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -316,6 +316,13 @@ def cover_art(): return send_file(cache.get(cache_key), mimetype=mimetype) +def lyrics_response_for_track(track, lyrics): + return request.formatter( + "lyrics", + dict(artist=track.album.artist.name, title=track.title, value=lyrics), + ) + + @api_routing("/getLyrics") def lyrics(): artist = request.values["artist"] @@ -323,6 +330,15 @@ def lyrics(): query = Track.select(lambda t: title in t.title and artist in t.artist.name) for track in query: + # Read from track metadata + lyrics = mediafile.MediaFile(track.path).lyrics + if lyrics is not None: + lyrics = lyrics.replace("\x00", "").strip() + if lyrics: + logger.debug("Found lyrics in file metadata: " + track.path) + return lyrics_response_for_track(track, lyrics) + + # Look for a text file with the same name of the track lyrics_path = os.path.splitext(track.path)[0] + ".txt" if os.path.exists(lyrics_path): logger.debug("Found lyrics file: " + lyrics_path) @@ -331,15 +347,12 @@ def lyrics(): with open(lyrics_path) as f: lyrics = f.read() except UnicodeError: - # Lyrics file couldn't be decoded. Rather than displaying an error, try with the potential next files or - # return no lyrics. Log it anyway. + # Lyrics file couldn't be decoded. Rather than displaying an error, try + # with the potential next files or return no lyrics. Log it anyway. logger.warning("Unsupported encoding for lyrics file " + lyrics_path) continue - return request.formatter( - "lyrics", - dict(artist=track.album.artist.name, title=track.title, value=lyrics), - ) + return lyrics_response_for_track(track, lyrics) # Create a stable, unique, filesystem-compatible identifier for the artist+title unique = hashlib.md5( diff --git a/tests/api/test_lyrics.py b/tests/api/test_lyrics.py index abbd0e33..c1cfea00 100644 --- a/tests/api/test_lyrics.py +++ b/tests/api/test_lyrics.py @@ -24,22 +24,33 @@ def setUp(self): with db_session: folder = Folder( name="Root", - path=os.path.abspath("tests/assets"), + path=os.path.abspath("tests/assets/lyrics"), root=True, - cover_art="cover.jpg", ) - self.folderid = folder.id artist = Artist(name="Artist") album = Album(artist=artist, name="Album") Track( - title="23bytes", + title="Nope", number=1, disc=1, artist=artist, album=album, - path=os.path.abspath("tests/assets/23bytes"), + path=os.path.abspath("tests/assets/lyrics/empty.mp3"), + root_folder=folder, + folder=folder, + duration=2, + bitrate=320, + last_modification=0, + ) + Track( + title="Yay", + number=1, + disc=1, + artist=artist, + album=album, + path=os.path.abspath("tests/assets/lyrics/withlyrics.mp3"), root_folder=folder, folder=folder, duration=2, @@ -91,9 +102,15 @@ def test_get_lyrics(self): # Local file rv, child = self._make_request( - "getLyrics", {"artist": "artist", "title": "23bytes"}, tag="lyrics" + "getLyrics", {"artist": "artist", "title": "nope"}, tag="lyrics" + ) + self.assertIn("text file", child.text) + + # Metadata + rv, child = self._make_request( + "getLyrics", {"artist": "artist", "title": "yay"}, tag="lyrics" ) - self.assertIn("null", child.text) + self.assertIn("Some words", child.text) if __name__ == "__main__": diff --git a/tests/assets/23bytes.txt b/tests/assets/23bytes.txt deleted file mode 100644 index 2870fb5c..00000000 --- a/tests/assets/23bytes.txt +++ /dev/null @@ -1 +0,0 @@ -That's a file full of null bytes diff --git a/tests/assets/empty b/tests/assets/empty deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/assets/lyrics/empty.mp3 b/tests/assets/lyrics/empty.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5d0f06f3649fbdc1fdcf81fc8a0c73660870ebd6 GIT binary patch literal 8567 zcmchcXHZjNx9>yK5Q0(_q!@ay0i*~CRl3rv^xjmefJpDX6KX>52oVra5JbB4A}CEk zqzEES>OP6@-235vxHD(w%z0+^m!0gj)^GjSTF)-P`J-OY|GU&Y9G>9L-NYT-AP|cm z=-M?Xl$x5Fo*o8+adPtW^TXj%Qc_5yii(P+rk4fh5+a`z+3?35t&m z(Fgb60q{$~N!u&hpCKh8h#rgt!3+CkHWaAOM7z$4_E->S)K@`ZMa-QDE(HpLlvpsR z^1aPa2;mKW*;k+@*uA`Om$u4uR$5Ggbmc^%WK0y&?dpC7qz*xT6=swOy!HCT)X796 zX`1@#NEHfL0tx8v%~pj;@v@>U=Ap}@VbIOV@E3-s& zZMaGX8hdN4cPfJ+x1{KU?B_V(1Mq8O$@c(ZXq{s?)u5NNRyv%Zc6#8pW`Ck(#)f?P zrc#wFZZV+5K)Cd#11{z>Q`^8M)M%+4WOp~#d$yn0Ib0;Ef^SOBCfmrPA+NI)_n!;k z%w^HlH%N`wi`r>Nae#)ZjqeMsk8|hw zT9Vq`f#XdTpvzzavS1?v-riNhk#cXqp`%-#)oNKto)RKCQUk=HX|WW*f22?$&h z4&x4B^Vf!PY876gtZ-6t>@^svEa?*&)t@<9Cg^3{`V_AFg9(vNoD$qgiI5V1xw4HH84U(ck)yqegw(%uifXPUV! z3yE;tvD5SxWz2Mtv8dThv2A{=d1xk6GNYF@vkaUqE1h({JA26*=KNSh+k(tb$Yqc& z{2@;@T1&-|5 zrx#RgV&+tI6!ohA|Ao%rZzyM9(DVdt!L9H{cD8n6C25Mh3J*o@FZECOj?jF&eR z?fHaF2yVL_mrpoTiGf4;ZLI#vB9&qYjBsfcpnqaeo_#A^O<%?#bg0!O71BSGCcWc5 z5eaSk=_RhQXvV&Pm||0c##AKZ6uWx9kxsuIGZK1ODxT0BCpt@VDAPJ$OA^3HFHgq^ z-u&oOKP*p%X8vGa`z5o%%iv$Y_WE=6ifbMd{B0~w{6Dj%bIB&{ToMJGZ#TiZlvK?# z@IW&}q?iWWP$;0kE4~UQV8A5g49kZ=)k)*}z(h#UGA@zIhO8xTMqFt}>~&L5{CLJ| z`@I7i1b-;AY-v<^{Ot1$XfraSZ$G~}Yxc=LZrSd;ml1hOyFi`$Fo#vVF>m$&*=^0B zDa)4h5skbFTy$Nab)#`s*J3kQn7rTlIZsm67(CE%OYu{t0$*#2-Kk}J@cYq9YWS=6 zZ(!}OYCX#zAQuUA>2f zMC!M!>gk!_z{V*58SR%cUZ(~^&7H^Kx9>hR#8JKJaK+@0YcaS9%RTyQbvp3~EorQI zm#9ylmfu?QS;$SC^{XrLKKsQDS3Tk&-<94-nKI z3xb6T49d4)pah(MaejaX+_7|#%qr5HZjvJk8FJR;Q+~%B&((kT76pt3q77R4^iX|` z+xj!gZh<4(A6(UZbq<%|aG>+7cjT`rGJcfPcA+sXQv3St%=MIAJx-PihIF;a1G?<2 zY{KeS<9MQr4ZI_p$1$Hk)@tC=#fhGkTmqvA+2M{#4O#OT=k$;JZry4=UI-(ZqWwYp z^Yvv($7} z{>Fu)jQ5-PTSqr`jdZN)9n7TL`5f&$316qAWsCaG2#fNuS>-%ZW##^xNra=>)v6Lx zl4t~v#fxUnsGlx2dDIG9BsiL~rYgD_rj#}^O;=CuD*L}II@oIr?rge1$DTzFSc~h& zMH-dcxwuqXQ?d;X>MLp9K#sd*m`}F!hTO#kyZt*Dzyoa&X*|OcA`@jv!&+d(l-T)$ zRNqb$R8`BfSgtFV4h|Bw4!()P2nsh4jc7Ri#~d)b6MeqL>s#a#sgKlp*M&AM^u$&W zBYDH{iAE8lhvs}fH|Td0E;FLYvP-2ySybPIXw#B03dKcuH6tj9k)TsKoO9eNl9}e= zwM?mQbFouTSZD7?QJYx5hG-^i!G^+Ppb=w|^EI|KoqKJ;8$lfqovQQ2-|i@kPSl3+ z@E(=E&ivo;nL9Lg6%e%U=^;>?zPohBocl@_ICHagzJcVP6X5KhW+XM?a=NRnN;xA|=n5Wn1p*n=LZaSovwjoy`6@*fNI3cSFB=HoFw0DL}Txve*&Li{jVj;xg zrUFNU=`63hcd@mg!;i~u=3Ls@>9nV@%>rJ4;4*M2)pgjli_nlwY?;!qa2%`c&YWd} z$HSxr1VX-EN*_n$k&%C~LjI?R$T)CQj{IJCngkk54gWj=f(*|AXU!4|f9^KXu8AeH zQdDzC^A!AyE8bF^p<{T}#tvsbdg>Iorvhucq&7Wc%{s&?~S;jMbA7>R4p4JJFtE)Y?U)nfrHM+bwJ>Z)S?0WU*NOSE8XEIjpwn$vjbT5@!@xYFfl{gLY zhxbm@60p!rc@bm^Rb~?&Xggd3)lB?8v49{_0GTMlJ z^fEUeQ)nIE^faYi54PAtc|@&i^)BeWMd><@wC{|KE(Zhy7bkN8wLV6jpL@!h)fHly z487vIT~@7J%E*W@M6m-mRO0gAfk1+*HcHS}e^!JkI)s2Do&rnqPw2OIoR;iukEc5} z*KC#H*y#zo`8}r(*STArP8LT0gzw5V1OunB76<_7I=||O`E}u=oN&iJr4L;-tpkb7 zV7`{oLnD0yiY6Uj{WlcC$^7v^h21U^RtW^0ry$%=y|TIGyr%y?q6ov0R3dQ>SN5>WL4>M-P}{Mwp!}pCv9AiKJ`fJq!4co#S;ezo8=m<29-x%?s$B|jndDS5b2 z54g(Y0Y~dG0-jYSFfbnbEvB(A4BF;MkPpG`VpP<#r@Z zDAHEaxbv@v!pIt%Ns%O!@L1A?{os%1 z{K_T`=$b)o@q3Et;`(lbVIF~ysgT+-Sw|nBXmeo8U3g8?>SVyh4iw*pv*AI1FFCC>4g5k)mV5K=jp$ znWb{ws>FF}hMj@sbFbD#-O_oqM96gE!MHf}TkT64lYYjdcyoJ6&J~wdM%COQ-xnoTAq`z5YP32W_j9EPZ6oX(Bjr7ltn)Bhl{tsxWPv2Na*Ys)QM zqWzlx&lj(Y4X+}e`+m!HuZuhN?_P_C)v5H*D|@p3eC^GzpqZ?@P5&GZv`q*#XePAA zy;DF)U=BfXP7H)(Z&fL|D?vE2L(4v;-#L&j;LqWNa{if)m>+vdi1s7dJA6y*x6r9_XepFeD%`aWK=rix(?u|Aywa!q8qk-~U z`6f20$Kl98gygT0p_8q^?!2Q{AaI&lb)T}2Q`=K^*MGFH^Y&Zc9my`#kF?_&TH`M= zVEP9%Ym^Q6>#yyh`Vt-Wowy+wbacm-rdQ z3kl!->1OWVTN4ythoH8xC9CGkq{IHs-Ltiw;!(qnf=2&l7!j_7$s9i~+ zrcL1uT_kC*DxjU)+F3d||I#JI;yIvSA9TVcm{@G^BRs8qa--P&eV&fFp;eGW2<`us z=}LPpMf0=^Xy3n^RcfJFxV1FuHwCepHGIaQ@qx^Lk(s1eQP%W;eL7y6VaU6felqya znT7d$@W#jLVBXF6aMD3aCVqD=(tJ@BSV}2LZs|XT!k|7_5@e#>U>JTVR5)qoQNB;n z+WbL2rJvpELd*pp{R>ny?g|96HP$>nrZP5BN5C~VuIE6Nd$aES>(NOH5m-xyEB^L(RjdRURUDb_Q ze+U59Z=3vIJQ3ENJ5TG8?lXRQkjEkIOtal%tplYAoq6im&zSs6b_Rq(KI zz{Oh;xG6l#IO2m-+uS$p?(>QtY5a=)Uv*8I*r|1mw+wZ~@zrJlR|>`XZ?!SAB8!(T z0{(n^CuVqBdQEvyo!KfTW>C8=4F2h0fy9P*-J9=WF8C2S)llMarc%V%}XizgwAz1>u78WsB#r(H4yO}$>KA@5(7pkG` zLQ1KeF9tH}0U=5ES_in${kPw`uRL6w#~uA2r;L3~oePwgQCwm)xUBqDofW-fSp)=c zoB{`kuABKBtg<7I8KLZYRtgI4+#wiGm=n_sj>c2NRtP2AXBBw^*mf^{Y>N(Xn)>bjc9L&R{;VVwxI zW$h;d$7%wnU9{hP&satVzJSM7%O1(Y6lG`ho@ZE;$||`Z;+z17btg(bJ{%SPKlRo6 zpIJdbNl_u7pcjB2P`+v<+-wD0Z6=u|>nXRaAC3K8wp%_By6Q~aG7e3I_`)fz}Kg)G7Fk1PZE96$Yd zHTAIYTiwGui=x6(`n`o-Jhz6*mA%{B==DeA^{RdvIs|%*shs03ZI(`2QuxxsRcT{N zJ zl2K*2z@y@)#Z5A(lqr!+{XZDqI<=t>H+-eVXJ*Q?jBJ8&UI2|?m3QtMOctwspV>gy z1vBw2bcqMrCRE>Vq{cuECJ@cvyeZ>43hod5;BKN>v*AmX4gMvb6PrCxk8uGm6I~R< z6GFWMTbW`K)!*9CP_k0){GR+s$}V&-{Y%}DC6Mayb^FK8a6v)roy@$z&Ojn5g1r|D zl5$MKRCG7UwxKtzBJFV%cSFNfjQ|nfROtEn19rnbq2}vBBTw2Sk?7SZ(RAPavd?3g zUQ)5`EGEKoH)7K4ihYQz+q*ev>WPY#Rx0Io#ayqL3eFt~sjaT0U z(y*P88;CTfLdDznioq4&iyo1u!A05Yz!UX~HobgQf-O5mtXQtfh>-z(J%&{aUiP7< zkk^G<@hPJ;p0f3 za`!5!g@U0)GTx3Kukx0IEibzEHujI`EG!p~r0Wb<&BW@hPtrO_$Ek#fc^MQUrpfR? zTZ9nz3R*O@Za$+0_L>EYDPblUwp9wvujoGrP0hfJBbRw!cYSLr(g4KRj-F8`5X3TzZ4&hr@(lohKFB|dFtuX(n|0? zga_n_oc0bUS@fF#z(JQxk>mY{6BezR$72qKtL1b$ir_70O7;L@OW^@;cstCxq_WW>hZ<+U&i`vvFx5rZ)52D-t} zaX}5LYrG&31>th7G68TFe8{_=4yUI#>8${E(pW2g9q01oV+RrrTF_*7tEq|QZ@QE@ zeSHKeA%<*)@=D#ZDkgWi4HOk^0KtE{E?Wb!8+D@gqsY;IZF;BT{{TUm8n2^q8>_a2 z^-qGh0LIr**JiNS!eUd|p~S83wh16_@3k3fBKXph1#TQ)`Bgj+k_)1Wt10>*akM~d zM6IRD9TKylz$n629j_AG$2L2fR8st2A{aPFXoeXgbC_}JgzX)uhOBjZ#OOwXDKhD3 zgNZ~tsO{qETOE^gPvXr2I#+TRV`Tey&iH%ZQOMc5&F!YdJLPRv(BtO`eRY)KA3Q)_lu}-TtkJV30f1E{6Z;3$c;cT~_ z$e;pW`YbfrPN^9@;s{AleOq_JkYE$HGAlMn`;n5=vDSO=mMVXOXjD0i^HV}CF<2_O*hs?rxolu< zb{KVMP<3D?3zM2|!kB0}{Ae&UgMy3AB$%^lm?fjFHoxgu{lP^SaGYA(#g@+BG%fDR zpQM5vYx-e%dMA{Gm~uFa8>XG4f(fn73CoRhFvkP!6GBa}q-c_x-O(*9PL#Q4o*vcz z{q=u7VE^~8{2#pn53~~oIcg+Ff}hN%v{*S&mjC@9_3*!J>>p70@Z(ntun=!B2qYE) O0ulc&$M8Su?!N%l?Bo#u literal 0 HcmV?d00001 diff --git a/tests/assets/lyrics/empty.txt b/tests/assets/lyrics/empty.txt new file mode 100644 index 00000000..177a1e02 --- /dev/null +++ b/tests/assets/lyrics/empty.txt @@ -0,0 +1 @@ +Lyrics in a text file next to a track without metadata. diff --git a/tests/assets/lyrics/withlyrics.mp3 b/tests/assets/lyrics/withlyrics.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5e8ec96f714b15d6933bf8ac8c52011f2f1a5b9c GIT binary patch literal 9659 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 literal 0 HcmV?d00001 From 26a0b7a712296c715845043b460bab160736806d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 23 Jan 2021 18:33:25 +0100 Subject: [PATCH 060/237] Simplfy the README --- README.md | 258 +++++++++---------------------------------------- docs/index.rst | 2 +- 2 files changed, 44 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index 1d4fac71..008a1ebc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Supysonic -_Supysonic_ is a Python implementation of the [Subsonic][] server API. +Supysonic is a Python implementation of the [Subsonic][] server API. ![Build Status](https://github.com/spl0k/supysonic/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) @@ -9,198 +9,53 @@ _Supysonic_ is a Python implementation of the [Subsonic][] server API. Current supported features are: * browsing (by folders or tags) * streaming of various audio file formats -* [transcoding] +* transcoding * user or random playlists -* cover arts (as image files in the same folder as music files) +* cover art * starred tracks/albums and ratings * [Last.FM][lastfm] scrobbling * Jukebox mode -_Supysonic_ currently targets the version 1.10.2 of the _Subsonic_ API. For more +Supysonic currently targets the version 1.10.2 of the Subsonic API. For more details, go check the [API implementation status][docs-api]. [subsonic]: http://www.subsonic.org/ -[transcoding]: docs/transcoding.rst -[lastfm]: https://last.fm/ -[docs-api]: docs/api.rst +[lastfm]: https://www.last.fm/ +[docs-api]: https://supysonic.readthedocs.io/en/latest/api.html -## Table of contents +## Documentation -* [Installation](#installation) - + [Prerequisites](#prerequisites) - + [Database initialization](#database-initialization) - + [Configuration](#configuration) -* [Running the application](#running-the-application) - + [As a standalone debug server](#as-a-standalone-debug-server) - + [As an Apache WSGI application](#as-an-apache-wsgi-application) - + [Other options](#other-options) - + [Docker](#docker) -* [Quickstart](#quickstart) -* [Running the daemon](#running-the-daemon) -* [Upgrading](#upgrading) - -## Installation - -_Supysonic_ can run as a standalone application (not recommended for a -"production" server) or as a WSGI application (on _Apache_ for instance). - -To install it, either run: - - $ python setup.py install - -or - - $ pip install . - -but not both. - -### Prerequisites - -You'll need Python 3.5 or later to run _Supysonic_. - -All the dependencies will automatically be installed by the installation -command above. - -You may also need a database specific package if you don't want to use SQLite -(the default): - -* _MySQL_: `pip install pymysql` or `pip install mysqlclient` -* _PostgreSQL_: `pip install psycopg2-binary` - -### Database initialization - -_Supysonic_ needs a database to run. It can either be a _SQLite_, -_MySQL_-compatible or _PostgreSQL_ database. - -Please refer to the documentation of the DBMS you've chosen on how to create a -database. Once it has a database, _Supysonic_ will automatically create the -tables it needs. - -If you want to use _PostgreSQL_ you'll have to add the `citext` extension to the -database once created. This can be done when connected to the database as the -superuser with the folowing SQL command: - - supysonic=# CREATE EXTENSION citext; - -If you absolutely have no clue about databases, you can go with _SQLite_ as it -doesn't need any setup other than specifying a path for the database. -Note that using _SQLite_ for large libraries might not be the brightest idea as -it tends to struggle with larger datasets. - -### Configuration - -Once you have a database, you'll need to create a configuration file. It must -be saved under one of the following paths: - -* `/etc/supysonic` -* `~/.supysonic` -* `~/.config/supysonic/supysonic.conf` - -A roughly documented sample configuration file is provided as `config.sample`. - -The minimal configuration using a _SQLite_ database would be: - -```ini -[base] -database_uri = sqlite:////some/path/to/a/supysonic.db -``` - -For a more details on the configuration, please refer to -[documentation][docs-config]. - -[docs-config]: docs/setup/configuration.rst - -## Running the application - -### As a standalone debug server - -It is possible to run _Supysonic_ as a standalone server, but it is only -recommended to do so if you are hacking on the source. A standalone won't be -able to serve more than one request at a time. - -To start the server, just run the `cgi-bin/server.py` script. - - $ python cgi-bin/server.py - -By default, it will listen on the loopback interface (`127.0.0.1`) on port -5000, but you can specify another address on the command line, for instance on -all the IPv6 interfaces: - - $ python cgi-bin/server.py :: - -### As an _Apache_ WSGI application - -_Supysonic_ can run as a WSGI application with the `cgi-bin/supysonic.wsgi` -file. To run it within an _Apache2_ server, first you need to install the WSGI -module and enable it. - - $ apt install libapache2-mod-wsgi-py3 - $ a2enmod wsgi - -Next, edit the _Apache_ configuration to load the application. Here's a basic -example of what it looks like: - - WSGIScriptAlias /supysonic /path/to/supysonic/cgi-bin/supysonic.wsgi - - WSGIApplicationGroup %{GLOBAL} - WSGIPassAuthorization On - Require all granted - - -With that kind of configuration, the server address will look like -*http://server/supysonic/* - -### Other options - -If you use another HTTP server, such as _nginx_ or _lighttpd_, or prefer to use -FastCGI or CGI over WSGI, FastCGI and CGI scripts are also provided in the -`cgi-bin` folder, respectively as `supysonic.fcgi` and `supysonic.cgi`. You -might need to edit those file to suit your system configuration. - -Here are some quick docs on how to configure your server for [FastCGI][] or -[CGI][]. - -[fastcgi]: http://flask.pocoo.org/docs/deploying/fastcgi/ -[cgi]: http://flask.pocoo.org/docs/deploying/cgi/ - -### Docker - -If you want to run _Supysonic_ in a _Docker_ container, here are some images -provided by the community. - -- https://github.com/ultimate-pms/docker-supysonic -- https://github.com/ogarcia/docker-supysonic -- https://github.com/foosinn/supysonic -- https://github.com/mikafouenski/docker-supysonic -- https://github.com/oakman/supysonic-docker -- https://github.com/glogiotatidis/supysonic-docker +Full documentation is available at https://supysonic.readthedocs.io/ ## Quickstart -To start using _Supysonic_, you'll first have to specify where your music -library is located and create a user to allow calls to the API. - -Let's start by creating a new admin user this way: - - $ supysonic-cli user add MyUserName -p MyAwesomePassword - $ supysonic-cli user setroles -A MyUserName - -To add a new folder to your music library, you can do something like this: +Use the following commands to install Supysonic, create an admin user, define a +library folder, scan it and start serving using [Gunicorn][]. + $ pip install git+https://github.com/spl0k/supysonic.git + $ pip install gunicorn + $ supysonic-cli user add MyUserName + $ supysonic-cli user setroles --admin MyUserName $ supysonic-cli folder add MyLibrary /home/username/Music - -Once you've added a folder, you will need to scan it: - $ supysonic-cli folder scan MyLibrary + $ gunicorn -b 0.0.0.0:5000 "supysonic.web:create_application()" You should now be able to enjoy your music with the client of your choice! -For more details on the command-line usage, take a look at the -[documentation][docs-cli]. +But using only the above commands will use a default configuration and +especially storing the database in a temporary directory. Head over to the +documentaiton for [full setup instructions][docs-setup], plus other options if +you don't want to use Gunicorn. + +Note that there's also an optional [daemon][docs-daemon] that watches for +library changes and provides support for other features such as the +jukebox mode. -[docs-cli]: docs/man/supysonic-cli.rst +[gunicorn]: https://gunicorn.org/ +[docs-setup]: https://supysonic.readthedocs.io/en/latest/setup/index.html +[docs-daemon]: https://supysonic.readthedocs.io/en/latest/setup/daemon.html -## Client authentication +## About client authentication The Subsonic API provides several authentication methods. One of them, known as _token authentication_ was added with API version 1.13.0. As Supysonic currently @@ -208,46 +63,19 @@ targets API version 1.9.0, the token based method isn't supported. So if your client offers you the option, you'll have to disable the token based authentication for it to work. -## Running the daemon - -_Supysonic_ comes with an optional daemon service that currently provides the -following features: -- background scans -- library changes detection -- jukebox mode - -First of all, the daemon allows running backgrounds scans, meaning you can start -scans from the CLI and do something else while it's scanning (otherwise the scan -will block the CLI until it's done). -Background scans also enable the web UI to run scans, while you have to use the -CLI to do so if you don't run the daemon. - -Instead of manually running a scan every time your library changes, the daemon -can listen to any library change and update the database accordingly. This -watcher is started along with the daemon but can be disabled to only keep -background scans. - -Finally, the daemon acts as a backend for the jukebox mode, allowing to play -audio on the machine running Supysonic. - -The daemon is `supysonic-daemon`, it is a non-exiting process. If you want to -keep it running in background, either use the old `nohup` or `screen` methods, -or start it as a _systemd_ unit (see the very basic _supysonic-daemon.service_ -file). - -## Upgrading - -To upgrade your _Supysonic_ installation, simply re-run the command you used to -install it (either `python setup.py install` or `pip install .`). - -Some commits might introduce changes in the database schema. Starting with -commit e84459d6278bfc735293edc19b535c62bc2ccd8d (August 29th, 2018) migrations -will be automatically applied. - -If your database was created prior to this date, you'll have to manually apply -unapplied migrations up to the latest. Once done you won't have to worry about -future migrations as they'll be automatically applied. -Migration scripts are provided in the `supysonic/schema/migration` folder, named -by the date of commit that introduced the schema changes. There could be both -SQL scripts or Python scripts. The Python scripts require arguments that are -explained when the script is invoked with the `-h` flag. +## Development stuff + +For those wishing to collaborate on the project, since Supysonic uses [Flask][] +you can use its development server which provides automatic reloading and +in-browser debugging among other things. To start said server: + + $ export FLASK_APP="supysonic.web:create_application()" + $ export FLASK_ENV=development + $ flask run + +And there's also the tests: + + $ python setup.py test + $ python setup.py test --test-suite tests.with_net + +[flask]: https://flask.palletsprojects.com/ \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 3782e132..96c540ef 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ Current supported features are: * streaming of various audio file formats * transcoding * user or random playlists -* cover arts (as image files in the same folder as music files) +* cover art * starred tracks/albums and ratings * `Last.FM`__ scrobbling * Jukebox mode From a94cc0e0693ac6ee6933ee4ef10c161dc9714152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 24 Jan 2021 17:49:18 +0100 Subject: [PATCH 061/237] Usage doc --- README.md | 10 +-- docs/index.rst | 1 + docs/make.bat | 36 +++++++++ docs/setup/configuration.rst | 2 + docs/transcoding.rst | 2 + docs/usage.rst | 145 +++++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 9 deletions(-) create mode 100755 docs/make.bat create mode 100644 docs/usage.rst diff --git a/README.md b/README.md index 008a1ebc..108e0405 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,6 @@ jukebox mode. [docs-setup]: https://supysonic.readthedocs.io/en/latest/setup/index.html [docs-daemon]: https://supysonic.readthedocs.io/en/latest/setup/daemon.html -## About client authentication - -The Subsonic API provides several authentication methods. One of them, known as -_token authentication_ was added with API version 1.13.0. As Supysonic currently -targets API version 1.9.0, the token based method isn't supported. So if your -client offers you the option, you'll have to disable the token based -authentication for it to work. - ## Development stuff For those wishing to collaborate on the project, since Supysonic uses [Flask][] @@ -78,4 +70,4 @@ And there's also the tests: $ python setup.py test $ python setup.py test --test-suite tests.with_net -[flask]: https://flask.palletsprojects.com/ \ No newline at end of file +[flask]: https://flask.palletsprojects.com/ diff --git a/docs/index.rst b/docs/index.rst index 96c540ef..0411c00d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,6 +23,7 @@ __ https://www.last.fm/ :maxdepth: 2 setup/index + usage transcoding jukebox man/index diff --git a/docs/make.bat b/docs/make.bat new file mode 100755 index 00000000..7b4c676e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR="." +set BUILDDIR="_build" + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd + diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index 0de5c152..821c82ce 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -239,6 +239,8 @@ Sample configuration:: log_file = /var/supysonic/supysonic-daemon.log log_level = INFO +.. _conf-lastfm: + ``[lastfm]`` section -------------------- diff --git a/docs/transcoding.rst b/docs/transcoding.rst index e451d534..15821ece 100644 --- a/docs/transcoding.rst +++ b/docs/transcoding.rst @@ -130,6 +130,8 @@ To include track metadata in the transcoded stream:: encoder_ogg = oggenc2 -Q -M %outrate -t %title -l %album -a %artist -N %tracknumber -c TOTALTRACKS=%totaltracks -c DISCNUMBER=%discnumber -G %genre -d %year - default_transcode_target = mp3 +.. _transcoding-enable: + Enabling transcoding -------------------- diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 00000000..36c11d3d --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,145 @@ +Using Supysonic +=============== + +Now that everything is :doc:`set up `, there's actually some more +administrative tasks to perform before really being able to access your music. +The first one being :ref:`usage-users` and next :ref:`usage-folders`. Then you +can choose one of the many :ref:`usage-clients` and you're good to go. + +.. _usage-users: + +Adding users +------------ + +One of the first thing you want to do is to create the user(s) that will be +allowed to access Supysonic. The first user has to be created with the +:abbr:`CLI (command-line interface)` (:doc:`manpage here `), +if you set them as an admin this new user will then be able to add more users +through :ref:`usage-web`. + +Creating a new user and giving them administrative rights is done with the +following two commands:: + + $ supysonic-cli user add TheUserName + $ supysonic-cli user setroles --admin TheUserName + +The first command will ask for a password but you can also provide it on the +command-line:: + + $ supysonic-cli user add TheUserName --password ThePassword + +If you don't want to set the user as an admin but still want them to be able to +use the :doc:`jukebox`, you can give them the right like so:: + + $ supysonic-cli user setroles --jukebox TheUserName + +This last one isn't needed for admins as they have full control over the +installation. + +.. _usage-folders: + +Defining and scanning folders +----------------------------- + +Supysonic will be pretty useless if you don't tell it where your music is +located. This can once again be done with the CLI or the web interface. + +Using the CLI:: + + $ supysonic-cli folder add SomeFolderName /path/where/the/music/is + +The next step is now to scan the folder to find all the media files it holds:: + + $ supysonic-cli folder scan SomeFolderName + +If :doc:`setup/daemon` is running, this will start scanning in the background, +otherwise you'll have to wait for the scan to end. This can take some time if +you have a huge library. + +.. _usage-web: + +The web interface +----------------- + +Once you have created a user, you can access the web interface at the root URL +where the application is deployed. As Supysonic is mostly a server and not a +media player this interface won't provide much. It is mainly used for +administrative purposes but also provides some features for regular users that +are only available through this interface. + +Once logged, users can click on their username in the top bar to access some +settings. These include the ability to link their Last.fm__ account provided +Supysonic was :ref:`configured ` with Last.fm API keys. Once linked +clients will then be able to send *scrobbles*. + +.. note:: + + In the case of Android clients (this haven't been tested with iOS) this could + lead to *scrobbles* being sent twice if the official Last.fm application is + also installed on the device. + +Another setting also available only through the web interface is the ability to +define the :ref:`preferred transcoding format `. + +Admins got two more options accessible from the top bar: the ability to manage +users and folders. But these have limitations compared to the CLI: you can't +grant or revoke the users' jukebox privilege and you can scan folders you +added only if :doc:`setup/daemon` is running. + +__ https://www.last.fm/ + +.. _usage-clients: + +Clients +------- + +You'll need a client to access your music. Whether you want an app for your +smartphone, something running on your desktop or in a web page you got several +options here. + +One good start would be looking at the list on `Subsonic website`__ but that +list *could* be a bit out of date and there's also some players that don't +appear here. Also disregard the trial notice there, Supysonic doesn't include +such nonsense. + +Here are some hand-picked clients: + +* in your browser: + + * SubPlayer__ (source__, especially designed to work with Supysonic) + * Jamstash__ (source__, whose maintainer contributed to Supysonic) + +* on Android: + + * Ultrasonic__ (source__, whose maintainer contributed to Supysonic) + * DSub__ (source__) + +* on iOS device: + + * you'll have to find one yourself 😉 + +* for the desktop (none of them were tested) + + * Clementine__ + * MusicBee__ with a plugin__ + +.. note:: + + The Subsonic API provides several authentication methods. One of them, known + as *token authentication* was added with API version 1.13.0. As Supysonic + currently targets API version 1.9.0, the token based method isn't supported. + So if your client offers you the option, you'll have to disable the token + based authentication for it to work. + +__ http://www.subsonic.org/pages/apps.jsp +__ https://subplayer.netlify.app/ +__ https://github.com/peguerosdc/subplayer +__ https://jamstash.com/ +__ https://github.com/tsquillario/Jamstash +__ https://play.google.com/store/apps/details?id=org.moire.ultrasonic +__ https://github.com/ultrasonic/ultrasonic/ +__ https://play.google.com/store/apps/details?id=github.daneren2005.dsub +__ https://github.com/daneren2005/Subsonic +__ https://www.clementine-player.org +__ https://getmusicbee.com +__ https://getmusicbee.com/addons/plugins/41/subsonic-client/ From 0d55b7cab9fda36deb17f5d31f99a53d170467b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 24 Jan 2021 17:53:59 +0100 Subject: [PATCH 062/237] Docs: fix redundant navigation in sidebar --- docs/conf.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 01a4cd1b..cf43a4b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,16 +32,22 @@ } html_static_path = ["_static"] -# Default alabaseter sidebars + localtoc html_sidebars = { - "**": [ + "*": [ + "about.html", + "navigation.html", + "relations.html", + "searchbox.html", + "donate.html", + ], + "setup/**": [ "about.html", "localtoc.html", "navigation.html", "relations.html", "searchbox.html", "donate.html", - ] + ], } html_domain_indices = False From be88f5fb78c9af9274e9321867f548a250de4f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 30 Jan 2021 15:04:33 +0100 Subject: [PATCH 063/237] Version bump --- docs/conf.py | 4 ++-- supysonic/__init__.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cf43a4b9..1f7e81f1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,8 +4,8 @@ author = "Alban Féron" copyright = "2013-2021, " + author -version = "0.6.2" -release = "0.6.2" +version = "0.6.3" +release = "0.6.3" # -- General configuration --------------------------------------------------- diff --git a/supysonic/__init__.py b/supysonic/__init__.py index ff1aa2cb..69046472 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "supysonic" -VERSION = "0.6.2" +VERSION = "0.6.3" DESCRIPTION = "Python implementation of the Subsonic server API." KEYWORDS = "subsonic music api" AUTHOR_NAME = "Alban Féron" @@ -20,6 +20,7 @@ * streaming of various audio file formats * transcoding * user or random playlists -* cover arts (cover.jpg files in the same folder as music files) +* cover art (cover.jpg files in the same folder as music files) * starred tracks/albums and ratings -* Last.FM scrobbling""" +* Last.FM scrobbling +* Jukebox mode""" From d05889df5b751de28e0096487bc94c64905e434c Mon Sep 17 00:00:00 2001 From: Robert Sprunk Date: Mon, 1 Feb 2021 20:21:15 +0100 Subject: [PATCH 064/237] Check id of the user who created the playlist --- supysonic/templates/playlist.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/templates/playlist.html b/supysonic/templates/playlist.html index 0ad0796a..8541ea8f 100644 --- a/supysonic/templates/playlist.html +++ b/supysonic/templates/playlist.html @@ -16,7 +16,7 @@ -{% if playlist.user_id == request.user.id %} +{% if playlist.user.id == request.user.id %}

Edit

From 08329fc8bc74072417cf2a753803f1ca1ad7fe0e Mon Sep 17 00:00:00 2001 From: Robert Sprunk Date: Mon, 1 Feb 2021 20:28:11 +0100 Subject: [PATCH 065/237] Export playlists to m3u using web interface --- supysonic/frontend/playlist.py | 23 ++++++++++++++++++++++- supysonic/templates/playlist_export.m3u | 12 ++++++++++++ supysonic/templates/playlists.html | 4 +++- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 supysonic/templates/playlist_export.m3u diff --git a/supysonic/frontend/playlist.py b/supysonic/frontend/playlist.py index ff6920bc..a0cbeb41 100644 --- a/supysonic/frontend/playlist.py +++ b/supysonic/frontend/playlist.py @@ -7,7 +7,7 @@ import uuid -from flask import flash, redirect, render_template, request, url_for +from flask import Response, flash, redirect, render_template, request, url_for from pony.orm import ObjectNotFound from ..db import Playlist @@ -40,6 +40,27 @@ def playlist_details(uid): return render_template("playlist.html", playlist=playlist) +@frontend.route("/playlist//export") +def playlist_export(uid): + try: + uid = uuid.UUID(uid) + except ValueError: + flash("Invalid playlist id") + return redirect(url_for("frontend.playlist_index")) + + try: + playlist = Playlist[uid] + except ObjectNotFound: + flash("Unknown playlist") + return redirect(url_for("frontend.playlist_index")) + + return Response( + render_template("playlist_export.m3u", playlist=playlist), + mimetype="audio/mpegurl", + headers={"Content-disposition": f"attachment; filename={playlist.name}.m3u"} + ) + + @frontend.route("/playlist/", methods=["POST"]) def playlist_update(uid): diff --git a/supysonic/templates/playlist_export.m3u b/supysonic/templates/playlist_export.m3u new file mode 100644 index 00000000..c56d9ef0 --- /dev/null +++ b/supysonic/templates/playlist_export.m3u @@ -0,0 +1,12 @@ +{#- + This file is part of Supysonic. + Supysonic is a Python implementation of the Subsonic server API. + + Copyright (C) 2013-2018 Alban 'spl0k' Féron + 2017 Óscar García Amor + + Distributed under terms of the GNU AGPLv3 license. +-#} +{% for t in playlist.get_tracks() %} +{{ t.path }} +{% endfor %} \ No newline at end of file diff --git a/supysonic/templates/playlists.html b/supysonic/templates/playlists.html index 2388e992..4949f154 100644 --- a/supysonic/templates/playlists.html +++ b/supysonic/templates/playlists.html @@ -21,7 +21,7 @@

My playlists

{% else %}
- + {% for p in mine %} @@ -32,6 +32,8 @@

My playlists

aria-label="Public playlist">{% else %}{% endif %} + From dcef74ca70e38463e471c355a158ce91892994f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 7 Feb 2021 16:35:55 +0100 Subject: [PATCH 066/237] Allow renaming users with the CLI --- docs/man/supysonic-cli-user.rst | 4 ++++ supysonic/cli.py | 28 +++++++++++++++++++++++++++- tests/base/test_cli.py | 21 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/man/supysonic-cli-user.rst b/docs/man/supysonic-cli-user.rst index b4f51d50..3803d099 100644 --- a/docs/man/supysonic-cli-user.rst +++ b/docs/man/supysonic-cli-user.rst @@ -18,6 +18,7 @@ Synopsis | ``supysonic-cli user delete`` `user` | ``supysonic-cli user changepass`` `user` `password` | ``supysonic-cli user setroles`` [``--admin``\|\ ``--noadmin``] [``--jukebox``\|\ ``--nojukebox``] `user` +| ``supysonic-cli user rename`` `user` `newname` Description =========== @@ -42,6 +43,9 @@ a new user, delete an existing user, and change their password or roles. ``supysonic-cli user setroles`` [``--admin``\|\ ``--noadmin``] [``--jukebox``\|\ ``--nojukebox``] `user` Give or remove rights to user `user`. +``supysonic-cli user rename`` `user` `newname` + Rename the user `user` to `newname` + Options ======= diff --git a/supysonic/cli.py b/supysonic/cli.py index e40db65e..7c8b292e 100644 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2021 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -347,6 +347,11 @@ def __watch_folder(self, folder): "name", help="Name/login of the user to which change the password" ) user_pass_parser.add_argument("password", nargs="?", help="New password") + user_rename_parser = user_subparsers.add_parser( + "rename", help="Rename a user", add_help=False + ) + user_rename_parser.add_argument("name", help="Name of the user to rename") + user_rename_parser.add_argument("newname", help="New name for the user") @db_session def user_list(self): @@ -414,6 +419,27 @@ def user_changepass(self, name, password): except ObjectNotFound as e: self.write_error_line(str(e)) + @db_session + def user_rename(self, name, newname): + if not name or not newname: + self.write_error_line("Missing user current name or new name") + return + + if name == newname: + return + + user = User.get(name=name) + if user is None: + self.write_error_line("No such user") + return + + if User.get(name=newname) is not None: + self.write_error_line("This name is already taken") + return + + user.name = newname + self.write_line("User '{}' renamed to '{}'".format(name, newname)) + def main(): config = IniConfig.from_common_locations() diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index 5c3b6926..f72ca3f7 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2021 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -137,6 +137,25 @@ def test_user_changepass(self): self.__cli.onecmd("user changepass alice newpass") self.__cli.onecmd("user changepass bob B0b") + def test_user_rename(self): + self.__cli.onecmd("user add -p Alic3 alice") + self.__cli.onecmd("user rename alice alice") + self.__cli.onecmd("user rename bob charles") + + self.__cli.onecmd("user rename alice ''") + with db_session: + self.assertEqual(User.select().first().name, "alice") + + self.__cli.onecmd("user rename alice bob") + with db_session: + self.assertEqual(User.select().first().name, "bob") + + self.__cli.onecmd("user add -p Ch4rl3s charles") + self.__cli.onecmd("user rename bob charles") + with db_session: + self.assertEqual(User.select(lambda u: u.name == "bob").count(), 1) + self.assertEqual(User.select(lambda u: u.name == "charles").count(), 1) + def test_other(self): self.assertTrue(self.__cli.do_EOF("")) self.__cli.onecmd("unknown command") From 8eca8ba60f134b677e5943674508cd2c34f46f7a Mon Sep 17 00:00:00 2001 From: ankitdobhal Date: Wed, 17 Feb 2021 06:33:33 -0500 Subject: [PATCH 067/237] Fixed antipattern - Remove unnecessary return statement - Remove unnecessary `del` statement from local scope - Consider merging the comparisons with 'in' --- supysonic/api/annotation.py | 1 - supysonic/scanner.py | 2 +- supysonic/watcher.py | 1 - tests/api/test_playlist.py | 10 +++------- tests/issue133.py | 1 - tests/issue139.py | 1 - tests/issue148.py | 1 - 7 files changed, 4 insertions(+), 13 deletions(-) diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index d49c03a9..41f15496 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -51,7 +51,6 @@ def unstar_single(cls, starcls, eid): """ delete(s for s in starcls if s.user.id == request.user.id and s.starred.id == eid) - return None def handle_star_request(func): diff --git a/supysonic/scanner.py b/supysonic/scanner.py index b9ad35a0..06302179 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -393,7 +393,7 @@ def __find_folder(self, path): children = [] drive, _ = os.path.splitdrive(path) path = os.path.dirname(path) - while path != drive and path != "/": + while path not in (drive, "/"): folder = Folder.get(path=path) if folder is not None: break diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 956b0881..24a6add0 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -166,7 +166,6 @@ def __run(self): scanner.prune() logger.debug("Freeing scanner") - del scanner def __process_regular_item(self, scanner, item): if item.operation & OP_MOVE: diff --git a/tests/api/test_playlist.py b/tests/api/test_playlist.py index c040ef9a..0df4d1b2 100644 --- a/tests/api/test_playlist.py +++ b/tests/api/test_playlist.py @@ -67,12 +67,8 @@ def test_get_playlists(self): "getPlaylists", {"u": "bob", "p": "B0b"}, tag="playlists" ) self.assertEqual(len(child), 2) - self.assertTrue( - child[0].get("owner") == "alice" or child[1].get("owner") == "alice" - ) - self.assertTrue( - child[0].get("owner") == "bob" or child[1].get("owner") == "bob" - ) + self.assertTrue("alice" in (child[0].get("owner"), child[1].get("owner"))) + self.assertTrue("bob" in (child[0].get("owner"), child[1].get("owner"))) self.assertIsNotNone( self._find(child, "./playlist[@owner='alice'][@public='true']") ) @@ -122,7 +118,7 @@ def test_get_playlist(self): self.assertEqual(child.get("duration"), "4") self.assertEqual(child[0].get("title"), "One") self.assertTrue( - child[1].get("title") == "Two" or child[1].get("title") == "Three" + child[1].get("title") in ("Two", "Three") ) # depending on 'getPlaylists' result ordering def test_create_playlist(self): diff --git a/tests/issue133.py b/tests/issue133.py index 11ff762d..3e7257b5 100644 --- a/tests/issue133.py +++ b/tests/issue133.py @@ -34,7 +34,6 @@ def test_issue133(self): scanner = Scanner() scanner.queue_folder("folder") scanner.run() - del scanner track = Track.select().first() self.assertNotIn("\x00", track.title) diff --git a/tests/issue139.py b/tests/issue139.py index d9f2c66c..db135a0d 100644 --- a/tests/issue139.py +++ b/tests/issue139.py @@ -32,7 +32,6 @@ def do_scan(self): scanner = Scanner() scanner.queue_folder("folder") scanner.run() - del scanner def test_null_genre(self): shutil.copy("tests/assets/issue139.mp3", self.__dir) diff --git a/tests/issue148.py b/tests/issue148.py index f5eba7a8..595b5d87 100644 --- a/tests/issue148.py +++ b/tests/issue148.py @@ -40,7 +40,6 @@ def test_issue(self): scanner = Scanner() scanner.queue_folder("folder") scanner.run() - del scanner if __name__ == "__main__": From 9324025c41ca482e59253de2e90a3f3d6b1fcd22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Apr 2021 15:47:31 +0200 Subject: [PATCH 068/237] Added disclaimer before exporting playlist --- supysonic/frontend/playlist.py | 8 +++++--- supysonic/static/js/supysonic.js | 2 +- supysonic/templates/playlists.html | 24 ++++++++++++++++++++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/supysonic/frontend/playlist.py b/supysonic/frontend/playlist.py index a0cbeb41..4cd04187 100644 --- a/supysonic/frontend/playlist.py +++ b/supysonic/frontend/playlist.py @@ -40,6 +40,7 @@ def playlist_details(uid): return render_template("playlist.html", playlist=playlist) + @frontend.route("/playlist//export") def playlist_export(uid): try: @@ -57,9 +58,10 @@ def playlist_export(uid): return Response( render_template("playlist_export.m3u", playlist=playlist), mimetype="audio/mpegurl", - headers={"Content-disposition": f"attachment; filename={playlist.name}.m3u"} - ) - + headers={ + "Content-disposition": "attachment; filename={}.m3u".format(playlist.name) + }, + ) @frontend.route("/playlist/", methods=["POST"]) diff --git a/supysonic/static/js/supysonic.js b/supysonic/static/js/supysonic.js index e207c696..44c58b07 100644 --- a/supysonic/static/js/supysonic.js +++ b/supysonic/static/js/supysonic.js @@ -12,6 +12,6 @@ $(function () { $('[data-toggle="tooltip"]').tooltip() }); -$('#confirm-delete').on('show.bs.modal', function(e) { +$('.modal').on('show.bs.modal', function(e) { $(this).find('.btn-ok').attr('href', $(e.relatedTarget).data('href')); }); diff --git a/supysonic/templates/playlists.html b/supysonic/templates/playlists.html index 4949f154..8e5c4792 100644 --- a/supysonic/templates/playlists.html +++ b/supysonic/templates/playlists.html @@ -32,8 +32,8 @@

My playlists

aria-label="Public playlist">{% else %}{% endif %} -
+ @@ -77,4 +77,24 @@ + {% endblock %} From aea8ebeb13357a24756f8597ea781cc9c8b8c8a6 Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 11 Jun 2021 20:40:18 +0200 Subject: [PATCH 069/237] fix del issue when unschedule scanning queue --- supysonic/watcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 24a6add0..4259025f 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -227,7 +227,7 @@ def put(self, path, operation, **kwargs): def unschedule_paths(self, basepath): with self.__cond: - for path in self.__queue.keys(): + for path in list(self.__queue): if path.startswith(basepath): del self.__queue[path] From e6e20d166978aa9b472a494f13d6a728273f103f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 11 Sep 2021 16:21:04 +0200 Subject: [PATCH 070/237] Work around race when creating ClientPrefs Closes #220 --- supysonic/api/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index bbc09985..3794c264 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -11,7 +11,7 @@ import uuid from flask import request from flask import Blueprint -from pony.orm import ObjectNotFound +from pony.orm import ObjectNotFound, TransactionIntegrityError from pony.orm import commit from ..db import ClientPrefs, Folder @@ -83,8 +83,15 @@ def get_client_prefs(): try: request.client = ClientPrefs[request.user, client] except ObjectNotFound: - request.client = ClientPrefs(user=request.user, client_name=client) - commit() + try: + request.client = ClientPrefs(user=request.user, client_name=client) + commit() + except TransactionIntegrityError: + # We might have hit a race condition here, another request already created + # the ClientPrefs. Issue #220 + # Reload the user or Pony will complain about different transactions + request.user = UserManager.get(request.user.id) + request.client = ClientPrefs[request.user, client] def get_entity(cls, param="id"): From a1eeeb8ba95b3852de357c7feb75fe8790a1c079 Mon Sep 17 00:00:00 2001 From: Dave Holland Date: Mon, 13 Sep 2021 14:00:48 +0100 Subject: [PATCH 071/237] merge identical genre tags in album info --- supysonic/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/db.py b/supysonic/db.py index 2fa3fa07..42758d33 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -240,7 +240,7 @@ def as_subsonic_album(self, user): # "AlbumID3" type in XSD if count(self.tracks.year) > 0: info["year"] = min(self.tracks.year) - genre = ", ".join(self.tracks.genre) + genre = ", ".join(list(set(self.tracks.genre))) if genre: info["genre"] = genre From 8652c47ec388b37f3d07925fc4d26fbb53473477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 18 Sep 2021 17:00:02 +0200 Subject: [PATCH 072/237] Added test for #221 --- tests/__init__.py | 2 ++ tests/issue221.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100755 tests/issue221.py diff --git a/tests/__init__.py b/tests/__init__.py index 36ba1519..3ea79525 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -19,6 +19,7 @@ from .issue133 import Issue133TestCase from .issue139 import Issue139TestCase from .issue148 import Issue148TestCase +from .issue221 import Issue221TestCase def suite(): @@ -34,5 +35,6 @@ def suite(): suite.addTest(unittest.makeSuite(Issue133TestCase)) suite.addTest(unittest.makeSuite(Issue139TestCase)) suite.addTest(unittest.makeSuite(Issue148TestCase)) + suite.addTest(unittest.makeSuite(Issue221TestCase)) return suite diff --git a/tests/issue221.py b/tests/issue221.py new file mode 100755 index 00000000..2825d195 --- /dev/null +++ b/tests/issue221.py @@ -0,0 +1,53 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import unittest + +from pony.orm import db_session + +from supysonic import db + + +class Issue221TestCase(unittest.TestCase): + def setUp(self): + db.init_database("sqlite:") + with db_session: + root = db.Folder(root=True, name="Folder", path="tests") + artist = db.Artist(name="Artist") + album = db.Album(artist=artist, name="Album") + + for i in range(3): + db.Track( + title="Track {}".format(i), + album=album, + artist=artist, + disc=1, + number=i + 1, + duration=3, + has_art=False, + bitrate=64, + path="tests/track{}".format(i), + last_modification=2, + root_folder=root, + folder=root, + genre="Genre", + ) + + db.User(name="user", password="secret", salt="sugar") + + def tearDown(self): + db.release_database() + + @db_session + def test_issue(self): + data = db.Album.get().as_subsonic_album(db.User.get()) + self.assertIn("genre", data) + self.assertEqual(data["genre"], "Genre") + + +if __name__ == "__main__": + unittest.main() From e8d3f164b038cc32a8c5c68c9d99eb168b918a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 18 Sep 2021 17:07:53 +0200 Subject: [PATCH 073/237] Slightly improved genre merging --- supysonic/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/db.py b/supysonic/db.py index 42758d33..3e531bcf 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -240,7 +240,7 @@ def as_subsonic_album(self, user): # "AlbumID3" type in XSD if count(self.tracks.year) > 0: info["year"] = min(self.tracks.year) - genre = ", ".join(list(set(self.tracks.genre))) + genre = ", ".join(self.tracks.genre.distinct()) if genre: info["genre"] = genre From 5490189484fbe7144c3d023b996aa920329f6b6d Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Thu, 7 Oct 2021 08:57:39 -0400 Subject: [PATCH 074/237] Add cover art to downloaded zip files - Only includes cover art in the zip file if it's provided separately from the files (ie. doesn't extract it from the tracks) - Refactors the existing code for implementing the `/getCoverArt` API endpoint to allow it to be reused. --- supysonic/api/media.py | 91 ++++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 33a49bdf..3a95ed61 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -27,6 +27,7 @@ from ..cache import CacheMiss from ..db import Track, Album, Folder, now +from ..covers import EXTENSIONS from . import get_entity, get_entity_id, api_routing from .exceptions import ( @@ -249,14 +250,63 @@ def download_media(): except ObjectNotFound: raise NotFound("Folder") + # Stream a zip of the tracks + cover art to the client z = ZipFile(compression=ZIP_DEFLATED) for track in rv.tracks: z.write(track.path, os.path.basename(track.path)) + + cover_path = _cover_from_collection(rv, extract=False) + if cover_path: + z.write(cover_path, os.path.basename(cover_path)) + resp = Response(z, mimetype="application/zip") resp.headers["Content-Disposition"] = "attachment; filename={}.zip".format(rv.name) return resp +def _cover_from_track(tid): + """Extract and return a path to a track's cover art + + Returns None if no cover art is available. + """ + cache = current_app.cache + cache_key = "{}-cover".format(tid) + try: + return cache.get(cache_key) + except CacheMiss: + obj = Track[tid] + try: + return cache.set(cache_key, mediafile.MediaFile(obj.path).art) + except mediafile.UnreadableFileError: + return None + + +def _cover_from_collection(obj, extract=True): + """Get a path to cover art from a collection (Album, Folder) + + If `extract` is True, will fall back to extracting cover art from tracks + Returns None if no cover art is available. + """ + cover_path = None + + if isinstance(obj, Folder) and obj.cover_art: + cover_path = os.path.join(obj.path, obj.cover_art) + + elif isinstance(obj, Album): + track_with_folder_cover = obj.tracks.select(lambda t: t.folder.cover_art is not None).first() + if track_with_folder_cover is not None: + cover_path = _cover_from_collection(track_with_folder_cover.folder) + + if not cover_path and extract: + track_with_embedded = obj.tracks.select(lambda t: t.has_art).first() + if track_with_embedded is not None: + cover_path = _cover_from_track(track_with_embedded.id) + + if not cover_path or not os.path.isfile(cover_path): + return None + return cover_path + + @api_routing("/getCoverArt") def cover_art(): cache = current_app.cache @@ -267,39 +317,38 @@ def cover_art(): except GenericError: fid = None try: - tid = get_entity_id(Track, eid) + uid = get_entity_id(Track, eid) except GenericError: - tid = None + uid = None - if not fid and not tid: + if not fid and not uid: raise GenericError("Invalid ID") + cover_path = None if fid and Folder.exists(id=eid): - res = get_entity(Folder) - if not res.cover_art or not os.path.isfile( - os.path.join(res.path, res.cover_art) - ): - raise NotFound("Cover art") - cover_path = os.path.join(res.path, res.cover_art) - elif tid and Track.exists(id=eid): - cache_key = "{}-cover".format(eid) - try: - cover_path = cache.get(cache_key) - except CacheMiss: - res = get_entity(Track) - try: - art = mediafile.MediaFile(res.path).art - except mediafile.UnreadableFileError: - raise NotFound("Cover art") - cover_path = cache.set(cache_key, art) + cover_path = _cover_from_collection(get_entity(Folder)) + elif uid and Track.exists(id=eid): + cover_path = _cover_from_track(eid) + elif uid and Album.exists(id=uid): + cover_path = _cover_from_collection(get_entity(Album)) else: raise NotFound("Entity") + if not cover_path: + raise NotFound("Cover art") + size = request.values.get("size") if size: size = int(size) else: - return send_file(cover_path) + # If the cover was extracted from a track it won't have an accurate + # extension for Flask to derive the mimetype from - derive it from the + # contents instead. + mimetype = None + if uid and os.path.splitext(cover_path)[1].lower() not in EXTENSIONS: + with Image.open(cover_path) as im: + mimetype = "image/{}".format(im.format.lower()) + return send_file(cover_path, mimetype=mimetype) with Image.open(cover_path) as im: mimetype = "image/{}".format(im.format.lower()) From 387a5e3de35ab1d565f8108789439871e70321bd Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Thu, 7 Oct 2021 09:44:48 -0400 Subject: [PATCH 075/237] Switch to using `zipstream-ng` to generate and stream zip files - Fixes zip downloads failing when zipping enough data that Zip64 extensions are required by automatically enabling them if needed. - Fixes zip downloads failing when a file has a datestamp that zipfiles cannot store (pre-1980 or post-2108) by clamping them within the supported range. - Massively speeds up zip downloads by disabling compression (audio files generally don't compress well anyway) - Computes the total size of a generated zip file before streaming it and sets the `Content-Length` header. This allows clients to show a final size and progress bar while downloading, as well as detect if the download fails. - Adds a check to prevent sending an empty zip file to the client if there was no content to download (will error out instead). --- setup.py | 2 +- supysonic/api/media.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index d5f4363e..04a7f0e0 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ "requests>=1.0.0", "mediafile", "watchdog>=0.8.0", - "zipstream", + "zipstream-ng>=1.1.0,<2.0.0", ] setup( diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 3a95ed61..7c2a2baa 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -22,8 +22,7 @@ from PIL import Image from pony.orm import ObjectNotFound from xml.etree import ElementTree -from zipfile import ZIP_DEFLATED -from zipstream import ZipFile +from zipstream import ZipStream from ..cache import CacheMiss from ..db import Track, Album, Folder, now @@ -251,16 +250,20 @@ def download_media(): raise NotFound("Folder") # Stream a zip of the tracks + cover art to the client - z = ZipFile(compression=ZIP_DEFLATED) + z = ZipStream(sized=True) for track in rv.tracks: - z.write(track.path, os.path.basename(track.path)) + z.add_path(track.path) cover_path = _cover_from_collection(rv, extract=False) if cover_path: - z.write(cover_path, os.path.basename(cover_path)) + z.add_path(cover_path) + + if not z: + raise GenericError("Nothing to download") resp = Response(z, mimetype="application/zip") resp.headers["Content-Disposition"] = "attachment; filename={}.zip".format(rv.name) + resp.headers["Content-Length"] = len(z) return resp From 359e391fcccbae069dcacc5b3528723b13a7388e Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Thu, 7 Oct 2021 10:22:37 -0400 Subject: [PATCH 076/237] Implement recursive downloading of folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Example use case: ``` Some Album/ ├── CD1 │ └── ├── CD2 │ └── └── cover.jpg ``` Previously, downloading the `Some Album` folder would result in no data being sent (not even `cover.jpg`) This commit changes folder-based downloads so that the entire folder tree (including any non-music) is added to the returned zip file. This allows any included album art, scans, notes, etc. to be distributed with the files. Album-based downloads are unaffected. --- supysonic/api/media.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 7c2a2baa..66e0dfd0 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -249,14 +249,19 @@ def download_media(): except ObjectNotFound: raise NotFound("Folder") - # Stream a zip of the tracks + cover art to the client + # Stream a zip of multiple files to the client z = ZipStream(sized=True) - for track in rv.tracks: - z.add_path(track.path) + if isinstance(rv, Folder): + # Add the entire folder tree to the zip + z.add_path(rv.path, recurse=True) + else: + # Add tracks + cover art to the zip + for track in rv.tracks: + z.add_path(track.path) - cover_path = _cover_from_collection(rv, extract=False) - if cover_path: - z.add_path(cover_path) + cover_path = _cover_from_collection(rv, extract=False) + if cover_path: + z.add_path(cover_path) if not z: raise GenericError("Nothing to download") From f8c3d99e87b43937567222bf46d56fd1d4a0d384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 1 Nov 2021 17:41:56 +0100 Subject: [PATCH 077/237] Embedded server using poorly designed wrappers on some WSGI servers --- setup.py | 2 + supysonic/server/__init__.py | 127 +++++++++++++++++++++++++++++++++++ supysonic/server/__main__.py | 11 +++ supysonic/server/_base.py | 32 +++++++++ supysonic/server/gevent.py | 39 +++++++++++ supysonic/server/gunicorn.py | 53 +++++++++++++++ supysonic/server/waitress.py | 32 +++++++++ tests/issue221.py | 0 8 files changed, 296 insertions(+) create mode 100644 supysonic/server/__init__.py create mode 100644 supysonic/server/__main__.py create mode 100644 supysonic/server/_base.py create mode 100644 supysonic/server/gevent.py create mode 100644 supysonic/server/gunicorn.py create mode 100644 supysonic/server/waitress.py mode change 100755 => 100644 tests/issue221.py diff --git a/setup.py b/setup.py index 04a7f0e0..2bec624f 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ from setuptools import find_packages reqs = [ + "click", "flask>=0.11", "pony>=0.7.6", "Pillow", @@ -37,6 +38,7 @@ "console_scripts": [ "supysonic-cli=supysonic.cli:main", "supysonic-daemon=supysonic.daemon:main", + "supysonic-server=supysonic.server:main" ] }, zip_safe=False, diff --git a/supysonic/server/__init__.py b/supysonic/server/__init__.py new file mode 100644 index 00000000..cafa3f43 --- /dev/null +++ b/supysonic/server/__init__.py @@ -0,0 +1,127 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import importlib +import os +import os.path + +from click import command, option, Option +from click.exceptions import UsageError, ClickException +from click.types import Choice + +from ..web import create_application + +_servers = [ + e.name[:-3] + for e in os.scandir(os.path.dirname(__file__)) + if not e.name.startswith("_") and e.name.endswith(".py") +] + + +class MutuallyExclusiveOption(Option): + def __init__(self, *args, **kwargs): + self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", [])) + help = kwargs.get("help", "") + if self.mutually_exclusive: + ex_str = ", ".join(self.mutually_exclusive) + kwargs[ + "help" + ] = "{} NOTE: This argument is mutually exclusive with arguments: [{}].".format( + help, ex_str + ) + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + if self.mutually_exclusive.intersection(opts) and self.name in opts: + raise UsageError( + "Illegal usage: `{}` is mutually exclusive with arguments `{}`.".format( + self.name, ", ".join(self.mutually_exclusive) + ) + ) + + return super().handle_parse_result(ctx, opts, args) + + +def get_server(name): + return importlib.import_module("." + name, __package__).server + + +def find_first_available_server(): + for module in _servers: + try: + return get_server(module) + except ImportError: + pass + + return None + + +@command() +@option( + "-S", + "--server", + type=Choice(_servers), + help="Specify which WSGI server to use. Pick the first available if none is set", +) +@option( + "-h", + "--host", + default="0.0.0.0", + show_default=True, + help="Hostname or IP address on which to listen", + cls=MutuallyExclusiveOption, + mutually_exclusive=("socket",), +) +@option( + "-p", + "--port", + default=5722, + show_default=True, + help="TCP port on which to listen", + cls=MutuallyExclusiveOption, + mutually_exclusive=("socket",), +) +@option( + "-s", + "--socket", + help="Unix socket on which to bind to, Can't be used with --host and --port", + cls=MutuallyExclusiveOption, + mutually_exclusive=("host", "port"), +) +@option( + "--processes", + type=int, + help="Number of processes to spawn. May not be supported by all servers", +) +@option( + "--threads", + type=int, + help="Number of threads used to process application logic. May not be supported by all servers", +) +def main(server, host, port, socket, processes, threads): + if server is None: + server = find_first_available_server() + if server is None: + raise ClickException( + "Couldn't load any server, please install one of {}".format(_servers) + ) + else: + try: + server = get_server(server) + except ImportError: + raise ClickException( + "Couldn't load {}, please install it first".format(server) + ) + + if socket is not None: + host = None + port = None + + app = create_application() + server( + app, host=host, port=port, socket=socket, processes=processes, threads=threads + ).run() diff --git a/supysonic/server/__main__.py b/supysonic/server/__main__.py new file mode 100644 index 00000000..93153f62 --- /dev/null +++ b/supysonic/server/__main__.py @@ -0,0 +1,11 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from . import main + +if __name__ == "__main__": + main() diff --git a/supysonic/server/_base.py b/supysonic/server/_base.py new file mode 100644 index 00000000..a47791ce --- /dev/null +++ b/supysonic/server/_base.py @@ -0,0 +1,32 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from abc import ABCMeta, abstractmethod + + +class BaseServer(metaclass=ABCMeta): + def __init__( + self, app, *, host=None, port=None, socket=None, processes=None, threads=None + ): + self._app = app + + self._host = host + self._port = port + self._socket = socket + self._processes = processes + self._threads = threads + + @abstractmethod + def _build_kwargs(self): + ... + + @abstractmethod + def _run(self, **kwargs): + ... + + def run(self): + self._run(**self._build_kwargs()) diff --git a/supysonic/server/gevent.py b/supysonic/server/gevent.py new file mode 100644 index 00000000..ba94f721 --- /dev/null +++ b/supysonic/server/gevent.py @@ -0,0 +1,39 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import os +import os.path + +from gevent import socket +from gevent.pywsgi import WSGIServer + +from ._base import BaseServer + + +class GeventServer(BaseServer): + def _build_kwargs(self): + rv = {"application": self._app} + + if self._socket is not None: + if os.path.exists(self._socket): + os.remove(self._socket) + + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(self._socket) + listener.listen() + + rv["listener"] = listener + else: + rv["listener"] = (self._host, self._port) + + return rv + + def _run(self, **kwargs): + return WSGIServer(**kwargs).serve_forever() + + +server = GeventServer diff --git a/supysonic/server/gunicorn.py b/supysonic/server/gunicorn.py new file mode 100644 index 00000000..3e6613d3 --- /dev/null +++ b/supysonic/server/gunicorn.py @@ -0,0 +1,53 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from gunicorn.app.base import BaseApplication + +from ._base import BaseServer + + +class GunicornApp(BaseApplication): + def __init__(self, app, **config): + self.__app = app + self.__config = config + + super().__init__() + + def load(self): + return self.__app + + def load_config(self): + socket = self.__config["socket"] + host = self.__config["host"] + port = self.__config["port"] + processes = self.__config["processes"] + threads = self.__config["threads"] + + if socket is not None: + self.cfg.set("bind", "unix:{}".format(socket)) + else: + self.cfg.set("bind", "{}:{}".format(host, port)) + + if processes is not None: + self.cfg.set("workers", processes) + if threads is not None: + self.cfg.set("threads", threads) + + +class GunicornServer(BaseServer): + def __init__(self, app, **kwargs): + super().__init__(app, **kwargs) + self.__server = GunicornApp(app, **kwargs) + + def _build_kwargs(self): + return {} + + def _run(self, **kwargs): + return self.__server.run() + + +server = GunicornServer diff --git a/supysonic/server/waitress.py b/supysonic/server/waitress.py new file mode 100644 index 00000000..99ded0ce --- /dev/null +++ b/supysonic/server/waitress.py @@ -0,0 +1,32 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from waitress import serve + +from ._base import BaseServer + + +class WaitressServer(BaseServer): + def _build_kwargs(self): + rv = {"app": self._app} + + if self._host is not None: + rv["host"] = self._host + if self._port is not None: + rv["port"] = self._port + if self._socket is not None: + rv["unix_socket"] = self._socket + if self._threads is not None: + rv["threads"] = self._threads + + return rv + + def _run(self, **kwargs): + return serve(**kwargs) + + +server = WaitressServer diff --git a/tests/issue221.py b/tests/issue221.py old mode 100755 new mode 100644 From 91cb3fb179d1e3662602c3be31908170b49102fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 6 Nov 2021 17:45:25 +0100 Subject: [PATCH 078/237] black --- supysonic/api/media.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 66e0dfd0..18622a0f 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -301,7 +301,9 @@ def _cover_from_collection(obj, extract=True): cover_path = os.path.join(obj.path, obj.cover_art) elif isinstance(obj, Album): - track_with_folder_cover = obj.tracks.select(lambda t: t.folder.cover_art is not None).first() + track_with_folder_cover = obj.tracks.select( + lambda t: t.folder.cover_art is not None + ).first() if track_with_folder_cover is not None: cover_path = _cover_from_collection(track_with_folder_cover.folder) From a033d45605fc28cfc5839108c96694d9cb60add7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 6 Nov 2021 17:51:54 +0100 Subject: [PATCH 079/237] Adding doc for the newly added `supysonic-server` --- README.md | 4 +- docs/conf.py | 9 +++- docs/man/index.rst | 7 +++ docs/man/supysonic-cli.rst | 6 +-- docs/man/supysonic-server.rst | 68 ++++++++++++++++++++++++ docs/setup/deploying/index.rst | 24 ++++++++- docs/setup/deploying/wsgi-standalone.rst | 10 ++-- docs/setup/index.rst | 2 +- docs/setup/install.rst | 23 +++++--- supysonic/server/__init__.py | 2 + 10 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 docs/man/supysonic-server.rst diff --git a/README.md b/README.md index 108e0405..a0e7e60a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Full documentation is available at https://supysonic.readthedocs.io/ ## Quickstart Use the following commands to install Supysonic, create an admin user, define a -library folder, scan it and start serving using [Gunicorn][]. +library folder, scan it and start serving on port 5722 using [Gunicorn][]. $ pip install git+https://github.com/spl0k/supysonic.git $ pip install gunicorn @@ -38,7 +38,7 @@ library folder, scan it and start serving using [Gunicorn][]. $ supysonic-cli user setroles --admin MyUserName $ supysonic-cli folder add MyLibrary /home/username/Music $ supysonic-cli folder scan MyLibrary - $ gunicorn -b 0.0.0.0:5000 "supysonic.web:create_application()" + $ supysonic-server You should now be able to enjoy your music with the client of your choice! diff --git a/docs/conf.py b/docs/conf.py index 1f7e81f1..36452ccd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,7 +66,7 @@ ( "man/supysonic-cli", "supysonic-cli", - "Python implementation of the Subsonic server API", + "Supysonic management command line interface", _man_authors, 1, ), @@ -91,4 +91,11 @@ _man_authors, 1, ), + ( + "man/supysonic-server", + "supysonic-server", + "Python implementation of the Subsonic server API", + [author], + 1 + ) ] diff --git a/docs/man/index.rst b/docs/man/index.rst index a5d55bc2..df1b3075 100644 --- a/docs/man/index.rst +++ b/docs/man/index.rst @@ -10,6 +10,13 @@ Man pages supysonic-cli-user supysonic-cli-folder +.. rubric:: Web server + +.. toctree:: + :maxdepth: 2 + + supysonic-server + .. rubric:: Daemon .. toctree:: diff --git a/docs/man/supysonic-cli.rst b/docs/man/supysonic-cli.rst index a19de78c..05313b12 100644 --- a/docs/man/supysonic-cli.rst +++ b/docs/man/supysonic-cli.rst @@ -2,9 +2,9 @@ supysonic-cli ============= ------------------------------------------------- -Python implementation of the Subsonic server API ------------------------------------------------- +------------------------------------------- +Supysonic management command line interface +------------------------------------------- :Author: Louis-Philippe Véronneau, Alban Féron :Date: 2019, 2021 diff --git a/docs/man/supysonic-server.rst b/docs/man/supysonic-server.rst new file mode 100644 index 00000000..6bf657b8 --- /dev/null +++ b/docs/man/supysonic-server.rst @@ -0,0 +1,68 @@ +================ +supysonic-server +================ + +------------------------------------------------ +Python implementation of the Subsonic server API +------------------------------------------------ + +:Author: Alban Féron +:Date: 2021 +:Manual section: 1 + +Synopsis +======== + +``supysonic-server`` [``--server`` ``gevent``\|\ ``gunicorn``\|\ ``waitress``] +[``--host`` `hostname`] [``--port`` `port`] [``--socket`` `path`] +[``--processes`` `n`] [``--threads`` `n`] + +Description +=========== + +``supysonic-server`` is the main Supysonic's component, allowing to serve +content to clients. It is actually a basic wrapper over ``Gevent``, ``Gunicorn`` +or ``Waitress``, requiring at least one of them to be installed to run. + +Options +======= + +-S name, --server name + Specify which WSGI server to use. `name` must be one of ``gevent``, + ``gunicorn`` or ``waitress`` and the matching package must then be installed. + If the option isn't provided, the first one available will be used. + +-h hostname, --host hostname + Hostname or IP address on which to listen. The default is ``0.0.0.0`` which + means to listen on all IPv4 interfaces on this host. + Cannot be used with ``--socket``. + +-p port, --port port + TCP port on which to listen. Default is ``5722``. + Cannot be used with ``--socket``. + +-s path, --socket path + Path of a Unix socket on which to bind to. If a path is specified, a Unix + domain socket is made instead of the usual inet domain socket. + Cannot be used with ``--host`` or ``--port``. + Not available on Windows. + +--processes n + Number of worker processes to spawn. Only applicable when using the + ``Gunicorn`` WSGI server (``--server gunicorn``). + +--threads n + The number of worker threads for handling requests. Only applicable when + using the ``Gunicorn`` or ``Waitress`` WSGI server (``--server gunicorn`` or + ``--server waitress``) + +Bugs +==== + +Bugs can be reported to your distribution's bug tracker or upstream +at https://github.com/spl0k/supysonic/issues. + +See Also +======== + +``supysonic-cli``\ (1) diff --git a/docs/setup/deploying/index.rst b/docs/setup/deploying/index.rst index 79b45020..0d96eceb 100644 --- a/docs/setup/deploying/index.rst +++ b/docs/setup/deploying/index.rst @@ -6,7 +6,29 @@ for the clients to be able to access the music. Here you have several options, whether you want to run it as independant process(es), then possibly putting it behind a reverse proxy, or running it as a WSGI application within Apache. -You'll find some common (and less common) deployment option below: +supysonic-server +^^^^^^^^^^^^^^^^ + +But the easiest might be to use Supysonic's own server. It actually requires a +WSGI server library to run, so you'll first need to have either `Gevent`__, +`Gunicorn`__ or `Waitress`__ to be installed. Then you can start the server with +the following command:: + + supysonic-server + +And it will start to listen on all IPv4 interfaces on port 5722. + +This command allows some options, more details are given on its manpage: +:doc:`/man/supysonic-server`. + +__ https://www.gevent.org +__ https://gunicorn.org/ +__ https://docs.pylonsproject.org/projects/waitress/en/stable/index.html + +Other options +^^^^^^^^^^^^^ + +You'll find some other common (and less common) deployment option below: .. toctree:: :maxdepth: 2 diff --git a/docs/setup/deploying/wsgi-standalone.rst b/docs/setup/deploying/wsgi-standalone.rst index d7fab3df..5c05fc4a 100644 --- a/docs/setup/deploying/wsgi-standalone.rst +++ b/docs/setup/deploying/wsgi-standalone.rst @@ -21,9 +21,9 @@ But this will only listen on the loopback interface, which isn't really useful. Gunicorn provides many command-line options -- see :command:`gunicorn -h`. For example, to run Supysonic with 4 worker processes (``-w 4``) binding to all -IPv4 interfaces on port 5000 (``-b 0.0.0.0:5000``):: +IPv4 interfaces on port 5722 (``-b 0.0.0.0:5722``):: - $ gunicorn -w 4 -b 0.0.0.0:5000 "supysonic.web:create_application()" + $ gunicorn -w 4 -b 0.0.0.0:5722 "supysonic.web:create_application()" __ https://gunicorn.org/ @@ -39,12 +39,12 @@ To use it, install the package ``uwsgi`` with either :command:`pip` or Then to run Supysonic in uWSGI:: - $ uwsgi --http-socket 0.0.0.0:5000 --module "supysonic.web:create_application()" + $ uwsgi --http-socket 0.0.0.0:5722 --module "supysonic.web:create_application()" If it complains about an unknown ``--module`` option, try adding ``--plugin python3``:: - $ uwsgi --http-socket 0.0.0.0:5000 --plugin python3 --module "supysonic.web:create_application()" + $ uwsgi --http-socket 0.0.0.0:5722 --plugin python3 --module "supysonic.web:create_application()" As uWSGI is highly configurable there are several options you could use to tweak it to your liking. Detailing all it can do is way beyond the scope of this @@ -58,4 +58,4 @@ upon). Replace the ``myapp:app`` in their example by double-quotes). __ https://uwsgi-docs.readthedocs.io/en/latest/ -__ https://flask.palletsprojects.com/en/1.1.x/deploying/uwsgi/ +__ https://flask.palletsprojects.com/en/2.0.x/deploying/uwsgi/ diff --git a/docs/setup/index.rst b/docs/setup/index.rst index 610c25fd..e2369aff 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -15,7 +15,7 @@ music is located 😏). This uses `gunicorn`__, but there are pip install git+https://github.com/spl0k/supysonic.git pip install gunicorn - gunicorn -b 0.0.0.0:5000 "supysonic.web:create_application()" + supysonic-server __ https://gunicorn.org/ diff --git a/docs/setup/install.rst b/docs/setup/install.rst index 9482c9e2..027588d5 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -14,12 +14,6 @@ package repositories. Install the package ``supysonic`` using :command:`apt`:: This will install Supysonic along with the minimal dependencies it needs to run. -.. note:: - - As of January 2021, Supysonic only reached Debian's *testing* release. If - you're using the *stable* release it might not be available in the packages - yet. - If you plan on using it with a MySQL or PostgreSQL database you also need the corresponding Python package, ``python-pymysql`` for MySQL or ``python-psycopg2`` for PostgreSQL. @@ -76,8 +70,21 @@ or simply installing directly via :command:`pip`:: $ pip install git+https://github.com/spl0k/supysonic.git -This will install Supysonic along with the minimal dependencies it needs to -run. +This will install Supysonic along with the minimal dependencies it needs, but +those don't include the requirements for the web server. For this you'll need +to install either ``gevent``, ``gunicorn`` or ``waitress``. + +:: + + $ pip install gevent + +:: + + $ pip install gunicorn + +:: + + $ pip install waitress If you plan on using it with a MySQL or PostgreSQL database you also need the corresponding package, ``pymysql`` for MySQL or ``psycopg2-binary`` for diff --git a/supysonic/server/__init__.py b/supysonic/server/__init__.py index cafa3f43..4acc6b43 100644 --- a/supysonic/server/__init__.py +++ b/supysonic/server/__init__.py @@ -103,6 +103,8 @@ def find_first_available_server(): help="Number of threads used to process application logic. May not be supported by all servers", ) def main(server, host, port, socket, processes, threads): + """Starts the Supysonic web server""" + if server is None: server = find_first_available_server() if server is None: From ac690592d6fb435a982484685dd2f041f5b615c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 6 Nov 2021 17:54:28 +0100 Subject: [PATCH 080/237] Also test on Python 3.10 --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 977c37f9..30a2151f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -16,6 +16,7 @@ jobs: - 3.7 - 3.8 - 3.9 + - 3.10 fail-fast: false steps: - name: Checkout From 8e33b374fe6e26c719c3c38471f67b110de71dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 6 Nov 2021 18:04:30 +0100 Subject: [PATCH 081/237] Fix test workflow for Python 3.10 --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 30a2151f..96891377 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -16,7 +16,7 @@ jobs: - 3.7 - 3.8 - 3.9 - - 3.10 + - "3.10" fail-fast: false steps: - name: Checkout From f4bfc735e8d680508a7af7f39080ba034bd41881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Thu, 11 Nov 2021 16:47:37 +0100 Subject: [PATCH 082/237] Rewriting the CLI using click rather than cmd+argparse --- docs/man/supysonic-cli-folder.rst | 5 + docs/man/supysonic-cli-user.rst | 9 +- docs/man/supysonic-cli.rst | 20 +- supysonic/cli.py | 689 +++++++++++++----------------- supysonic/daemon/client.py | 2 +- tests/base/test_cli.py | 112 +++-- 6 files changed, 380 insertions(+), 457 deletions(-) diff --git a/docs/man/supysonic-cli-folder.rst b/docs/man/supysonic-cli-folder.rst index d41dca00..66f68cf4 100644 --- a/docs/man/supysonic-cli-folder.rst +++ b/docs/man/supysonic-cli-folder.rst @@ -13,6 +13,7 @@ Supysonic folder management commands Synopsis ======== +| ``supysonic-cli folder --help`` | ``supysonic-cli folder list`` | ``supysonic-cli folder add`` `name` `path` | ``supysonic-cli folder delete`` `name` @@ -43,6 +44,10 @@ audio files are located. This allows to list, add, delete and scan the folders. Options ======= +-h, --help + Shows help and exits. Depending on where this option appears it will either list the + available commands or display help for a specific command. + -f, --force Force scan of already known files even if they haven't changed. Might be useful if an update to Supysonic adds new metadata to audio files. diff --git a/docs/man/supysonic-cli-user.rst b/docs/man/supysonic-cli-user.rst index 3803d099..e08af6f8 100644 --- a/docs/man/supysonic-cli-user.rst +++ b/docs/man/supysonic-cli-user.rst @@ -13,10 +13,11 @@ Supysonic user management commands Synopsis ======== +| ``supysonic-cli user --help`` | ``supysonic-cli user list`` | ``supysonic-cli user add`` `user` [``--password`` `password`] [``--email`` `email`] | ``supysonic-cli user delete`` `user` -| ``supysonic-cli user changepass`` `user` `password` +| ``supysonic-cli user changepass`` `user` [``--password`` `password`] | ``supysonic-cli user setroles`` [``--admin``\|\ ``--noadmin``] [``--jukebox``\|\ ``--nojukebox``] `user` | ``supysonic-cli user rename`` `user` `newname` @@ -36,7 +37,7 @@ a new user, delete an existing user, and change their password or roles. ``supysonic-cli user delete`` `user` Delete the user `user`. -``supysonic-cli user changepass`` `user` [`password`] +``supysonic-cli user changepass`` `user` [``--password`` `password`] Change the password of user `user`. Will prompt for the new password if not provided. @@ -49,6 +50,10 @@ a new user, delete an existing user, and change their password or roles. Options ======= +-h, --help + Shows help and exits. Depending on where this option appears it will either list the + available commands or display help for a specific command. + -p password, --password password Specify the user's password upon creation. diff --git a/docs/man/supysonic-cli.rst b/docs/man/supysonic-cli.rst index 05313b12..76cdbf23 100644 --- a/docs/man/supysonic-cli.rst +++ b/docs/man/supysonic-cli.rst @@ -13,8 +13,8 @@ Supysonic management command line interface Synopsis ======== +| ``supysonic-cli --help`` | ``supysonic-cli`` [`subcommand`] -| ``supysonic-cli help`` [`subcommand`] Description =========== @@ -35,18 +35,20 @@ The "Subsonic API" is a set of adhoc standards to browse, stream or download a music collection over HTTP. The command-line interface is an interface allowing administration operations -without the use of the web interface. If ran without arguments, -``supysonic-cli`` will open an interactive prompt, with arguments it will run -a single command and exit. +without the use of the web interface. + +Options +======= + +-h, --help + Shows the help and exits. At top level it only lists the subcommands. To + display the help of a specific subcommand, add the ``--help`` flag *after* + the said subcommand name. Subcommands =========== -``supysonic-cli`` has three different subcommands: - -``help`` [`subcommand`] - When used without argument, displays the list of available subcommands. With - an argument, shows the help and arguments for the given subcommand. +``supysonic-cli`` has two different subcommands: ``user`` `args` ... User management commands diff --git a/supysonic/cli.py b/supysonic/cli.py index 7c8b292e..d22b148c 100644 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -5,15 +5,12 @@ # # Distributed under terms of the GNU AGPLv3 license. -import argparse -import cmd -import getpass -import shlex -import sys +import click import time +from click.exceptions import ClickException from pony.orm import db_session, select -from pony.orm import ObjectNotFound +from pony.orm.core import ObjectNotFound from .config import IniConfig from .daemon.client import DaemonClient @@ -25,8 +22,8 @@ class TimedProgressDisplay: - def __init__(self, stdout, interval=5): - self.__stdout = stdout + def __init__(self, interval=5): + self.__stdout = click.get_text_stream("stdout") self.__interval = interval self.__last_display = 0 self.__last_len = 0 @@ -42,415 +39,333 @@ def __call__(self, name, scanned): self.__last_display = time.time() -class CLIParser(argparse.ArgumentParser): - def error(self, message): - self.print_usage(sys.stderr) - raise RuntimeError(message) - - -class SupysonicCLI(cmd.Cmd): - prompt = "supysonic> " - - def _make_do(self, command): - def method(obj, line): - try: - args = getattr(obj, command + "_parser").parse_args(shlex.split(line)) - except RuntimeError as e: - self.write_error_line(str(e)) - return - - if hasattr(obj.__class__, command + "_subparsers"): - try: - func = getattr(obj, "{}_{}".format(command, args.action)) - except AttributeError: - return obj.default(line) - return func( - **{key: vars(args)[key] for key in vars(args) if key != "action"} - ) - else: - try: - func = getattr(obj, command) - except AttributeError: - return obj.default(line) - return func(**vars(args)) - - return method - - def __init__(self, config, stderr=None, *args, **kwargs): - cmd.Cmd.__init__(self, *args, **kwargs) - - if stderr is not None: - self.stderr = stderr - else: - self.stderr = sys.stderr - - self.__config = config - self.__daemon = DaemonClient(config.DAEMON["socket"]) - - # Generate do_* and help_* methods - for parser_name in filter( - lambda attr: attr.endswith("_parser") and "_" not in attr[:-7], - dir(self.__class__), - ): - command = parser_name[:-7] - - if not hasattr(self.__class__, "do_" + command): - setattr(self.__class__, "do_" + command, self._make_do(command)) - - if hasattr(self.__class__, "do_" + command) and not hasattr( - self.__class__, "help_" + command - ): - setattr( - self.__class__, - "help_" + command, - getattr(self.__class__, parser_name).print_help, - ) - if hasattr(self.__class__, command + "_subparsers"): - for action, subparser in getattr( - self.__class__, command + "_subparsers" - ).choices.items(): - setattr( - self, "help_{} {}".format(command, action), subparser.print_help - ) - - def write_line(self, line=""): - self.stdout.write(line + "\n") - - def write_error_line(self, line=""): - self.stderr.write(line + "\n") - - def do_EOF(self, line): - return True - - do_exit = do_EOF - - def default(self, line): - self.write_line("Unknown command %s" % line.split()[0]) - self.do_help(None) - - def postloop(self): - self.write_line() - - def completedefault(self, text, line, begidx, endidx): - command = line.split()[0] - parsers = getattr(self.__class__, command + "_subparsers", None) - if not parsers: - return [] - - num_words = len(line[len(command) : begidx].split()) - if num_words == 0: - return [a for a in parsers.choices if a.startswith(text)] - return [] - - folder_parser = CLIParser(prog="folder", add_help=False) - folder_subparsers = folder_parser.add_subparsers(dest="action") - folder_subparsers.add_parser("list", help="Lists folders", add_help=False) - folder_add_parser = folder_subparsers.add_parser( - "add", help="Adds a folder", add_help=False - ) - folder_add_parser.add_argument("name", help="Name of the folder to add") - folder_add_parser.add_argument( - "path", help="Path to the directory pointed by the folder" - ) - folder_del_parser = folder_subparsers.add_parser( - "delete", help="Deletes a folder", add_help=False - ) - folder_del_parser.add_argument("name", help="Name of the folder to delete") - folder_scan_parser = folder_subparsers.add_parser( - "scan", help="Run a scan on specified folders", add_help=False - ) - folder_scan_parser.add_argument( - "folders", - metavar="folder", - nargs="*", - help="Folder(s) to be scanned. If ommitted, all folders are scanned", - ) - folder_scan_parser.add_argument( - "-f", - "--force", - action="store_true", - help="Force scan of already know files even if they haven't changed", - ) - folder_scan_target_group = folder_scan_parser.add_mutually_exclusive_group() - folder_scan_target_group.add_argument( - "--background", - action="store_true", - help="Scan the folder(s) in the background. Requires the daemon to be running.", - ) - folder_scan_target_group.add_argument( - "--foreground", - action="store_true", - help="Scan the folder(s) in the foreground, blocking the processus while the scan is running.", - ) - - @db_session - def folder_list(self): - self.write_line("Name\t\tPath\n----\t\t----") - self.write_line( - "\n".join( - "{: <16}{}".format(f.name, f.path) - for f in Folder.select(lambda f: f.root) - ) - ) - - @db_session - def folder_add(self, name, path): - try: - FolderManager.add(name, path) - self.write_line("Folder '{}' added".format(name)) - except ValueError as e: - self.write_error_line(str(e)) - - @db_session - def folder_delete(self, name): - try: - FolderManager.delete_by_name(name) - self.write_line("Deleted folder '{}'".format(name)) - except ObjectNotFound as e: - self.write_error_line(str(e)) - - def folder_scan(self, folders, force, background, foreground): - auto = not background and not foreground - if auto: - try: - self.__folder_scan_background(folders, force) - except DaemonUnavailableError: - self.write_error_line( - "Couldn't connect to the daemon, scanning in foreground" - ) - self.__folder_scan_foreground(folders, force) - elif background: - try: - self.__folder_scan_background(folders, force) - except DaemonUnavailableError: - self.write_error_line( - "Couldn't connect to the daemon, please use the '--foreground' option" - ) - elif foreground: - self.__folder_scan_foreground(folders, force) - - def __folder_scan_background(self, folders, force): - self.__daemon.scan(folders, force) - - def __folder_scan_foreground(self, folders, force): +@click.group() +def cli(): + """Supysonic management command line interface""" + pass + + +@cli.group() +def folder(): + """Folder management commands""" + pass + + +@folder.command("list") +@db_session +def folder_list(): + """Lists folders.""" + + click.echo("Name\t\tPath\n----\t\t----") + for f in Folder.select(lambda f: f.root): + click.echo("{: <16}{}".format(f.name, f.path)) + + +@folder.command("add") +@click.argument("name") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True), +) +@db_session +def folder_add(name, path): + """Adds a folder. + + NAME can be anything but must be unique. + PATH must point to an existing readable directory on the filesystem. + + If the daemon is running it will start to listen for changes in this folder but will + not scan files already present in the folder. + """ + + try: + FolderManager.add(name, path) + click.echo("Folder '{}' added".format(name)) + except ValueError as e: + raise ClickException(str(e)) from e + + +@folder.command("delete") +@click.argument("name") +@db_session +def folder_delete(name): + """Deletes a folder. + + NAME is the name of the folder to delete. + """ + + try: + FolderManager.delete_by_name(name) + click.echo("Deleted folder '{}'".format(name)) + except ObjectNotFound as e: + raise ClickException("Folder '{}' does not exist.".format(name)) from e + + +@folder.command("scan") +@click.argument( + "folder", + nargs=-1, +) +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + help="Force scan of already known files even if they haven't changed", +) +@click.option( + "--background", + "mode", + flag_value="background", + help="Scan the folder(s) in the background. Requires the daemon to be running.", +) +@click.option( + "--foreground", + "mode", + flag_value="foreground", + help="Scan the folder(s) in the foreground, blocking the processus while the scan is running.", +) +@click.pass_obj +def folder_scan(config, folder, force, mode): + """Run a scan on specified folders. + + FOLDER is the name of the folder to scan. Multiple can be specified. If ommitted, + all folders are scanned. + """ + + daemon = DaemonClient(config.DAEMON["socket"]) + + # quick and dirty shorthand calls + scan_bg = lambda: daemon.scan(folder, force) + scan_fg = lambda: _folder_scan_foreground(config, daemon, folder, force) + + auto = not mode + if auto: try: - progress = self.__daemon.get_scanning_progress() - if progress is not None: - self.write_error_line( - "The daemon is currently scanning, can't start a scan now" - ) - return + scan_bg() except DaemonUnavailableError: - pass - - extensions = self.__config.BASE["scanner_extensions"] - if extensions: - extensions = extensions.split(" ") - - scanner = Scanner( - force=force, - extensions=extensions, - follow_symlinks=self.__config.BASE["follow_symlinks"], - progress=TimedProgressDisplay(self.stdout), - on_folder_start=self.__unwatch_folder, - on_folder_end=self.__watch_folder, - ) - - if folders: - fstrs = folders - with db_session: - folders = select(f.name for f in Folder if f.root and f.name in fstrs)[ - : - ] - notfound = set(fstrs) - set(folders) - if notfound: - self.write_line("No such folder(s): " + " ".join(notfound)) - for folder in folders: - scanner.queue_folder(folder) - else: - with db_session: - for folder in select(f.name for f in Folder if f.root): - scanner.queue_folder(folder) - - scanner.run() - stats = scanner.stats() - - self.write_line("\nScanning done") - self.write_line( - "Added: {0.artists} artists, {0.albums} albums, {0.tracks} tracks".format( - stats.added + click.echo( + "Couldn't connect to the daemon, scanning in foreground", err=True ) - ) - self.write_line( - "Deleted: {0.artists} artists, {0.albums} albums, {0.tracks} tracks".format( - stats.deleted + scan_fg() + elif mode == "background": + try: + scan_bg() + except DaemonUnavailableError as e: + raise ClickException( + "Couldn't connect to the daemon, please use the '--foreground' option", + ) from e + elif mode == "foreground": + scan_fg() + + +def _folder_scan_foreground(config, daemon, folders, force): + try: + progress = daemon.get_scanning_progress() + if progress is not None: + raise ClickException( + "The daemon is currently scanning, can't start a scan now" ) - ) - if stats.errors: - self.write_line("Errors in:") - for err in stats.errors: - self.write_line("- " + err) + except DaemonUnavailableError: + pass + + extensions = config.BASE["scanner_extensions"] + if extensions: + extensions = extensions.split(" ") - def __unwatch_folder(self, folder): + def unwatch_folder(folder): try: - self.__daemon.remove_watched_folder(folder.path) + daemon.remove_watched_folder(folder.path) except DaemonUnavailableError: pass - def __watch_folder(self, folder): + def watch_folder(folder): try: - self.__daemon.add_watched_folder(folder.path) + daemon.add_watched_folder(folder.path) except DaemonUnavailableError: pass - user_parser = CLIParser(prog="user", add_help=False) - user_subparsers = user_parser.add_subparsers(dest="action") - user_subparsers.add_parser("list", help="List users", add_help=False) - user_add_parser = user_subparsers.add_parser( - "add", help="Adds a user", add_help=False - ) - user_add_parser.add_argument("name", help="Name/login of the user to add") - user_add_parser.add_argument( - "-p", "--password", help="Specifies the user's password" - ) - user_add_parser.add_argument( - "-e", "--email", default="", help="Sets the user's email address" - ) - user_del_parser = user_subparsers.add_parser( - "delete", help="Deletes a user", add_help=False - ) - user_del_parser.add_argument("name", help="Name/login of the user to delete") - user_roles_parser = user_subparsers.add_parser( - "setroles", help="Enable/disable rights for a user", add_help=False + scanner = Scanner( + force=force, + extensions=extensions, + follow_symlinks=config.BASE["follow_symlinks"], + progress=TimedProgressDisplay(), + on_folder_start=unwatch_folder, + on_folder_end=watch_folder, ) - user_roles_parser.add_argument( - "name", help="Name/login of the user to grant/revoke admin rights" - ) - user_roles_admin_group = user_roles_parser.add_mutually_exclusive_group() - user_roles_admin_group.add_argument( - "-A", "--admin", action="store_true", help="Grant admin rights" - ) - user_roles_admin_group.add_argument( - "-a", "--noadmin", action="store_true", help="Revoke admin rights" - ) - user_roles_jukebox_group = user_roles_parser.add_mutually_exclusive_group() - user_roles_jukebox_group.add_argument( - "-J", "--jukebox", action="store_true", help="Grant jukebox rights" - ) - user_roles_jukebox_group.add_argument( - "-j", "--nojukebox", action="store_true", help="Revoke jukebox rights" - ) - user_pass_parser = user_subparsers.add_parser( - "changepass", help="Changes a user's password", add_help=False - ) - user_pass_parser.add_argument( - "name", help="Name/login of the user to which change the password" + + if folders: + fstrs = folders + with db_session: + folders = select(f.name for f in Folder if f.root and f.name in fstrs)[:] + notfound = set(fstrs) - set(folders) + if notfound: + click.echo("No such folder(s): " + " ".join(notfound)) + for folder in folders: + scanner.queue_folder(folder) + else: + with db_session: + for folder in select(f.name for f in Folder if f.root): + scanner.queue_folder(folder) + + scanner.run() + stats = scanner.stats() + + click.echo("\nScanning done") + click.echo( + "Added: {0.artists} artists, {0.albums} albums, {0.tracks} tracks".format( + stats.added + ) ) - user_pass_parser.add_argument("password", nargs="?", help="New password") - user_rename_parser = user_subparsers.add_parser( - "rename", help="Rename a user", add_help=False + click.echo( + "Deleted: {0.artists} artists, {0.albums} albums, {0.tracks} tracks".format( + stats.deleted + ) ) - user_rename_parser.add_argument("name", help="Name of the user to rename") - user_rename_parser.add_argument("newname", help="New name for the user") - - @db_session - def user_list(self): - self.write_line("Name\t\tAdmin\tJukebox\tEmail") - self.write_line("----\t\t-----\t-------\t-----") - self.write_line( - "\n".join( - "{: <16}{}\t{}\t{}".format( - u.name, "*" if u.admin else "", "*" if u.jukebox else "", u.mail - ) - for u in User.select() + if stats.errors: + click.echo("Errors in:") + for err in stats.errors: + click.echo("- " + err) + + +@cli.group("user") +def user(): + """User management commands""" + pass + + +@user.command("list") +@db_session +def user_list(): + """Lists users.""" + + click.echo("Name\t\tAdmin\tJukebox\tEmail") + click.echo("----\t\t-----\t-------\t-----") + for u in User.select(): + click.echo( + "{: <16}{}\t{}\t{}".format( + u.name, "*" if u.admin else "", "*" if u.jukebox else "", u.mail ) ) - def _ask_password(self): # pragma: nocover - password = getpass.getpass() - confirm = getpass.getpass("Confirm password: ") - if password != confirm: - raise ValueError("Passwords don't match") - return password - @db_session - def user_add(self, name, password, email): - try: - if not password: - password = self._ask_password() # pragma: nocover - UserManager.add(name, password, mail=email) - except ValueError as e: - self.write_error_line(str(e)) - - @db_session - def user_delete(self, name): - try: - UserManager.delete_by_name(name) - self.write_line("Deleted user '{}'".format(name)) - except ObjectNotFound as e: - self.write_error_line(str(e)) - - @db_session - def user_setroles(self, name, admin, noadmin, jukebox, nojukebox): - user = User.get(name=name) - if user is None: - self.write_error_line("No such user") - else: - if admin: - user.admin = True - self.write_line("Granted '{}' admin rights".format(name)) - elif noadmin: - user.admin = False - self.write_line("Revoked '{}' admin rights".format(name)) - if jukebox: - user.jukebox = True - self.write_line("Granted '{}' jukebox rights".format(name)) - elif nojukebox: - user.jukebox = False - self.write_line("Revoked '{}' jukebox rights".format(name)) - - @db_session - def user_changepass(self, name, password): - try: - if not password: - password = self._ask_password() # pragma: nocover - UserManager.change_password2(name, password) - self.write_line("Successfully changed '{}' password".format(name)) - except ObjectNotFound as e: - self.write_error_line(str(e)) +@user.command("add") +@click.argument("name") +@click.password_option("-p", "--password", help="Specifies the user's password") +@click.option("-e", "--email", default="", help="Sets the user's email address") +@db_session +def user_add(name, password, email): + """Adds a new user. + + NAME is the name (or login) of the new user. + """ + + try: + UserManager.add(name, password, mail=email) + except ValueError as e: + raise ClickException(str(e)) from e + + +@user.command("delete") +@click.argument("name") +@db_session +def user_delete(name): + """Deletes a user. + + NAME is the name of the user to delete. + """ + + try: + UserManager.delete_by_name(name) + click.echo("Deleted user '{}'".format(name)) + except ObjectNotFound as e: + raise ClickException("User '{}' does not exist.".format(name)) from e - @db_session - def user_rename(self, name, newname): - if not name or not newname: - self.write_error_line("Missing user current name or new name") - return - if name == newname: - return +def _echo_role_change(username, name, value): + click.echo( + "{} '{}' {} rights".format("Granted" if value else "Revoked", username, name) + ) + + +@user.command("setroles") +@click.argument("name") +@click.option( + "-A/-a", "--admin/--noadmin", default=None, help="Grant or revoke admin rights" +) +@click.option( + "-J/-j", + "--jukebox/--nojukebox", + default=None, + help="Grant or revoke jukebox rights", +) +@db_session +def user_roles(name, admin, jukebox): + """Enable/disable rights for a user. + + NAME is the login of the user to which grant or revoke rights. + """ + + user = User.get(name=name) + if user is None: + raise ClickException("No such user") + + if admin is not None: + user.admin = admin + _echo_role_change(name, "admin", admin) + if jukebox is not None: + user.jukebox = jukebox + _echo_role_change(name, "jukebox", jukebox) + + +@user.command("changepass") +@click.argument("name") +@click.password_option("-p", "--password", help="New password") +@db_session +def user_changepass(name, password): + """Changes a user's password. + + NAME is the login of the user to which change the password. + """ - user = User.get(name=name) - if user is None: - self.write_error_line("No such user") - return + try: + UserManager.change_password2(name, password) + click.echo("Successfully changed '{}' password".format(name)) + except ObjectNotFound as e: + raise ClickException("User '{}' does not exist.".format(name)) from e - if User.get(name=newname) is not None: - self.write_error_line("This name is already taken") - return - user.name = newname - self.write_line("User '{}' renamed to '{}'".format(name, newname)) +@user.command("rename") +@click.argument("name") +@click.argument("newname") +@db_session +def user_rename(name, newname): + """Renames a user. + + User NAME will then be known as NEWNAME. + """ + + if not name or not newname: + raise ClickException("Missing user current name or new name") + + if name == newname: + return + + user = User.get(name=name) + if user is None: + raise ClickException("No such user") + + if User.get(name=newname) is not None: + raise ClickException("This name is already taken") + + user.name = newname + click.echo("User '{}' renamed to '{}'".format(name, newname)) def main(): config = IniConfig.from_common_locations() init_database(config.BASE["database_uri"]) - - cli = SupysonicCLI(config) - if len(sys.argv) > 1: - cli.onecmd(" ".join(shlex.quote(arg) for arg in sys.argv[1:])) - else: - cli.cmdloop() - + cli.main(obj=config) release_database() diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index 46540b82..666a187a 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -159,7 +159,7 @@ def get_scanning_progress(self): return c.recv().scanned def scan(self, folders=[], force=False): - if not isinstance(folders, list): + if not isinstance(folders, (list, tuple)): raise TypeError("Expecting list, got " + str(type(folders))) with self.__get_connection() as c: c.send(ScannerStartCommand(folders, force)) diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index f72ca3f7..51e055ff 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -10,37 +10,39 @@ import shlex import unittest -from io import StringIO +from click.testing import CliRunner from pony.orm import db_session from supysonic.db import Folder, User, init_database, release_database -from supysonic.cli import SupysonicCLI +from supysonic.cli import cli from ..testbase import TestConfig class CLITestCase(unittest.TestCase): - """ Really basic tests. Some even don't check anything but are just there for coverage """ + """Really basic tests. Some even don't check anything but are just there for coverage""" def setUp(self): - conf = TestConfig(False, False) + self.__conf = TestConfig(False, False) self.__db = tempfile.mkstemp() - conf.BASE["database_uri"] = "sqlite:///" + self.__db[1] - init_database(conf.BASE["database_uri"]) + self.__conf.BASE["database_uri"] = "sqlite:///" + self.__db[1] + init_database(self.__conf.BASE["database_uri"]) - self.__stdout = StringIO() - self.__stderr = StringIO() - self.__cli = SupysonicCLI(conf, stdout=self.__stdout, stderr=self.__stderr) + self.__runner = CliRunner() def tearDown(self): - self.__stdout.close() - self.__stderr.close() release_database() os.close(self.__db[0]) os.remove(self.__db[1]) - def __add_folder(self, name, path): - self.__cli.onecmd("folder add {} {}".format(name, shlex.quote(path))) + def __invoke(self, cmd, expect_fail=False): + rv = self.__runner.invoke(cli, shlex.split(cmd), obj=self.__conf) + func = self.assertNotEqual if expect_fail else self.assertEqual + func(rv.exit_code, 0) + return rv + + def __add_folder(self, name, path, expect_fail=False): + self.__invoke("folder add {} {}".format(name, shlex.quote(path)), expect_fail) def test_folder_add(self): with tempfile.TemporaryDirectory() as d: @@ -54,10 +56,10 @@ def test_folder_add(self): def test_folder_add_errors(self): with tempfile.TemporaryDirectory() as d: self.__add_folder("f1", d) - self.__add_folder("f2", d) + self.__add_folder("f2", d, True) with tempfile.TemporaryDirectory() as d: - self.__add_folder("f1", d) - self.__cli.onecmd("folder add f3 /invalid/path") + self.__add_folder("f1", d, True) + self.__invoke("folder add f3 /invalid/path", True) with db_session: self.assertEqual(Folder.select().count(), 1) @@ -65,8 +67,8 @@ def test_folder_add_errors(self): def test_folder_delete(self): with tempfile.TemporaryDirectory() as d: self.__add_folder("tmpfolder", d) - self.__cli.onecmd("folder delete randomfolder") - self.__cli.onecmd("folder delete tmpfolder") + self.__invoke("folder delete randomfolder", True) + self.__invoke("folder delete tmpfolder") with db_session: self.assertEqual(Folder.select().count(), 0) @@ -74,94 +76,88 @@ def test_folder_delete(self): def test_folder_list(self): with tempfile.TemporaryDirectory() as d: self.__add_folder("tmpfolder", d) - self.__cli.onecmd("folder list") - self.assertIn("tmpfolder", self.__stdout.getvalue()) - self.assertIn(d, self.__stdout.getvalue()) + rv = self.__invoke("folder list") + self.assertIn("tmpfolder", rv.output) + self.assertIn(d, rv.output) def test_folder_scan(self): with tempfile.TemporaryDirectory() as d: self.__add_folder("tmpfolder", d) with tempfile.NamedTemporaryFile(dir=d): - self.__cli.onecmd("folder scan") - self.__cli.onecmd("folder scan tmpfolder nonexistent") + self.__invoke("folder scan") + self.__invoke("folder scan tmpfolder nonexistent") def test_user_add(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user add -p alice alice") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user add -p alice alice", True) with db_session: self.assertEqual(User.select().count(), 1) def test_user_delete(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user delete alice") - self.__cli.onecmd("user delete bob") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user delete alice") + self.__invoke("user delete bob", True) with db_session: self.assertEqual(User.select().count(), 0) def test_user_list(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user list") - self.assertIn("alice", self.__stdout.getvalue()) + self.__invoke("user add -p Alic3 alice") + rv = self.__invoke("user list") + self.assertIn("alice", rv.output) def test_user_setadmin(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user setroles -A alice") - self.__cli.onecmd("user setroles -A bob") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user setroles -A alice") + self.__invoke("user setroles -A bob", True) with db_session: self.assertTrue(User.get(name="alice").admin) def test_user_unsetadmin(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user setroles -A alice") - self.__cli.onecmd("user setroles -a alice") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user setroles -A alice") + self.__invoke("user setroles -a alice") with db_session: self.assertFalse(User.get(name="alice").admin) def test_user_setjukebox(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user setroles -J alice") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user setroles -J alice") with db_session: self.assertTrue(User.get(name="alice").jukebox) def test_user_unsetjukebox(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user setroles -J alice") - self.__cli.onecmd("user setroles -j alice") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user setroles -J alice") + self.__invoke("user setroles -j alice") with db_session: self.assertFalse(User.get(name="alice").jukebox) def test_user_changepass(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user changepass alice newpass") - self.__cli.onecmd("user changepass bob B0b") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user changepass alice -p newpass") + self.__invoke("user changepass bob -p B0b", True) def test_user_rename(self): - self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user rename alice alice") - self.__cli.onecmd("user rename bob charles") + self.__invoke("user add -p Alic3 alice") + self.__invoke("user rename alice alice") + self.__invoke("user rename bob charles", True) - self.__cli.onecmd("user rename alice ''") + self.__invoke("user rename alice ''", True) with db_session: self.assertEqual(User.select().first().name, "alice") - self.__cli.onecmd("user rename alice bob") + self.__invoke("user rename alice bob") with db_session: self.assertEqual(User.select().first().name, "bob") - self.__cli.onecmd("user add -p Ch4rl3s charles") - self.__cli.onecmd("user rename bob charles") + self.__invoke("user add -p Ch4rl3s charles") + self.__invoke("user rename bob charles", True) with db_session: self.assertEqual(User.select(lambda u: u.name == "bob").count(), 1) self.assertEqual(User.select(lambda u: u.name == "charles").count(), 1) - def test_other(self): - self.assertTrue(self.__cli.do_EOF("")) - self.__cli.onecmd("unknown command") - self.__cli.postloop() - self.__cli.completedefault("user", "user", 4, 4) - if __name__ == "__main__": unittest.main() From a88e261a8d0bba2d66f96bdbbc96b1eea15ce62c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 12 Nov 2021 11:31:03 +0100 Subject: [PATCH 083/237] Remove old stuff --- MANIFEST.in | 1 - bin/supysonic-cli | 18 ------------------ bin/supysonic-watcher | 18 ------------------ cgi-bin/server.py | 20 -------------------- cgi-bin/supysonic.cgi | 15 --------------- cgi-bin/supysonic.fcgi | 15 --------------- cgi-bin/supysonic.wsgi | 9 --------- supysonic-daemon.service | 11 ----------- 8 files changed, 107 deletions(-) delete mode 100755 bin/supysonic-cli delete mode 100755 bin/supysonic-watcher delete mode 100644 cgi-bin/server.py delete mode 100755 cgi-bin/supysonic.cgi delete mode 100755 cgi-bin/supysonic.fcgi delete mode 100644 cgi-bin/supysonic.wsgi delete mode 100644 supysonic-daemon.service diff --git a/MANIFEST.in b/MANIFEST.in index 6492ed78..a861f768 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -include cgi-bin/* include config.sample include README.md recursive-include supysonic/schema * diff --git a/bin/supysonic-cli b/bin/supysonic-cli deleted file mode 100755 index 4b2bd50f..00000000 --- a/bin/supysonic-cli +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env python - -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2017 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -import warnings -from supysonic.cli import main - -if __name__ == "__main__": - warnings.warn( - "You're using an old version of the `supysonic-cli` script. " - "It should have been replaced on install." - ) - main() diff --git a/bin/supysonic-watcher b/bin/supysonic-watcher deleted file mode 100755 index 40e2dcce..00000000 --- a/bin/supysonic-watcher +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env python - -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2014-2019 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -import warnings -from supysonic.daemon import main - -if __name__ == "__main__": - warnings.warn( - "You're using an old version of the `supysonic-watcher` script.\nNo worries " - "though, it will still work (for some time), but you should call `supysonic-daemon` instead." - ) - main() diff --git a/cgi-bin/server.py b/cgi-bin/server.py deleted file mode 100644 index e0985bbf..00000000 --- a/cgi-bin/server.py +++ /dev/null @@ -1,20 +0,0 @@ -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2013 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -from supysonic.web import create_application - -app = create_application() - -if __name__ == "__main__": - if app: - import sys - - app.run( - host=sys.argv[1] if len(sys.argv) > 1 else None, - port=int(sys.argv[2]) if len(sys.argv) > 2 else 5000, - debug=True, - ) diff --git a/cgi-bin/supysonic.cgi b/cgi-bin/supysonic.cgi deleted file mode 100755 index b44b1e2b..00000000 --- a/cgi-bin/supysonic.cgi +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python -# -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2013 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -from wsgiref.handlers import CGIHandler -from supysonic.web import create_application - -app = create_application() -if app: - CGIHandler().run(app) diff --git a/cgi-bin/supysonic.fcgi b/cgi-bin/supysonic.fcgi deleted file mode 100755 index 0241a6ac..00000000 --- a/cgi-bin/supysonic.fcgi +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python -# -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2013 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -from flup.server.fcgi import WSGIServer -from supysonic.web import create_application - -app = create_application() -if app: - WSGIServer(app, bindAddress = "/path/to/fcgi.sock").run() diff --git a/cgi-bin/supysonic.wsgi b/cgi-bin/supysonic.wsgi deleted file mode 100644 index f25309c6..00000000 --- a/cgi-bin/supysonic.wsgi +++ /dev/null @@ -1,9 +0,0 @@ -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2013 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -from supysonic.web import create_application -application = create_application() diff --git a/supysonic-daemon.service b/supysonic-daemon.service deleted file mode 100644 index a07b4273..00000000 --- a/supysonic-daemon.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Supysonic Daemon - -[Service] -User=supysonic -Group=supysonic -WorkingDirectory=/home/supysonic -ExecStart=/usr/bin/env python -m supysonic.daemon - -[Install] -WantedBy=multi-user.target From 64475a8dfa3983efa7dc89b1f270f18f9ed191f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 12 Nov 2021 12:22:16 +0100 Subject: [PATCH 084/237] Small update on WSGI containers doc --- docs/setup/deploying/wsgi-standalone.rst | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/setup/deploying/wsgi-standalone.rst b/docs/setup/deploying/wsgi-standalone.rst index 5c05fc4a..a47d0737 100644 --- a/docs/setup/deploying/wsgi-standalone.rst +++ b/docs/setup/deploying/wsgi-standalone.rst @@ -25,13 +25,21 @@ IPv4 interfaces on port 5722 (``-b 0.0.0.0:5722``):: $ gunicorn -w 4 -b 0.0.0.0:5722 "supysonic.web:create_application()" +.. note:: + + While :command:`gunicorn` provides way more options to configure its + behaviour than :command:`supysonic-server` will ever do, the above example is + actually equivalent to:: + + $ supysonic-server -S gunicorn --processes 4 + __ https://gunicorn.org/ uWSGI ----- `uWSGI`__ is a fast application server written in C. It is very configurable -which makes it more complicated to setup than gunicorn. +which makes it more complicated to setup than Gunicorn. To use it, install the package ``uwsgi`` with either :command:`pip` or :command:`apt`. Using the later, wou might also need the additional package @@ -59,3 +67,27 @@ double-quotes). __ https://uwsgi-docs.readthedocs.io/en/latest/ __ https://flask.palletsprojects.com/en/2.0.x/deploying/uwsgi/ + +Waitress +======== + +`Waitress`__ is meant to be a production-quality pure-Python WSGI server with +very acceptable performance. It has no dependencies except ones which live in +the Python standard library. + +As for Gunicorn, using it to run Supysonic is rather simple. Install it using +either :command:`pip install waitress` or +:command:`apt install python3-waitress`. Then start the server this way:: + + $ waitress-serve --call supysonic.web:create_application + +Waitress behaviour can be tuned through various command-line options -- see +:command:`waitress-serve --help`. If none of them are relevant to you, +:command:`supysonic-server` can actually be used instead:: + + $ supysonic-server -S waitress + +Both commands are equivalent, with the only difference being the port they +listen on. + +__ https://docs.pylonsproject.org/projects/waitress/en/stable/index.html From 8ab9f444b7daafc4cd8cc699dbf1ea32371f0236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 14 Nov 2021 18:08:22 +0100 Subject: [PATCH 085/237] Rationalizing/modernizing building stuff Metadata in `setup.cfg` rather than `pyproject.toml` as I'm a bit confused about `setuptools` support for PEP-621. Test stuff still in `setup.py`, this needs updating and I'm not satisfied with the way they are loaded/discovered. --- docs/conf.py | 12 ++++--- pyproject.toml | 3 ++ setup.cfg | 74 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 61 ++++------------------------------- supysonic/__init__.py | 20 +++--------- 5 files changed, 95 insertions(+), 75 deletions(-) create mode 100644 pyproject.toml create mode 100644 setup.cfg diff --git a/docs/conf.py b/docs/conf.py index 36452ccd..a28cadbe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,11 +1,13 @@ +import supysonic + # -- Project information ----------------------------------------------------- -project = "Supysonic" -author = "Alban Féron" +project = supysonic.NAME +author = supysonic.AUTHOR copyright = "2013-2021, " + author -version = "0.6.3" -release = "0.6.3" +version = supysonic.VERSION +release = supysonic.VERSION # -- General configuration --------------------------------------------------- @@ -26,7 +28,7 @@ html_theme = "alabaster" html_theme_options = { - "description": "A Python implementation of the Subsonic server API", + "description": supysonic.DESCRIPTION, "github_user": "spl0k", "github_repo": "supysonic", } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..3607e0fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=51.0.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..e028f076 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,74 @@ +[metadata] +name = Supysonic +version = attr: supysonic.VERSION +url = https://supysonic.readthedocs.io +download_url = https://github.com/spl0k/supysonic +author = Alban Féron +author_email = alban.feron@gmail.com +license = GNU AGPLv3 +license_files = LICENSE + +description = Python implementation of the Subsonic server API +long_description = + Supysonic is a Python implementation of the [Subsonic][] server API. + + Current supported features are: + * browsing (by folders or tags) + * streaming of various audio file formats + * transcoding + * user or random playlists + * cover art + * starred tracks/albums and ratings + * [Last.FM][lastfm] scrobbling + * Jukebox mode + + Supysonic currently targets the version 1.10.2 of the Subsonic API. For more + details, go check the [API implementation status][docs-api]. + + [subsonic]: http://www.subsonic.org/ + [lastfm]: https://www.last.fm/ + [docs-api]: https://supysonic.readthedocs.io/en/latest/api.html + +long_description_content_type = text/markdown +keywords = subsonic, music, server + +classifiers = + Development Status :: 3 - Alpha + Environment :: Console + Environment :: Web Environment + Framework :: Flask + Intended Audience :: End Users/Desktop + Intended Audience :: System Administrators + License :: OSI Approved :: GNU Affero General Public License v3 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: Multimedia :: Sound/Audio + +[options] +python_requires = >=3.5,<3.10 +install_requires = + click + flask >=0.11 + pony >=0.7.6 + Pillow + requests >=1.0.0 + mediafile + watchdog >=0.8.0 + zipstream-ng >=1.1.0, <2.0.0 + +packages = find: +include_package_data = true +zip_safe = false + +[options.packages.find] +include = supysonic* + +[options.entry_points] +console_scripts = + supysonic-cli = supysonic.cli:main + supysonic-daemon = supysonic.daemon:main + supysonic-server = supysonic.server:mai diff --git a/setup.py b/setup.py index 2bec624f..c043717b 100644 --- a/setup.py +++ b/setup.py @@ -1,64 +1,15 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2021 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. -import supysonic as project - from setuptools import setup -from setuptools import find_packages - -reqs = [ - "click", - "flask>=0.11", - "pony>=0.7.6", - "Pillow", - "requests>=1.0.0", - "mediafile", - "watchdog>=0.8.0", - "zipstream-ng>=1.1.0,<2.0.0", -] -setup( - name=project.NAME, - version=project.VERSION, - description=project.DESCRIPTION, - keywords=project.KEYWORDS, - long_description=project.LONG_DESCRIPTION, - author=project.AUTHOR_NAME, - author_email=project.AUTHOR_EMAIL, - url=project.URL, - license=project.LICENSE, - packages=find_packages(exclude=["tests*"]), - install_requires=reqs, - entry_points={ - "console_scripts": [ - "supysonic-cli=supysonic.cli:main", - "supysonic-daemon=supysonic.daemon:main", - "supysonic-server=supysonic.server:main" - ] - }, - zip_safe=False, - include_package_data=True, - test_suite="tests.suite", - tests_require=["lxml"], - classifiers=[ - "Development Status :: 3 - Alpha", - "Environment :: Console", - "Environment :: Web Environment", - "Framework :: Flask", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: GNU Affero General Public License v3", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Topic :: Multimedia :: Sound/Audio", - ], -) +if __name__ == "__main__": + setup( + test_suite="tests.suite", + tests_require=["lxml"], + ) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 69046472..01e32d52 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -6,21 +6,11 @@ # # Distributed under terms of the GNU AGPLv3 license. -NAME = "supysonic" +NAME = "Supysonic" VERSION = "0.6.3" -DESCRIPTION = "Python implementation of the Subsonic server API." -KEYWORDS = "subsonic music api" -AUTHOR_NAME = "Alban Féron" +DESCRIPTION = "Python implementation of the Subsonic server API" +AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" -URL = "https://github.com/spl0k/supysonic" +URL = "https://supysonic.readthedocs.io/" +DOWNLOAD_URL = "https://github.com/spl0k/supysonic" LICENSE = "GNU AGPLv3" -LONG_DESCRIPTION = """Supysonic is a Python implementation of the Subsonic server API. -Current supported features are: -* browsing (by folders or tags) -* streaming of various audio file formats -* transcoding -* user or random playlists -* cover art (cover.jpg files in the same folder as music files) -* starred tracks/albums and ratings -* Last.FM scrobbling -* Jukebox mode""" From b4e737c2435739199aaaa39790d520cea86fe6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 20 Nov 2021 21:01:00 +0100 Subject: [PATCH 086/237] Use test discovery rather than explicit suites --- .github/workflows/tests.yaml | 6 ++--- README.md | 12 ++++++---- setup.py | 5 +---- tests/__init__.py | 36 +++++------------------------- tests/api/__init__.py | 36 ------------------------------ tests/api/test_response_helper.py | 10 --------- tests/base/__init__.py | 24 -------------------- tests/base/test_watcher.py | 9 -------- tests/frontend/__init__.py | 18 --------------- tests/managers/__init__.py | 14 ------------ tests/net/__init__.py | 28 +++++++++++++++++++++++ tests/{base => net}/test_lastfm.py | 2 +- tests/{api => net}/test_lyrics.py | 2 +- tests/with_net.py | 20 ----------------- 14 files changed, 48 insertions(+), 174 deletions(-) create mode 100644 tests/net/__init__.py rename tests/{base => net}/test_lastfm.py (90%) rename tests/{api => net}/test_lyrics.py (98%) delete mode 100644 tests/with_net.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 96891377..0868352a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -16,7 +16,7 @@ jobs: - 3.7 - 3.8 - 3.9 - - "3.10" + #- "3.10" fail-fast: false steps: - name: Checkout @@ -31,8 +31,8 @@ jobs: pip install -r ci-requirements.txt - name: Run tests run: | - coverage run setup.py test - coverage run -a setup.py test --test-suite tests.with_net + coverage run -m unittest + coverage run -a -m unittest tests.net.suite - name: Upload coverage uses: codecov/codecov-action@v1.0.15 if: ${{ !cancelled() }} diff --git a/README.md b/README.md index a0e7e60a..9666659f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Current supported features are: * user or random playlists * cover art * starred tracks/albums and ratings -* [Last.FM][lastfm] scrobbling +* [Last.fm][lastfm] scrobbling * Jukebox mode Supysonic currently targets the version 1.10.2 of the Subsonic API. For more @@ -65,9 +65,13 @@ in-browser debugging among other things. To start said server: $ export FLASK_ENV=development $ flask run -And there's also the tests: +And there's also the tests (which require `lxml` to run): - $ python setup.py test - $ python setup.py test --test-suite tests.with_net + $ pip install lxml + $ python -m unittest + $ python -m unittest tests.net.suite + +The last command runs a few tests that make HTTP requests to remote third-party +services (namely Last.fm and ChartLyrics). [flask]: https://flask.palletsprojects.com/ diff --git a/setup.py b/setup.py index c043717b..e51e2d7b 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,4 @@ from setuptools import setup if __name__ == "__main__": - setup( - test_suite="tests.suite", - tests_require=["lxml"], - ) + setup() diff --git a/tests/__init__.py b/tests/__init__.py index 3ea79525..aedbb987 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,35 +6,11 @@ # # Distributed under terms of the GNU AGPLv3 license. -import unittest +import os.path -from . import base -from . import managers -from . import api -from . import frontend -from .issue85 import Issue85TestCase -from .issue101 import Issue101TestCase -from .issue129 import Issue129TestCase -from .issue133 import Issue133TestCase -from .issue139 import Issue139TestCase -from .issue148 import Issue148TestCase -from .issue221 import Issue221TestCase - - -def suite(): - suite = unittest.TestSuite() - - suite.addTest(base.suite()) - suite.addTest(managers.suite()) - suite.addTest(api.suite()) - suite.addTest(frontend.suite()) - suite.addTest(unittest.makeSuite(Issue85TestCase)) - suite.addTest(unittest.makeSuite(Issue101TestCase)) - suite.addTest(unittest.makeSuite(Issue129TestCase)) - suite.addTest(unittest.makeSuite(Issue133TestCase)) - suite.addTest(unittest.makeSuite(Issue139TestCase)) - suite.addTest(unittest.makeSuite(Issue148TestCase)) - suite.addTest(unittest.makeSuite(Issue221TestCase)) - - return suite +def load_tests(loader, tests, pattern): + this_dir = os.path.dirname(__file__) + tests.addTests(loader.discover(start_dir=this_dir, pattern="test*.py")) + tests.addTests(loader.discover(start_dir=this_dir, pattern="issue*.py")) + return tests diff --git a/tests/api/__init__.py b/tests/api/__init__.py index b67211d1..227addb1 100644 --- a/tests/api/__init__.py +++ b/tests/api/__init__.py @@ -4,39 +4,3 @@ # Copyright (C) 2017 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. - -import unittest - -from .test_response_helper import suite as rh_suite -from .test_api_setup import ApiSetupTestCase -from .test_system import SystemTestCase -from .test_user import UserTestCase -from .test_chat import ChatTestCase -from .test_search import SearchTestCase -from .test_playlist import PlaylistTestCase -from .test_browse import BrowseTestCase -from .test_album_songs import AlbumSongsTestCase -from .test_annotation import AnnotationTestCase -from .test_media import MediaTestCase -from .test_transcoding import TranscodingTestCase -from .test_radio import RadioStationTestCase - - -def suite(): - suite = unittest.TestSuite() - - suite.addTest(rh_suite()) - suite.addTest(unittest.makeSuite(ApiSetupTestCase)) - suite.addTest(unittest.makeSuite(SystemTestCase)) - suite.addTest(unittest.makeSuite(UserTestCase)) - suite.addTest(unittest.makeSuite(ChatTestCase)) - suite.addTest(unittest.makeSuite(SearchTestCase)) - suite.addTest(unittest.makeSuite(PlaylistTestCase)) - suite.addTest(unittest.makeSuite(BrowseTestCase)) - suite.addTest(unittest.makeSuite(AlbumSongsTestCase)) - suite.addTest(unittest.makeSuite(AnnotationTestCase)) - suite.addTest(unittest.makeSuite(MediaTestCase)) - suite.addTest(unittest.makeSuite(TranscodingTestCase)) - suite.addTest(unittest.makeSuite(RadioStationTestCase)) - - return suite diff --git a/tests/api/test_response_helper.py b/tests/api/test_response_helper.py index 78b0b548..695b480b 100644 --- a/tests/api/test_response_helper.py +++ b/tests/api/test_response_helper.py @@ -194,15 +194,5 @@ def test_nesting(self): self.assertEqual(lists[2].text, "final string") -def suite(): - suite = unittest.TestSuite() - - suite.addTest(unittest.makeSuite(ResponseHelperJsonTestCase)) - suite.addTest(unittest.makeSuite(ResponseHelperJsonpTestCase)) - suite.addTest(unittest.makeSuite(ResponseHelperXMLTestCase)) - - return suite - - if __name__ == "__main__": unittest.main() diff --git a/tests/base/__init__.py b/tests/base/__init__.py index 3633340a..227addb1 100644 --- a/tests/base/__init__.py +++ b/tests/base/__init__.py @@ -4,27 +4,3 @@ # Copyright (C) 2017 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. - -import unittest - -from .test_cli import CLITestCase -from .test_cache import CacheTestCase -from .test_config import ConfigTestCase -from .test_db import DbTestCase -from .test_scanner import ScannerTestCase -from .test_secret import SecretTestCase -from .test_watcher import suite as watcher_suite - - -def suite(): - suite = unittest.TestSuite() - - suite.addTest(unittest.makeSuite(CacheTestCase)) - suite.addTest(unittest.makeSuite(ConfigTestCase)) - suite.addTest(unittest.makeSuite(DbTestCase)) - suite.addTest(unittest.makeSuite(ScannerTestCase)) - suite.addTest(watcher_suite()) - suite.addTest(unittest.makeSuite(CLITestCase)) - suite.addTest(unittest.makeSuite(SecretTestCase)) - - return suite diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index 7720d5fa..afbe7287 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -344,14 +344,5 @@ def test_add_track_to_empty_folder(self): self._sleep() -def suite(): - suite = unittest.TestSuite() - - suite.addTest(unittest.makeSuite(AudioWatcherTestCase)) - suite.addTest(unittest.makeSuite(CoverWatcherTestCase)) - - return suite - - if __name__ == "__main__": unittest.main() diff --git a/tests/frontend/__init__.py b/tests/frontend/__init__.py index 24bb0bbe..227addb1 100644 --- a/tests/frontend/__init__.py +++ b/tests/frontend/__init__.py @@ -4,21 +4,3 @@ # Copyright (C) 2017 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. - -import unittest - -from .test_login import LoginTestCase -from .test_folder import FolderTestCase -from .test_playlist import PlaylistTestCase -from .test_user import UserTestCase - - -def suite(): - suite = unittest.TestSuite() - - suite.addTest(unittest.makeSuite(LoginTestCase)) - suite.addTest(unittest.makeSuite(FolderTestCase)) - suite.addTest(unittest.makeSuite(PlaylistTestCase)) - suite.addTest(unittest.makeSuite(UserTestCase)) - - return suite diff --git a/tests/managers/__init__.py b/tests/managers/__init__.py index efe761bb..4c9aa068 100644 --- a/tests/managers/__init__.py +++ b/tests/managers/__init__.py @@ -5,17 +5,3 @@ # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. - -import unittest - -from .test_manager_folder import FolderManagerTestCase -from .test_manager_user import UserManagerTestCase - - -def suite(): - suite = unittest.TestSuite() - - suite.addTest(unittest.makeSuite(FolderManagerTestCase)) - suite.addTest(unittest.makeSuite(UserManagerTestCase)) - - return suite diff --git a/tests/net/__init__.py b/tests/net/__init__.py new file mode 100644 index 00000000..dbfd0d0a --- /dev/null +++ b/tests/net/__init__.py @@ -0,0 +1,28 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import importlib +import os +import os.path +import unittest + +from unittest.suite import TestSuite + + +def load_tests(loader, tests, pattern): + # Skip these tests from discovery + return tests + + +suite = TestSuite() +for e in os.scandir(os.path.dirname(__file__)): + if not e.name.startswith("test") or not e.name.endswith(".py"): + continue + + module = importlib.import_module("tests.net." + e.name[:-3]) + tests = unittest.defaultTestLoader.loadTestsFromModule(module) + suite.addTests(tests) diff --git a/tests/base/test_lastfm.py b/tests/net/test_lastfm.py similarity index 90% rename from tests/base/test_lastfm.py rename to tests/net/test_lastfm.py index dfa72b86..72c63551 100644 --- a/tests/base/test_lastfm.py +++ b/tests/net/test_lastfm.py @@ -12,7 +12,7 @@ class LastFmTestCase(unittest.TestCase): - """ Designed only to have coverage on the most important method """ + """Designed only to have coverage on the most important method""" def test_request(self): logging.getLogger("supysonic.lastfm").addHandler(logging.NullHandler()) diff --git a/tests/api/test_lyrics.py b/tests/net/test_lyrics.py similarity index 98% rename from tests/api/test_lyrics.py rename to tests/net/test_lyrics.py index c1cfea00..158fddfb 100644 --- a/tests/api/test_lyrics.py +++ b/tests/net/test_lyrics.py @@ -14,7 +14,7 @@ from supysonic.db import Folder, Artist, Album, Track -from .apitestbase import ApiTestBase +from ..api.apitestbase import ApiTestBase class LyricsTestCase(ApiTestBase): diff --git a/tests/with_net.py b/tests/with_net.py deleted file mode 100644 index 64a268c7..00000000 --- a/tests/with_net.py +++ /dev/null @@ -1,20 +0,0 @@ -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2019 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -import unittest - -from .api.test_lyrics import LyricsTestCase -from .base.test_lastfm import LastFmTestCase - - -def suite(): - suite = unittest.TestSuite() - - suite.addTest(unittest.makeSuite(LastFmTestCase)) - suite.addTest(unittest.makeSuite(LyricsTestCase)) - - return suite From 8f5a9a3b734024a7b2b072c144ffba0cb2a80b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 21 Nov 2021 11:43:28 +0100 Subject: [PATCH 087/237] Properly close connections when killing the daemon --- supysonic/daemon/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index 989825c0..fe780127 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -69,6 +69,8 @@ def __listen(self): conn = self.__listener.accept() self.__handle_connection(conn) + self.__listener.close() + def start_scan(self, folders=[], force=False): if not folders: with db_session: From 3b0023e1ac0a04e82969530bf8abbb8bac868188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 21 Nov 2021 12:20:48 +0100 Subject: [PATCH 088/237] Test tweaks --- tests/api/test_scan.py | 2 +- tests/base/test_watcher.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/api/test_scan.py b/tests/api/test_scan.py index 84e3167b..63ecc281 100644 --- a/tests/api/test_scan.py +++ b/tests/api/test_scan.py @@ -45,7 +45,7 @@ def tearDown(self): super().tearDown() def test_startScan(self): - rv, child = self._make_request("startScan", tag="scanStatus") + rv, child = self._make_request("startScan", tag="scanStatus", skip_post=True) self.assertEqual(child.get("scanning"), "true") self.assertGreaterEqual(int(child.get("count")), 0) diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index afbe7287..d1ff35e2 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -111,11 +111,10 @@ def test_add(self): self._sleep() self.assertTrackCountEqual(1) - # This test now fails and I don't understand why - # def test_add_nowait_stop(self): - # self._addfile() - # self._stop() - # self.assertTrackCountEqual(1) + def test_add_nowait_stop(self): + self._addfile() + self._stop() + self.assertTrackCountEqual(1) def test_add_multiple(self): self._addfile() From 56fe87af970ddcc2ea428c0c3d66bba6406a6eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 21 Nov 2021 17:51:44 +0100 Subject: [PATCH 089/237] Supysonic is going to PyPI, update the docs accordingly --- README.md | 2 +- docs/setup/deploying/wsgi-standalone.rst | 2 +- docs/setup/index.rst | 2 +- docs/setup/install.rst | 25 ++++++++---------------- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9666659f..22ee71a4 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Full documentation is available at https://supysonic.readthedocs.io/ Use the following commands to install Supysonic, create an admin user, define a library folder, scan it and start serving on port 5722 using [Gunicorn][]. - $ pip install git+https://github.com/spl0k/supysonic.git + $ pip install supysonic $ pip install gunicorn $ supysonic-cli user add MyUserName $ supysonic-cli user setroles --admin MyUserName diff --git a/docs/setup/deploying/wsgi-standalone.rst b/docs/setup/deploying/wsgi-standalone.rst index a47d0737..1d245043 100644 --- a/docs/setup/deploying/wsgi-standalone.rst +++ b/docs/setup/deploying/wsgi-standalone.rst @@ -69,7 +69,7 @@ __ https://uwsgi-docs.readthedocs.io/en/latest/ __ https://flask.palletsprojects.com/en/2.0.x/deploying/uwsgi/ Waitress -======== +-------- `Waitress`__ is meant to be a production-quality pure-Python WSGI server with very acceptable performance. It has no dependencies except ones which live in diff --git a/docs/setup/index.rst b/docs/setup/index.rst index e2369aff..04c71c55 100644 --- a/docs/setup/index.rst +++ b/docs/setup/index.rst @@ -13,7 +13,7 @@ music is located 😏). This uses `gunicorn`__, but there are :: - pip install git+https://github.com/spl0k/supysonic.git + pip install supysonic pip install gunicorn supysonic-server diff --git a/docs/setup/install.rst b/docs/setup/install.rst index 027588d5..afcca5ac 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -26,8 +26,8 @@ corresponding Python package, ``python-pymysql`` for MySQL or $ apt install python-psycopg2 -For other distributions, you might consider installing from :ref:`docker` images -or from `source`_. +For other distributions, you might consider installing with `pip`_ or from +:ref:`docker` images. Windows ------- @@ -49,26 +49,19 @@ Guide to Python's`__ Python on Windows installation guides. You must install `Python 3`__. Once Python is installed, you can install Supysonic using :command:`pip`. Refer -to the `source installation instructions `_ below for more information. +to the `installation instructions `_ below for more information. __ https://docs.python-guide.org/ __ https://docs.python-guide.org/starting/install3/win/ -.. _source: +.. _pip: -Source ------- +pip +--- -You can install Supysonic directly from a clone of the `Git repository`__. This -can be done either by cloning the repo and installing from the local clone:: +Simply install the package ``supysonic`` with :command:`pip`:: - $ git clone https://github.com/spl0k/supysonic.git - $ cd supysonic - $ pip install . - -or simply installing directly via :command:`pip`:: - - $ pip install git+https://github.com/spl0k/supysonic.git + $ pip install supysonic This will install Supysonic along with the minimal dependencies it needs, but those don't include the requirements for the web server. For this you'll need @@ -97,5 +90,3 @@ PostgreSQL. :: $ pip install psycopg2-binary - -__ https://github.com/spl0k/supysonic From ddb7c6966c84eb8e24437e3860d321297295341e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 21 Nov 2021 17:54:08 +0100 Subject: [PATCH 090/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 01e32d52..aeebfa07 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.6.3" +VERSION = "0.7.0" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From e8f4b2fbc2e1f16e8283920d8305264e1680476c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 21 Nov 2021 20:58:42 +0100 Subject: [PATCH 091/237] Fix typo in setup file --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index e028f076..050bbf9c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -71,4 +71,4 @@ include = supysonic* console_scripts = supysonic-cli = supysonic.cli:main supysonic-daemon = supysonic.daemon:main - supysonic-server = supysonic.server:mai + supysonic-server = supysonic.server:main From 632b1bc8351aa2660e9e7d690ebf4bc8bb9a8056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 28 Nov 2021 12:15:55 +0100 Subject: [PATCH 092/237] Fixed tests Come to think of it, I wonder how they could ever work --- tests/testbase.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/testbase.py b/tests/testbase.py index e22eb4df..df0654e5 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -7,7 +7,9 @@ import inspect import os +import os.path import shutil +import sys import tempfile import unittest @@ -46,6 +48,12 @@ def __init__(self, with_webui, with_api): self.WEBAPP.update({"mount_webui": with_webui, "mount_api": with_api}) + with tempfile.NamedTemporaryFile() as tf: + if sys.platform == "win32": + self.DAEMON["socket"] = "\\\\.\\pipe\\" + os.path.basename(tf.name) + else: + self.DAEMON["socket"] = tf.name + class MockResponse: def __init__(self, response): From 98ff73738d606b401de620c09926ee55ec5e45b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 28 Nov 2021 14:50:02 +0100 Subject: [PATCH 093/237] Drop Python 3.5 --- .github/workflows/tests.yaml | 1 - README.md | 2 +- docs/setup/install.rst | 14 ++++++++------ setup.cfg | 3 +-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0868352a..292fab4c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -11,7 +11,6 @@ jobs: strategy: matrix: python-version: - - 3.5 - 3.6 - 3.7 - 3.8 diff --git a/README.md b/README.md index 22ee71a4..10756788 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Supysonic is a Python implementation of the [Subsonic][] server API. ![Build Status](https://github.com/spl0k/supysonic/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) -![Python](https://img.shields.io/badge/python-3.5--3.9-blue.svg) +![Python](https://img.shields.io/badge/python-3.6--3.9-blue.svg) Current supported features are: * browsing (by folders or tags) diff --git a/docs/setup/install.rst b/docs/setup/install.rst index afcca5ac..d5ff6059 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -1,7 +1,8 @@ Installing Supysonic ==================== -Supysonic is written in Python and supports Python 3.5+. +Supysonic is written in Python and supports Python 3.6 through 3.9. Python 3.10 +and later are not yet supported. Linux ----- @@ -44,15 +45,16 @@ the installation of Python itself. To check if you already have Python installed, open the *Command Prompt* (:kbd:`Win-R` and type :command:`cmd`). Once the command prompt is open, type :command:`python --version` and press Enter. If Python is installed, you will see the version of Python printed to -the screen. If you do not have Python installed, refer to the `Hitchhikers -Guide to Python's`__ Python on Windows installation guides. You must install -`Python 3`__. +the screen. If you do not have Python installed, head over to the `Python +website`__ and install one of the `compatible Python versions`__. You need at +least Python 3.6, but you can go up to the latest 3.9. Once Python is installed, you can install Supysonic using :command:`pip`. Refer to the `installation instructions `_ below for more information. -__ https://docs.python-guide.org/ -__ https://docs.python-guide.org/starting/install3/win/ +__ https://www.python.org/ +__ https://www.python.org/downloads/windows/ + .. _pip: diff --git a/setup.cfg b/setup.cfg index 050bbf9c..430be0d7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,6 @@ classifiers = Intended Audience :: System Administrators License :: OSI Approved :: GNU Affero General Public License v3 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 @@ -49,7 +48,7 @@ classifiers = Topic :: Multimedia :: Sound/Audio [options] -python_requires = >=3.5,<3.10 +python_requires = >=3.6,<3.10 install_requires = click flask >=0.11 From c3f911b3f41af1bb032ea643112b8fc402fc493e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 28 Nov 2021 15:13:33 +0100 Subject: [PATCH 094/237] Display version and proper URL on web UI --- supysonic/frontend/__init__.py | 8 +++++++- supysonic/templates/layout.html | 10 +++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index 27ad49c0..764d9c44 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2021 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -19,6 +19,7 @@ from functools import wraps from pony.orm import ObjectNotFound +from .. import VERSION, DOWNLOAD_URL from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError from ..db import Artist, Album, Track @@ -27,6 +28,11 @@ frontend = Blueprint("frontend", __name__) +@frontend.context_processor +def inject_metadata(): + return {"version": VERSION, "download_url": DOWNLOAD_URL} + + @frontend.before_request def login_check(): request.user = None diff --git a/supysonic/templates/layout.html b/supysonic/templates/layout.html index b91cf05d..c382c3fb 100644 --- a/supysonic/templates/layout.html +++ b/supysonic/templates/layout.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2021 Alban 'spl0k' Féron 2017 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -86,10 +86,10 @@
From 799bfa3ddefec9cfb78d513a2faed4c246c8c5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 28 Nov 2021 17:15:05 +0100 Subject: [PATCH 095/237] Code style --- supysonic/api/__init__.py | 2 +- supysonic/api/albums_songs.py | 78 ++++++------ supysonic/api/annotation.py | 4 +- supysonic/api/browse.py | 65 +++++----- supysonic/api/chat.py | 2 +- supysonic/api/exceptions.py | 5 +- supysonic/api/formatters.py | 6 +- supysonic/api/jukebox.py | 12 +- supysonic/api/media.py | 16 +-- supysonic/api/playlists.py | 8 +- supysonic/api/radio.py | 6 +- supysonic/api/scan.py | 16 +-- supysonic/api/search.py | 30 ++--- supysonic/api/system.py | 2 +- supysonic/api/user.py | 4 +- supysonic/db.py | 220 +++++++++++++++++----------------- supysonic/frontend/folder.py | 4 +- supysonic/frontend/user.py | 16 +-- supysonic/lastfm.py | 4 +- supysonic/scanner.py | 9 +- supysonic/watcher.py | 8 +- tests/api/test_playlist.py | 2 +- 22 files changed, 262 insertions(+), 257 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 3794c264..ddbdf9e6 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -36,7 +36,7 @@ def decorator(func): @api.before_request def set_formatter(): """Return a function to create the response.""" - f, callback = map(request.values.get, ["f", "callback"]) + f, callback = map(request.values.get, ("f", "callback")) if f == "jsonp": request.formatter = JSONPFormatter(callback) elif f == "json": diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 2fc5e583..f71295bf 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -29,7 +29,7 @@ def rand_songs(): size = request.values.get("size", "10") genre, fromYear, toYear, musicFolderId = map( - request.values.get, ["genre", "fromYear", "toYear", "musicFolderId"] + request.values.get, ("genre", "fromYear", "toYear", "musicFolderId") ) size = int(size) if size else 10 @@ -57,12 +57,12 @@ def rand_songs(): return request.formatter( "randomSongs", - dict( - song=[ + { + "song": [ t.as_subsonic_child(request.user, request.client) for t in query.without_distinct().random(size) ] - ), + }, ) @@ -70,7 +70,7 @@ def rand_songs(): def album_list(): ltype = request.values["type"] - size, offset = map(request.values.get, ["size", "offset"]) + size, offset = map(request.values.get, ("size", "offset")) size = int(size) if size else 10 offset = int(offset) if offset else 0 @@ -78,12 +78,12 @@ def album_list(): if ltype == "random": return request.formatter( "albumList", - dict( - album=[ + { + "album": [ a.as_subsonic_child(request.user) for a in distinct(query.random(size)) ] - ), + }, ) elif ltype == "newest": query = query.sort_by(desc(Folder.created)).distinct() @@ -123,9 +123,11 @@ def album_list(): return request.formatter( "albumList", - dict( - album=[f.as_subsonic_child(request.user) for f in query.limit(size, offset)] - ), + { + "album": [ + f.as_subsonic_child(request.user) for f in query.limit(size, offset) + ] + }, ) @@ -133,7 +135,7 @@ def album_list(): def album_list_id3(): ltype = request.values["type"] - size, offset = map(request.values.get, ["size", "offset"]) + size, offset = map(request.values.get, ("size", "offset")) size = int(size) if size else 10 offset = int(offset) if offset else 0 @@ -141,7 +143,7 @@ def album_list_id3(): if ltype == "random": return request.formatter( "albumList2", - dict(album=[a.as_subsonic_album(request.user) for a in query.random(size)]), + {"album": [a.as_subsonic_album(request.user) for a in query.random(size)]}, ) elif ltype == "newest": query = query.order_by(lambda a: desc(min(a.tracks.created))) @@ -177,9 +179,11 @@ def album_list_id3(): return request.formatter( "albumList2", - dict( - album=[f.as_subsonic_album(request.user) for f in query.limit(size, offset)] - ), + { + "album": [ + f.as_subsonic_album(request.user) for f in query.limit(size, offset) + ] + }, ) @@ -187,14 +191,14 @@ def album_list_id3(): def songs_by_genre(): genre = request.values["genre"] - count, offset = map(request.values.get, ["count", "offset"]) + count, offset = map(request.values.get, ("count", "offset")) count = int(count) if count else 10 offset = int(offset) if offset else 0 query = select(t for t in Track if t.genre == genre).limit(count, offset) return request.formatter( "songsByGenre", - dict(song=[t.as_subsonic_child(request.user, request.client) for t in query]), + {"song": [t.as_subsonic_child(request.user, request.client) for t in query]}, ) @@ -207,17 +211,17 @@ def now_playing(): return request.formatter( "nowPlaying", - dict( - entry=[ - dict( - u.last_play.as_subsonic_child(request.user, request.client), - username=u.name, - minutesAgo=(now() - u.last_play_date).seconds / 60, - playerId=0, - ) + { + "entry": [ + { + **u.last_play.as_subsonic_child(request.user, request.client), + "username": u.name, + "minutesAgo": (now() - u.last_play_date).seconds / 60, + "playerId": 0, + } for u in query ] - ), + }, ) @@ -227,22 +231,22 @@ def get_starred(): return request.formatter( "starred", - dict( - artist=[ + { + "artist": [ sf.as_subsonic_artist(request.user) for sf in folders.filter(lambda f: count(f.tracks) == 0) ], - album=[ + "album": [ sf.as_subsonic_child(request.user) for sf in folders.filter(lambda f: count(f.tracks) > 0) ], - song=[ + "song": [ st.as_subsonic_child(request.user, request.client) for st in select( s.starred for s in StarredTrack if s.user.id == request.user.id ) ], - ), + }, ) @@ -250,24 +254,24 @@ def get_starred(): def get_starred_id3(): return request.formatter( "starred2", - dict( - artist=[ + { + "artist": [ sa.as_subsonic_artist(request.user) for sa in select( s.starred for s in StarredArtist if s.user.id == request.user.id ) ], - album=[ + "album": [ sa.as_subsonic_album(request.user) for sa in select( s.starred for s in StarredAlbum if s.user.id == request.user.id ) ], - song=[ + "song": [ st.as_subsonic_child(request.user, request.client) for st in select( s.starred for s in StarredTrack if s.user.id == request.user.id ) ], - ), + }, ) diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index 41f15496..4b905339 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -54,7 +54,7 @@ def unstar_single(cls, starcls, eid): def handle_star_request(func): - id, albumId, artistId = map(request.values.getlist, ["id", "albumId", "artistId"]) + id, albumId, artistId = map(request.values.getlist, ("id", "albumId", "artistId")) if not id and not albumId and not artistId: raise MissingParameter("id, albumId or artistId") @@ -174,7 +174,7 @@ def rate(): @api_routing("/scrobble") def scrobble(): res = get_entity(Track) - t, submission = map(request.values.get, ["time", "submission"]) + t, submission = map(request.values.get, ("time", "submission")) t = int(t) / 1000 if t else int(time.time()) lfm = LastFm(current_app.config["LASTFM"], request.user) diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index 6e83c271..af3b5204 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -20,12 +20,12 @@ def list_folders(): return request.formatter( "musicFolders", - dict( - musicFolder=[ - dict(id=str(f.id), name=f.name) + { + "musicFolder": [ + {"id": str(f.id), "name": f.name} for f in Folder.select(lambda f: f.root).order_by(Folder.name) ] - ), + }, ) @@ -66,13 +66,14 @@ def list_indexes(): folders = [folder] - last_modif = max(map(lambda f: f.last_scan, folders)) + last_modif = max(f.last_scan for f in folders) if ifModifiedSince is not None and last_modif < ifModifiedSince: return request.formatter( "indexes", - dict( - lastModified=last_modif * 1000, ignoredArticles=ignored_articles_str() - ), + { + "lastModified": last_modif * 1000, + "ignoredArticles": ignored_articles_str(), + }, ) # The XSD lies, we don't return artists but a directory structure @@ -82,7 +83,7 @@ def list_indexes(): artists += f.children.select()[:] children += f.tracks.select()[:] - indexes = dict() + indexes = {} pattern = build_ignored_articles_pattern() for artist in artists: name = artist.name @@ -101,24 +102,24 @@ def list_indexes(): return request.formatter( "indexes", - dict( - lastModified=last_modif * 1000, - ignoredArticles=ignored_articles_str(), - index=[ - dict( - name=k, - artist=[ + { + "lastModified": last_modif * 1000, + "ignoredArticles": ignored_articles_str(), + "index": [ + { + "name": k, + "artist": [ a.as_subsonic_artist(request.user) for a, _ in sorted(v, key=lambda t: t[1].lower()) ], - ) + } for k, v in sorted(indexes.items()) ], - child=[ + "child": [ c.as_subsonic_child(request.user, request.client) for c in sorted(children, key=lambda t: t.sort_key()) ], - ), + }, ) @@ -134,21 +135,21 @@ def show_directory(): def list_genres(): return request.formatter( "genres", - dict( - genre=[ - dict(value=genre, songCount=sc, albumCount=ac) + { + "genre": [ + {"value": genre, "songCount": sc, "albumCount": ac} for genre, sc, ac in select( (t.genre, count(), count(t.album)) for t in Track if t.genre ) ] - ), + }, ) @api_routing("/getArtists") def list_artists(): # According to the API page, there are no parameters? - indexes = dict() + indexes = {} pattern = build_ignored_articles_pattern() for artist in Artist.select(): name = artist.name or "?" @@ -167,19 +168,19 @@ def list_artists(): return request.formatter( "artists", - dict( - ignoredArticles=ignored_articles_str(), - index=[ - dict( - name=k, - artist=[ + { + "ignoredArticles": ignored_articles_str(), + "index": [ + { + "name": k, + "artist": [ a.as_subsonic_artist(request.user) for a, _ in sorted(v, key=lambda t: t[1].lower()) ], - ) + } for k, v in sorted(indexes.items()) ], - ), + }, ) diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index dbe5e244..b5a2be9a 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -21,7 +21,7 @@ def get_chat(): query = query.filter(lambda m: m.time > since) return request.formatter( - "chatMessages", dict(chatMessage=[msg.responsize() for msg in query]) + "chatMessages", {"chatMessage": [msg.responsize() for msg in query]} ) diff --git a/supysonic/api/exceptions.py b/supysonic/api/exceptions.py index 6a1a80e2..48e7a42b 100644 --- a/supysonic/api/exceptions.py +++ b/supysonic/api/exceptions.py @@ -114,11 +114,12 @@ def get_response(self, environ=None): codes = {exc.api_code for exc in self.exceptions} errors = [ - dict(code=exc.api_code, message=exc.message) for exc in self.exceptions + {"code": exc.api_code, "message": exc.message} for exc in self.exceptions ] rv = request.formatter( - "error", dict(code=list(codes)[0] if len(codes) == 1 else 0, error=errors) + "error", + {"code": next(iter(codes)) if len(codes) == 1 else 0, "error": errors}, ) # rv.status_code = self.code return rv diff --git a/supysonic/api/formatters.py b/supysonic/api/formatters.py index 2321b961..c9f63752 100644 --- a/supysonic/api/formatters.py +++ b/supysonic/api/formatters.py @@ -16,7 +16,7 @@ def make_response(self, elem, data): raise NotImplementedError() def make_error(self, code, message): - return self.make_response("error", dict(code=code, message=message)) + return self.make_response("error", {"code": code, "message": message}) def make_empty(self): return self.make_response(None, None) @@ -78,7 +78,7 @@ def __init__(self, callback): def make_response(self, elem, data): if not self.__callback: return jsonify( - self._subsonicify("error", dict(code=10, message="Missing callback")) + self._subsonicify("error", {"code": 10, "message": "Missing callback"}) ) rv = self._subsonicify(elem, data) @@ -100,7 +100,7 @@ def __dict2xml(self, elem, dictionary): """ if not isinstance(dictionary, dict): raise TypeError("Expecting a dict") - if not all(map(lambda x: isinstance(x, str), dictionary)): + if not all(isinstance(x, str) for x in dictionary): raise TypeError("Dictionary keys must be strings") for name, value in dictionary.items(): diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 28a2255a..4dd9972a 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -79,12 +79,12 @@ def jukebox_control(): except DaemonUnavailableError: raise GenericError("Jukebox unavaliable") - rv = dict( - currentIndex=status.index, - playing=status.playing, - gain=status.gain, - position=status.position, - ) + rv = { + "currentIndex": status.index, + "playing": status.playing, + "gain": status.gain, + "position": status.position, + } if action == "get": playlist = [] for path in status.playlist: diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 18622a0f..471cb435 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -73,7 +73,7 @@ def stream_media(): raise UnsupportedParameter("size") maxBitRate, request_format, estimateContentLength = map( - request.values.get, ["maxBitRate", "format", "estimateContentLength"] + request.values.get, ("maxBitRate", "format", "estimateContentLength") ) if request_format: request_format = request_format.lower() @@ -378,7 +378,7 @@ def cover_art(): def lyrics_response_for_track(track, lyrics): return request.formatter( "lyrics", - dict(artist=track.album.artist.name, title=track.title, value=lyrics), + {"artist": track.album.artist.name, "title": track.title, "value": lyrics}, ) @@ -419,7 +419,7 @@ def lyrics(): ).hexdigest() cache_key = "lyrics-{}".format(unique) - lyrics = dict() + lyrics = {} try: lyrics = json.loads( zlib.decompress(current_app.cache.get_value(cache_key)).decode("utf-8") @@ -434,11 +434,11 @@ def lyrics(): root = ElementTree.fromstring(r.content) ns = {"cl": "http://api.chartlyrics.com/"} - lyrics = dict( - artist=root.find("cl:LyricArtist", namespaces=ns).text, - title=root.find("cl:LyricSong", namespaces=ns).text, - value=root.find("cl:Lyric", namespaces=ns).text, - ) + lyrics = { + "artist": root.find("cl:LyricArtist", namespaces=ns).text, + "title": root.find("cl:LyricSong", namespaces=ns).text, + "value": root.find("cl:Lyric", namespaces=ns).text, + } current_app.cache.set( cache_key, zlib.compress(json.dumps(lyrics).encode("utf-8"), 9) diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index 174a7858..6ae90794 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -36,7 +36,7 @@ def list_playlists(): return request.formatter( "playlists", - dict(playlist=[p.as_subsonic_playlist(request.user) for p in query]), + {"playlist": [p.as_subsonic_playlist(request.user) for p in query]}, ) @@ -55,7 +55,7 @@ def show_playlist(): @api_routing("/createPlaylist") def create_playlist(): - playlist_id, name = map(request.values.get, ["playlistId", "name"]) + playlist_id, name = map(request.values.get, ("playlistId", "name")) # songId actually doesn't seem to be required songs = request.values.getlist("songId") playlist_id = uuid.UUID(playlist_id) if playlist_id else None @@ -99,9 +99,9 @@ def update_playlist(): raise Forbidden() playlist = res - name, comment, public = map(request.values.get, ["name", "comment", "public"]) + name, comment, public = map(request.values.get, ("name", "comment", "public")) to_add, to_remove = map( - request.values.getlist, ["songIdToAdd", "songIndexToRemove"] + request.values.getlist, ("songIdToAdd", "songIndexToRemove") ) if name: diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index 8b5aca6b..d4f2e46a 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -18,7 +18,7 @@ def get_radio_stations(): query = RadioStation.select().sort_by(RadioStation.name) return request.formatter( "internetRadioStations", - dict(internetRadioStation=[p.as_subsonic_station() for p in query]), + {"internetRadioStation": [p.as_subsonic_station() for p in query]}, ) @@ -28,7 +28,7 @@ def create_radio_station(): raise Forbidden() stream_url, name, homepage_url = map( - request.values.get, ["streamUrl", "name", "homepageUrl"] + request.values.get, ("streamUrl", "name", "homepageUrl") ) if stream_url and name: @@ -47,7 +47,7 @@ def update_radio_station(): res = get_entity(RadioStation) stream_url, name, homepage_url = map( - request.values.get, ["streamUrl", "name", "homepageUrl"] + request.values.get, ("streamUrl", "name", "homepageUrl") ) if stream_url and name: res.stream_url = stream_url diff --git a/supysonic/api/scan.py b/supysonic/api/scan.py index 3e5ae95c..9dc20094 100644 --- a/supysonic/api/scan.py +++ b/supysonic/api/scan.py @@ -28,10 +28,10 @@ def startScan(): raise ServerError(str(e)) return request.formatter( "scanStatus", - dict( - scanning="true" if scanned is not None else "false", - count=scanned if scanned is not None else 0, - ), + { + "scanning": scanned is not None, + "count": scanned or 0, + }, ) @@ -46,8 +46,8 @@ def getScanStatus(): raise ServerError(str(e)) return request.formatter( "scanStatus", - dict( - scanning="true" if scanned is not None else "false", - count=scanned if scanned is not None else 0, - ), + { + "scanning": scanned is not None, + "count": scanned or 0, + }, ) diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 18a0444d..4091e75d 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -20,7 +20,7 @@ def old_search(): artist, album, title, anyf, count, offset, newer_than = map( request.values.get, - ["artist", "album", "title", "any", "count", "offset", "newerThan"], + ("artist", "album", "title", "any", "count", "offset", "newerThan"), ) count = int(count) if count else 20 @@ -54,32 +54,32 @@ def old_search(): return request.formatter( "searchResult", - dict( - totalHits=folders.count() + tracks.count(), - offset=offset, - match=[ + { + "totalHits": folders.count() + tracks.count(), + "offset": offset, + "match": [ r.as_subsonic_child(request.user) if isinstance(r, Folder) else r.as_subsonic_child(request.user, request.client) for r in res ], - ), + }, ) else: raise MissingParameter("search") return request.formatter( "searchResult", - dict( - totalHits=query.count(), - offset=offset, - match=[ + { + "totalHits": query.count(), + "offset": offset, + "match": [ r.as_subsonic_child(request.user) if isinstance(r, Folder) else r.as_subsonic_child(request.user, request.client) for r in query[offset : offset + count] ], - ), + }, ) @@ -95,14 +95,14 @@ def new_search(): song_offset, ) = map( request.values.get, - [ + ( "artistCount", "artistOffset", "albumCount", "albumOffset", "songCount", "songOffset", - ], + ), ) artist_count = int(artist_count) if artist_count else 20 @@ -147,14 +147,14 @@ def search_id3(): song_offset, ) = map( request.values.get, - [ + ( "artistCount", "artistOffset", "albumCount", "albumOffset", "songCount", "songOffset", - ], + ), ) artist_count = int(artist_count) if artist_count else 20 diff --git a/supysonic/api/system.py b/supysonic/api/system.py index 8bea25d3..341a43c7 100644 --- a/supysonic/api/system.py +++ b/supysonic/api/system.py @@ -18,4 +18,4 @@ def ping(): @api_routing("/getLicense") def license(): - return request.formatter("license", dict(valid=True)) + return request.formatter("license", {"valid": True}) diff --git a/supysonic/api/user.py b/supysonic/api/user.py index ace7c577..df51e882 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -43,7 +43,7 @@ def user_info(): @admin_only def users_info(): return request.formatter( - "users", dict(user=[u.as_subsonic_user() for u in User.select()]) + "users", {"user": [u.as_subsonic_user() for u in User.select()]} ) @@ -107,7 +107,7 @@ def user_edit(): UserManager.change_password2(user, password) email, admin, jukebox = map( - request.values.get, ["email", "adminRole", "jukeboxRole"] + request.values.get, ("email", "adminRole", "jukeboxRole") ) if email is not None: user.mail = email diff --git a/supysonic/db.py b/supysonic/db.py index 3e531bcf..9c8a4fb2 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -92,13 +92,13 @@ class Folder(PathMixin, db.Entity): ratings = Set(lambda: RatingFolder) def as_subsonic_child(self, user): - info = dict( - id=str(self.id), - isDir=True, - title=self.name, - album=self.name, - created=self.created.isoformat(), - ) + info = { + "id": str(self.id), + "isDir": True, + "title": self.name, + "album": self.name, + "created": self.created.isoformat(), + } if not self.root: info["parent"] = str(self.parent.id) info["artist"] = self.parent.name @@ -129,7 +129,7 @@ def as_subsonic_child(self, user): return info def as_subsonic_artist(self, user): # "Artist" type in XSD - info = dict(id=str(self.id), name=self.name) + info = {"id": str(self.id), "name": self.name} try: starred = StarredFolder[user.id, self.id] @@ -140,10 +140,10 @@ def as_subsonic_artist(self, user): # "Artist" type in XSD return info def as_subsonic_directory(self, user, client): # "Directory" type in XSD - info = dict( - id=str(self.id), - name=self.name, - child=[ + info = { + "id": str(self.id), + "name": self.name, + "child": [ f.as_subsonic_child(user) for f in self.children.order_by(lambda c: c.name.lower()) ] @@ -151,7 +151,7 @@ def as_subsonic_directory(self, user, client): # "Directory" type in XSD t.as_subsonic_child(user, client) for t in sorted(self.tracks, key=lambda t: t.sort_key()) ], - ) + } if not self.root: info["parent"] = str(self.parent.id) @@ -183,12 +183,12 @@ class Artist(db.Entity): stars = Set(lambda: StarredArtist) def as_subsonic_artist(self, user): - info = dict( - id=str(self.id), - name=self.name, + info = { + "id": str(self.id), + "name": self.name, # coverArt - albumCount=self.albums.count(), - ) + "albumCount": self.albums.count(), + } try: starred = StarredArtist[user.id, self.id] @@ -217,15 +217,15 @@ class Album(db.Entity): stars = Set(lambda: StarredAlbum) def as_subsonic_album(self, user): # "AlbumID3" type in XSD - info = dict( - id=str(self.id), - name=self.name, - artist=self.artist.name, - artistId=str(self.artist.id), - songCount=self.tracks.count(), - duration=sum(self.tracks.duration), - created=min(self.tracks.created).isoformat(), - ) + info = { + "id": str(self.id), + "name": self.name, + "artist": self.artist.name, + "artistId": str(self.artist.id), + "songCount": self.tracks.count(), + "duration": sum(self.tracks.duration), + "created": min(self.tracks.created).isoformat(), + } track_with_cover = self.tracks.select( lambda t: t.folder.cover_art is not None @@ -253,8 +253,8 @@ def as_subsonic_album(self, user): # "AlbumID3" type in XSD return info def sort_key(self): - year = min(map(lambda t: t.year if t.year else 9999, self.tracks)) - return "%i%s" % (year, self.name.lower()) + year = min(t.year if t.year else 9999 for t in self.tracks) + return f"{year}{self.name.lower()}" @classmethod def prune(cls): @@ -297,27 +297,27 @@ class Track(PathMixin, db.Entity): ratings = Set(lambda: RatingTrack) def as_subsonic_child(self, user, prefs): - info = dict( - id=str(self.id), - parent=str(self.folder.id), - isDir=False, - title=self.title, - album=self.album.name, - artist=self.artist.name, - track=self.number, - size=os.path.getsize(self.path) if os.path.isfile(self.path) else -1, - contentType=self.mimetype, - suffix=self.suffix(), - duration=self.duration, - bitRate=self.bitrate, - path=self.path[len(self.root_folder.path) + 1 :], - isVideo=False, - discNumber=self.disc, - created=self.created.isoformat(), - albumId=str(self.album.id), - artistId=str(self.artist.id), - type="music", - ) + info = { + "id": str(self.id), + "parent": str(self.folder.id), + "isDir": False, + "title": self.title, + "album": self.album.name, + "artist": self.artist.name, + "track": self.number, + "size": os.path.getsize(self.path) if os.path.isfile(self.path) else -1, + "contentType": self.mimetype, + "suffix": self.suffix(), + "duration": self.duration, + "bitRate": self.bitrate, + "path": self.path[len(self.root_folder.path) + 1 :], + "isVideo": False, + "discNumber": self.disc, + "created": self.created.isoformat(), + "albumId": str(self.album.id), + "artistId": str(self.artist.id), + "type": "music", + } if self.year: info["year"] = self.year @@ -362,22 +362,16 @@ def mimetype(self): return mimetypes.guess_type(self.path, False)[0] or "application/octet-stream" def duration_str(self): - ret = "%02i:%02i" % ((self.duration % 3600) / 60, self.duration % 60) + ret = "{:02}:{:02}".format((self.duration % 3600) / 60, self.duration % 60) if self.duration >= 3600: - ret = "%02i:%s" % (self.duration / 3600, ret) + ret = "{:02}:{}".format(self.duration / 3600, ret) return ret def suffix(self): return os.path.splitext(self.path)[1][1:].lower() def sort_key(self): - return ( - self.album.artist.name - + self.album.name - + ("%02i" % self.disc) - + ("%02i" % self.number) - + self.title - ).lower() + return f"{self.album.artist.name}{self.album.name}{self.disc:02}{self.number:02}{self.title}".lower() class User(db.Entity): @@ -412,22 +406,22 @@ class User(db.Entity): track_ratings = Set(lambda: RatingTrack, lazy=True) def as_subsonic_user(self): - return dict( - username=self.name, - email=self.mail, - scrobblingEnabled=self.lastfm_session is not None and self.lastfm_status, - adminRole=self.admin, - settingsRole=True, - downloadRole=True, - uploadRole=False, - playlistRole=True, - coverArtRole=False, - commentRole=False, - podcastRole=False, - streamRole=True, - jukeboxRole=self.admin or self.jukebox, - shareRole=False, - ) + return { + "username": self.name, + "email": self.mail, + "scrobblingEnabled": self.lastfm_session is not None and self.lastfm_status, + "adminRole": self.admin, + "settingsRole": True, + "downloadRole": True, + "uploadRole": False, + "playlistRole": True, + "coverArtRole": False, + "commentRole": False, + "podcastRole": False, + "streamRole": True, + "jukeboxRole": self.admin or self.jukebox, + "shareRole": False, + } class ClientPrefs(db.Entity): @@ -507,9 +501,11 @@ class ChatMessage(db.Entity): message = Required(str, 512) def responsize(self): - return dict( - username=self.user.name, time=self.time * 1000, message=self.message - ) + return { + "username": self.user.name, + "time": self.time * 1000, + "message": self.message, + } class Playlist(db.Entity): @@ -525,17 +521,17 @@ class Playlist(db.Entity): def as_subsonic_playlist(self, user): tracks = self.get_tracks() - info = dict( - id=str(self.id), - name=self.name + info = { + "id": str(self.id), + "name": self.name if self.user.id == user.id else "[{}] {}".format(self.user.name, self.name), - owner=self.user.name, - public=self.public, - songCount=len(tracks), - duration=sum(map(lambda t: t.duration, tracks)), - created=self.created.isoformat(), - ) + "owner": self.user.name, + "public": self.public, + "songCount": len(tracks), + "duration": sum(t.duration for t in tracks), + "created": self.created.isoformat(), + } if self.comment: info["comment"] = self.comment return info @@ -556,7 +552,7 @@ def get_tracks(self): should_fix = True if should_fix: - self.tracks = ",".join(map(lambda t: str(t.id), tracks)) + self.tracks = ",".join(str(t.id) for t in tracks) db.commit() return tracks @@ -597,12 +593,12 @@ class RadioStation(db.Entity): created = Required(datetime, precision=0, default=now) def as_subsonic_station(self): - info = dict( - id=str(self.id), - streamUrl=self.stream_url, - name=self.name, - homePageUrl=self.homepage_url, - ) + info = { + "id": str(self.id), + "streamUrl": self.stream_url, + "name": self.name, + "homePageUrl": self.homepage_url, + } return info @@ -622,28 +618,28 @@ def parse_uri(database_uri): elif path[0] == "/": path = path[1:] - return dict(provider="sqlite", filename=path, create_db=True, **args) + return {"provider": "sqlite", "filename": path, "create_db": True, **args} elif uri.scheme in ("postgres", "postgresql"): - return dict( - provider="postgres", - user=uri.username, - password=uri.password, - host=uri.hostname, - dbname=uri.path[1:], - **args - ) + return { + "provider": "postgres", + "user": uri.username, + "password": uri.password, + "host": uri.hostname, + "dbname": uri.path[1:], + **args, + } elif uri.scheme == "mysql": args.setdefault("charset", "utf8mb4") args.setdefault("binary_prefix", True) - return dict( - provider="mysql", - user=uri.username, - passwd=uri.password, - host=uri.hostname, - db=uri.path[1:], - **args - ) - return dict() + return { + "provider": "mysql", + "user": uri.username, + "passwd": uri.password, + "host": uri.hostname, + "db": uri.path[1:], + **args, + } + return {} def execute_sql_resource_script(respath): diff --git a/supysonic/frontend/folder.py b/supysonic/frontend/folder.py index 1ab340ec..3622a1f8 100644 --- a/supysonic/frontend/folder.py +++ b/supysonic/frontend/folder.py @@ -43,7 +43,7 @@ def add_folder_form(): @admin_only def add_folder_post(): error = False - (name, path) = map(request.form.get, ["name", "path"]) + name, path = map(request.form.get, ("name", "path")) if name in (None, ""): flash("The name is required.") error = True @@ -59,7 +59,7 @@ def add_folder_post(): flash(str(e), "error") return render_template("addfolder.html") - flash("Folder '%s' created. You should now run a scan" % name) + flash(f"Folder '{name}' created. You should now run a scan") return redirect(url_for("frontend.folder_index")) diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index f2150283..ce744085 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -73,7 +73,7 @@ def user_profile(uid, user): @frontend.route("/user/", methods=["POST"]) @me_or_uuid def update_clients(uid, user): - clients_opts = dict() + clients_opts = {} for key, value in request.form.items(): if "_" not in key: continue @@ -85,7 +85,7 @@ def update_clients(uid, user): continue if client not in clients_opts: - clients_opts[client] = dict([(opt, value)]) + clients_opts[client] = {opt: value} else: clients_opts[client][opt] = value logger.debug(clients_opts) @@ -157,9 +157,9 @@ def change_username_post(uid): if user.name != username or user.admin != admin: user.name = username user.admin = admin - flash("User '%s' updated." % username) + flash(f"User '{username}' updated.") else: - flash("No changes for '%s'." % username) + flash(f"No changes for '{username}'.") return redirect(url_for("frontend.user_profile", uid=uid)) @@ -195,7 +195,7 @@ def change_password_post(uid, user): flash("The current password is required") error = True - new, confirm = map(request.form.get, ["new", "confirm"]) + new, confirm = map(request.form.get, ("new", "confirm")) if not new: flash("The new password is required") @@ -231,7 +231,7 @@ def add_user_post(): error = False args = request.form.copy() (name, passwd, passwd_confirm) = map( - args.pop, ["user", "passwd", "passwd_confirm"], [None] * 3 + args.pop, ("user", "passwd", "passwd_confirm"), (None,) * 3 ) if not name: flash("The name is required.") @@ -246,7 +246,7 @@ def add_user_post(): if not error: try: UserManager.add(name, passwd, **args) - flash("User '%s' successfully added" % name) + flash(f"User '{name}' successfully added") return redirect(url_for("frontend.user_index")) except ValueError as e: flash(str(e), "error") @@ -302,7 +302,7 @@ def login(): if request.method == "GET": return render_template("login.html") - name, password = map(request.form.get, ["user", "password"]) + name, password = map(request.form.get, ("user", "password")) error = False if not name: flash("Missing user name") diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index acdf83bb..d347a7e4 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -30,7 +30,7 @@ def link_account(self, token): if not res: return False, "Error connecting to LastFM" elif "error" in res: - return False, "Error %i: %s" % (res["error"], res["message"]) + return False, f"Error {res['error']}: {res['message']}" else: self.__user.lastfm_session = res["session"]["key"] self.__user.lastfm_status = True @@ -107,6 +107,6 @@ def __api_request(self, write, **kwargs): if "error" in json: if json["error"] in (9, "9"): self.__user.lastfm_status = False - logger.warning("LastFM error %i: %s" % (json["error"], json["message"])) + logger.warning("LastFM error %i: %s", json["error"], json["message"]) return json diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 06302179..bde31928 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -400,9 +400,12 @@ def __find_folder(self, path): created = datetime.fromtimestamp(os.path.getmtime(path)) children.append( - dict( - root=False, name=os.path.basename(path), path=path, created=created - ) + { + "root": False, + "name": os.path.basename(path), + "path": path, + "created": created, + } ) path = os.path.dirname(path) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 4259025f..f55e461a 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -31,9 +31,9 @@ class SupysonicWatcherEventHandler(PatternMatchingEventHandler): def __init__(self, extensions): patterns = None if extensions: - patterns = list(map(lambda e: "*." + e.lower(), extensions.split())) + list( - map(lambda e: "*" + e, covers.EXTENSIONS) - ) + patterns = ["*." + e.lower() for e in extensions.split()] + [ + "*" + e for e in covers.EXTENSIONS + ] super().__init__(patterns=patterns, ignore_directories=True) def dispatch(self, event): @@ -132,7 +132,7 @@ def __init__(self, delay): self.__timeout = delay self.__cond = Condition() self.__timer = None - self.__queue = dict() + self.__queue = {} self.__running = True def run(self): diff --git a/tests/api/test_playlist.py b/tests/api/test_playlist.py index 0df4d1b2..a7c2a71a 100644 --- a/tests/api/test_playlist.py +++ b/tests/api/test_playlist.py @@ -172,7 +172,7 @@ def test_create_playlist(self): "createPlaylist", { "name": "songs", - "songId": list(map(lambda s: songs[s], ["Three", "One", "Two"])), + "songId": [songs[s] for s in ("Three", "One", "Two")], }, skip_post=True, ) From a02ece78efc583f743a1189c1c143190c11f8fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis-Philippe=20V=C3=A9ronneau?= Date: Fri, 3 Dec 2021 13:53:45 -0500 Subject: [PATCH 096/237] Fix wrong package name for apt install commands. Since Debian 11 (Bullseye), python2 has been deprecated and all python packages should now use python3-foo versions. --- docs/setup/install.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/setup/install.rst b/docs/setup/install.rst index d5ff6059..693b5749 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -16,16 +16,16 @@ This will install Supysonic along with the minimal dependencies it needs to run. If you plan on using it with a MySQL or PostgreSQL database you also need the -corresponding Python package, ``python-pymysql`` for MySQL or -``python-psycopg2`` for PostgreSQL. +corresponding Python package, ``python3-pymysql`` for MySQL or +``python3-psycopg2`` for PostgreSQL. :: - $ apt install python-pymysql + $ apt install python3-pymysql :: - $ apt install python-psycopg2 + $ apt install python3-psycopg2 For other distributions, you might consider installing with `pip`_ or from :ref:`docker` images. From 09802aedaf06c5e4a54beaf894f4cc090c38e357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 4 Dec 2021 15:51:54 +0100 Subject: [PATCH 097/237] CI tweaks --- .github/workflows/tests.yaml | 18 ++++++++++++++++-- codecov.yml | 4 ++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 292fab4c..e38830a2 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -2,8 +2,22 @@ name: Tests on: - - push - - pull_request + push: + paths: + - supysonic/** + - tests/** + - ci-requirements.txt + - pyproject.toml + - setup.cfg + - setup.py + pull_request: + paths: + - supysonic/** + - tests/** + - ci-requirements.txt + - pyproject.toml + - setup.cfg + - setup.py jobs: build: name: Build diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..35cde5cd --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + status: + project: off + patch: off From 430d5a0dad6bc9b08ad3262db44b43d8c3715f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 4 Dec 2021 17:43:08 +0100 Subject: [PATCH 098/237] Try to fix stalling tests --- supysonic/daemon/server.py | 2 +- tests/api/test_scan.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index fe780127..5111aff9 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -106,8 +106,8 @@ def __unwatch(self, folder): self.__watcher.remove_folder(folder.path) def terminate(self): - self.__stopped.set() with Client(self.__listener.address, authkey=self.__listener._authkey) as c: + self.__stopped.set() c.send(None) if self.__scanner is not None: diff --git a/tests/api/test_scan.py b/tests/api/test_scan.py index 63ecc281..7b5454f5 100644 --- a/tests/api/test_scan.py +++ b/tests/api/test_scan.py @@ -6,6 +6,7 @@ # Distributed under terms of the GNU AGPLv3 license. from pony.orm import db_session +from time import sleep from threading import Thread from supysonic.daemon.server import Daemon @@ -37,6 +38,7 @@ def setUp(self): self._daemon = Daemon(self.config) self._thread = Thread(target=self._daemon.run) self._thread.start() + sleep(0.2) # Wait a bit for the daemon thread to initialize def tearDown(self): self._daemon.terminate() From 5c969e2f538d2a305a39707c43d1cd886a3ec90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis-Philippe=20V=C3=A9ronneau?= Date: Tue, 7 Dec 2021 23:32:48 -0500 Subject: [PATCH 099/237] Modify the man pages for sphinx. Closes #225. --- docs/man/README.md | 4 - docs/man/supysonic-cli-folder.rst | 104 ++++++++++++------------ docs/man/supysonic-cli-user.rst | 126 +++++++++++++++--------------- docs/man/supysonic-cli.rst | 77 +++++++++--------- docs/man/supysonic-daemon.rst | 45 +++++------ docs/man/supysonic-server.rst | 91 ++++++++++----------- 6 files changed, 211 insertions(+), 236 deletions(-) delete mode 100644 docs/man/README.md diff --git a/docs/man/README.md b/docs/man/README.md deleted file mode 100644 index a06280ab..00000000 --- a/docs/man/README.md +++ /dev/null @@ -1,4 +0,0 @@ -The man pages in this directory can be generated using the `rst2man` command -line tool provided by the Python `docutils` project: - - $ rst2man supysonic-cli.rst supysonic.1 diff --git a/docs/man/supysonic-cli-folder.rst b/docs/man/supysonic-cli-folder.rst index 66f68cf4..3fece67e 100644 --- a/docs/man/supysonic-cli-folder.rst +++ b/docs/man/supysonic-cli-folder.rst @@ -1,69 +1,68 @@ -==================== supysonic-cli-folder ==================== ------------------------------------- -Supysonic folder management commands ------------------------------------- +SYNOPSIS +-------- + +supysonic-cli folder *--help* + +supysonic-cli folder **list** + +supysonic-cli folder **add** <*name*> <*path*> -:Author: Louis-Philippe Véronneau, Alban Féron -:Date: 2019, 2021 -:Manual section: 1 +supysonic-cli folder **delete** <*name*> -Synopsis -======== +supysonic-cli folder **scan** [*--force*] [*--background* | *--foreground*] <*name*> -| ``supysonic-cli folder --help`` -| ``supysonic-cli folder list`` -| ``supysonic-cli folder add`` `name` `path` -| ``supysonic-cli folder delete`` `name` -| ``supysonic-cli folder scan`` [``--force``] [``--background``\|\ ``--foreground``] [`name`]... +DESCRIPTION +----------- -Description -=========== +The **supysonic-cli folder** subcommand manages your library folders, where the +audio files are located. This allows one to list, add, delete and scan the +folders. -The ``supysonic-cli folder`` subcommand manages your library folders, where the -audio files are located. This allows to list, add, delete and scan the folders. +ARGUMENTS +--------- -``supysonic-cli folder list`` - List all the folders. +**list** + List all the folders. -``supysonic-cli folder add`` `name` `path` - Add a new library folder called `name` and located at `path`. `name` must be - unique and `path` pointing to an existing directory. If ``supysonic-daemon`` - is running it will start to listen for changes in this folder but will not - scan files already present in the folder. +**add** <*name*> <*path*> + Add a new library folder called <*name*> and located at <*path*>. <*name*> + must be unique and <*path*> pointing to an existing directory. If + ``supysonic-daemon`` is running it will start to listen for changes in this + folder but will not scan files already present in the folder. -``supysonic-cli folder delete`` `name` - Delete the folder called `name`. +**delete** <*name*> + Delete the folder called <*name*>. -``supysonic-cli folder scan`` [``--force``] [``--background``\|\ ``--foreground``] [`name`]... - Scan the specified folders. If none is given, all the registered folders are - scanned. +**scan** [*--force*] [*--background* | *--foreground*] <*name*> + Scan the specified folders. If none is given, all the registered folders + are scanned. -Options -======= +OPTIONS +------- --h, --help - Shows help and exits. Depending on where this option appears it will either list the - available commands or display help for a specific command. +**-h**, **--help** + Shows help and exits. Depending on where this option appears it will either + list the available commands or display help for a specific command. --f, --force - Force scan of already known files even if they haven't changed. Might be - useful if an update to Supysonic adds new metadata to audio files. +**-f**, **--force** + Force scan of already known files even if they haven't changed. Might be + useful if an update to supysonic adds new metadata to audio files. ---background - Scan in the background. Requires the ``supysonic-daemon`` to be running +**--background** + Scan in the background. Requires the ``supysonic-daemon`` to be running. ---foreground - Scan in the foreground, blocking the process while the scan is running +**--foreground** + Scan in the foreground, blocking the process while the scan is running. -If neither ``--background`` nor ``--foreground`` is provided, ``supysonic-cli`` -will try to connect to the daemon to initiate a background scan, falling back to -a foreground scan if it isn't available. +If neither **--background** nor **--foreground** is provided, supysonic-cli +will try to connect to the daemon to initiate a background scan, falling back +to a foreground scan if it isn't available. -Examples -======== +EXAMPLES +-------- To add a new folder to your music library, you can do something like this:: @@ -73,10 +72,11 @@ Once you've added a folder, you will need to scan it:: $ supysonic-cli folder scan MyLibrary -The audio files residing in `/home/username/Music` will now appear under the -`MyLibrary` folder on the clients. +The audio files residing in ``/home/username/Music`` will now appear under the +``MyLibrary`` folder on the clients. -See Also -======== +SEE ALSO +-------- -``supysonic-cli``\ (1), ``supysonic-cli-user``\ (1) +``supysonic-cli (1)``, ``supysonic-cli-user (1)``, +``supysonic-server (1)``, ``supysonic-daemon (1)`` diff --git a/docs/man/supysonic-cli-user.rst b/docs/man/supysonic-cli-user.rst index e08af6f8..59e5dbcb 100644 --- a/docs/man/supysonic-cli-user.rst +++ b/docs/man/supysonic-cli-user.rst @@ -1,94 +1,98 @@ -================== supysonic-cli-user ================== ----------------------------------- -Supysonic user management commands ----------------------------------- +SYNOPSIS +-------- + +supysonic-cli user *--help* + +supysonic-cli user **list** + +supysonic-cli user **add** <*user*> [*--password* <*password*>] [*--email* <*email*>] -:Author: Louis-Philippe Véronneau, Alban Féron -:Date: 2019, 2021 -:Manual section: 1 +supysonic-cli user **delete** <*user*> -Synopsis -======== +supysonic-cli user **changepass** <*user*> [*--password* <*password*>] -| ``supysonic-cli user --help`` -| ``supysonic-cli user list`` -| ``supysonic-cli user add`` `user` [``--password`` `password`] [``--email`` `email`] -| ``supysonic-cli user delete`` `user` -| ``supysonic-cli user changepass`` `user` [``--password`` `password`] -| ``supysonic-cli user setroles`` [``--admin``\|\ ``--noadmin``] [``--jukebox``\|\ ``--nojukebox``] `user` -| ``supysonic-cli user rename`` `user` `newname` +supysonic-cli user **setroles** [*--admin* | *--noadmin*] [*--jukebox* | *--nojukebox*] <*user*> -Description -=========== +supysonic-cli user **rename** <*user*> <*newname*> -The ``supysonic-cli user`` subcommand manages users, allowing to list them, add +DESCRIPTION +----------- + +The **supysonic-cli user** subcommand manages users, allowing to list them, add a new user, delete an existing user, and change their password or roles. -``supysonic-cli user list`` - List all the users. +ARGUMENTS +--------- + +**list** + List all the users. -``supysonic-cli user add`` `user` [``--password`` `password`] [``--email`` `email`] - Add a new user named `user`. Will prompt for a password if it isn't given - with the ``--password`` option. +**add** <*user*> [*--password* <*password*>] [*--email* <*email*>] + Add a new user named <*user*>. Will prompt for a password if it isn't given + with the *--password* option. -``supysonic-cli user delete`` `user` - Delete the user `user`. +**delete** <*user*> + Delete the user <*user*>. -``supysonic-cli user changepass`` `user` [``--password`` `password`] - Change the password of user `user`. Will prompt for the new password if not - provided. +**changepass** <*user*> [*--password* <*password*>] + Change the password of user <*user*>. Will prompt for the new password if + not provided. -``supysonic-cli user setroles`` [``--admin``\|\ ``--noadmin``] [``--jukebox``\|\ ``--nojukebox``] `user` - Give or remove rights to user `user`. +**setroles** [*--admin* | *--noadmin*] [*--jukebox* | *--nojukebox*] <*user*> + Give or remove rights to user <*user*>. -``supysonic-cli user rename`` `user` `newname` - Rename the user `user` to `newname` +**rename** <*user*> <*newname*> + Rename the user <*user*> to <*newname*>. -Options -======= +OPTIONS +------- --h, --help - Shows help and exits. Depending on where this option appears it will either list the - available commands or display help for a specific command. +**-h**, **--help** + Shows help and exits. Depending on where this option appears it will either + list the available commands or display help for a specific command. --p password, --password password - Specify the user's password upon creation. +**-p** <*password*>, **--password** <*password*> + Specify the user's password upon creation. --e email, --email email - Specify the user's email. +**-e** <*email*>, **--email** <*email*> + Specify the user's email. The next options relate to user roles. They work in pairs, one option granting a right while the other revokes it; obviously options of the same pair are -mutually exclusive. The long options are named with the matching right, prefix -it with a ``no`` to revoke the right. For short options, the upper case letter -grants the right while the lower case letter revokes it. Short options might be -combined into a single one such as ``-aJ`` to both revoke the admin right and -grant the jukebox one. +mutually exclusive. + +The long options are named with the matching right, prefix it with a **no** to +revoke the right. For short options, the upper case letter grants the right +while the lower case letter revokes it. Short options might be combined into a +single one such as **-aJ** to both revoke the admin right and grant the jukebox +one. --A, --admin - Grant admin rights. +**-A**, **--admin** + Grant admin rights. --a, --noadmin - Revoke admin rights. +**-a**, **--noadmin** + Revoke admin rights. --J, --jukebox - Grant jukebox rights. +**-J**, **--jukebox** + Grant jukebox rights. --j, --nojukebox - Revoke jukebox rights. +**-j**, **--nojukebox** + Revoke jukebox rights. -Examples -======== +EXAMPLES +-------- -To add a new admin user named `MyUserName` having password `MyAwesomePassword`:: +To add a new admin user named ``MyUserName`` having password +``MyAwesomePassword``:: $ supysonic-cli user add MyUserName -p MyAwesomePassword $ supysonic-cli user setroles -A MyUserName -See Also -======== +SEE ALSO +-------- -``supysonic-cli``\ (1), ``supysonic-cli-folder``\ (1) +``supysonic-cli (1)``, ``supysonic-cli-folder (1)``, +``supysonic-server (1)``, ``supysonic-daemon (1)`` diff --git a/docs/man/supysonic-cli.rst b/docs/man/supysonic-cli.rst index 76cdbf23..aef0e8b2 100644 --- a/docs/man/supysonic-cli.rst +++ b/docs/man/supysonic-cli.rst @@ -1,35 +1,29 @@ -============= supysonic-cli ============= -------------------------------------------- -Supysonic management command line interface -------------------------------------------- +SYNOPSIS +-------- -:Author: Louis-Philippe Véronneau, Alban Féron -:Date: 2019, 2021 -:Manual section: 1 +supysonic-cli *--help* -Synopsis -======== +supysonic-cli **user** [*options*] -| ``supysonic-cli --help`` -| ``supysonic-cli`` [`subcommand`] +supysonic-cli **folder** [*options*] -Description -=========== +DESCRIPTION +----------- Supysonic is a Python implementation of the Subsonic server API. Current supported features are: -| * browsing (by folders or tags) -| * streaming of various audio file formats -| * transcoding -| * user or random playlists -| * cover arts (as image files in the same folder as music files) -| * starred tracks/albums and ratings -| * Last.FM scrobbling -| * Jukebox mode +* browsing (by folders or tags) +* streaming of various audio file formats +* transcoding +* user or random playlists +* cover arts (as image files in the same folder as music files) +* starred tracks/albums and ratings +* Last.FM scrobbling +* Jukebox mode The "Subsonic API" is a set of adhoc standards to browse, stream or download a music collection over HTTP. @@ -37,35 +31,36 @@ music collection over HTTP. The command-line interface is an interface allowing administration operations without the use of the web interface. -Options -======= +SUBCOMMANDS +----------- --h, --help - Shows the help and exits. At top level it only lists the subcommands. To - display the help of a specific subcommand, add the ``--help`` flag *after* - the said subcommand name. +supysonic-cli has two different subcommands: -Subcommands -=========== +**user** [*options*] + User management commands -``supysonic-cli`` has two different subcommands: +**folder** [*options*] + Folder management commands -``user`` `args` ... - User management commands +For more details on the **user** and **folder** subcommands, see the +``subsonic-cli-user (1)``, ``subsonic-cli-folder (1)`` manual pages. -``folder`` `args` ... - Folder managemnt commands +OPTIONS +------- -For more details on the ``user`` and ``folder`` subcommands, see the -``subsonic-cli-user``\ (1), ``subsonic-cli-folder``\ (1) manual pages. +**-h**, **--help** + Shows the help and exits. At top level it only lists the subcommands. To + display the help of a specific subcommand, add the **--help** flag *after* + the said subcommand name. -Bugs -==== +BUGS +---- Bugs can be reported to your distribution's bug tracker or upstream at https://github.com/spl0k/supysonic/issues. -See Also -======== +SEE ALSO +-------- -``supysonic-cli-user``\ (1), ``supysonic-cli-folder``\ (1) +``supysonic-cli-user (1)``, ``supysonic-cli-folder (1)``, +``supysonic-server (1)``, ``supysonic-daemon (1)`` diff --git a/docs/man/supysonic-daemon.rst b/docs/man/supysonic-daemon.rst index 39acd79d..3b076ed0 100644 --- a/docs/man/supysonic-daemon.rst +++ b/docs/man/supysonic-daemon.rst @@ -1,42 +1,33 @@ -================ supysonic-daemon ================ ---------------------------- -Supysonic background daemon ---------------------------- - -:Author: Louis-Philippe Véronneau, Alban Féron -:Date: 2019, 2021 -:Manual section: 1 +SYNOPSIS +-------- -Synopsis -======== - -``supysonic-daemon`` +supysonic-daemon -Description -=========== +DESCRIPTION +----------- -``supysonic-daemon`` is an optional non-exiting process made to be ran in the +**supysonic-daemon** is an optional non-exiting process made to be ran in the background to manage background scans, library changes detection and the jukebox mode (audio played on the server hardware). -If ``supysonic-daemon`` is running when you start a manual scan using -``supysonic-cli``\ (1), the scan will be run by the daemon process in the -background instead of running in the foreground. This daemon also enables the -web UI scan feature. +If **supysonic-daemon** is running when you start a manual scan using +**supysonic-cli**, the scan will be run by the daemon process in the background +instead of running in the foreground. This daemon also enables the web UI scan +feature. -With proper configuration, ``supysonic-daemon`` also allows authorized users to +With proper configuration, **supysonic-daemon** also allows authorized users to play audio on the machine's hardware, using their client as a remote control. -Bugs -==== +BUGS +---- -Bugs can be reported to your distribution's bug tracker or upstream -at https://github.com/spl0k/supysonic/issues. +Bugs can be reported to your distribution's bug tracker or upstream at +https://github.com/spl0k/supysonic/issues. -See Also -======== +SEE ALSO +-------- -``supysonic-cli``\ (1) +``supysonic-cli (1)`` diff --git a/docs/man/supysonic-server.rst b/docs/man/supysonic-server.rst index 6bf657b8..ba32f308 100644 --- a/docs/man/supysonic-server.rst +++ b/docs/man/supysonic-server.rst @@ -1,68 +1,57 @@ -================ supysonic-server ================ ------------------------------------------------- -Python implementation of the Subsonic server API ------------------------------------------------- - -:Author: Alban Féron -:Date: 2021 -:Manual section: 1 - -Synopsis -======== +SYNOPSIS +-------- -``supysonic-server`` [``--server`` ``gevent``\|\ ``gunicorn``\|\ ``waitress``] -[``--host`` `hostname`] [``--port`` `port`] [``--socket`` `path`] -[``--processes`` `n`] [``--threads`` `n`] +supysonic-server [**--server** *gevent* | *gunicorn* | *waitress*] [**--host** <*hostname*>] [**--port** <*port*>] [**--socket** <*path*>] [**--processes** <*n*>] [**--threads** <*n*>] -Description -=========== +DESCRIPTION +----------- -``supysonic-server`` is the main Supysonic's component, allowing to serve -content to clients. It is actually a basic wrapper over ``Gevent``, ``Gunicorn`` -or ``Waitress``, requiring at least one of them to be installed to run. +**supysonic-server** is the main supysonic's component, allowing to serve +content to clients. It is actually a basic wrapper over **Gevent**, **Gunicorn** +or **Waitress**, requiring at least one of them to be installed to run. -Options -======= +OPTIONS +------- --S name, --server name - Specify which WSGI server to use. `name` must be one of ``gevent``, - ``gunicorn`` or ``waitress`` and the matching package must then be installed. - If the option isn't provided, the first one available will be used. +**-S** <*name*>, **--server** <*name*> + Specify which WSGI server to use. <*name*> must be one of ``gevent``, + ``gunicorn`` or ``waitress`` and the matching package must then be + installed. If the option isn't provided, the first one available will be + used. --h hostname, --host hostname - Hostname or IP address on which to listen. The default is ``0.0.0.0`` which - means to listen on all IPv4 interfaces on this host. - Cannot be used with ``--socket``. +**-h** <*hostname*>, **--host** <*hostname*> + Hostname or IP address on which to listen. The default is ``0.0.0.0`` which + means to listen on all IPv4 interfaces on this host. + Cannot be used with **--socket**. --p port, --port port - TCP port on which to listen. Default is ``5722``. - Cannot be used with ``--socket``. +**-p** <*port*>, **--port** <*port*> + TCP port on which to listen. Default is ``5722``. + Cannot be used with **--socket**. --s path, --socket path - Path of a Unix socket on which to bind to. If a path is specified, a Unix - domain socket is made instead of the usual inet domain socket. - Cannot be used with ``--host`` or ``--port``. - Not available on Windows. +**-s** <*path*>, **--socket** <*path*> + Path of a Unix socket on which to bind to. If a path is specified, a Unix + domain socket is made instead of the usual inet domain socket. + Cannot be used with **--host** or **--port**. + Not available on Windows. ---processes n - Number of worker processes to spawn. Only applicable when using the - ``Gunicorn`` WSGI server (``--server gunicorn``). +**--processes** <*n*> + Number of worker processes to spawn. Only applicable when using the + **Gunicorn** WSGI server. ---threads n - The number of worker threads for handling requests. Only applicable when - using the ``Gunicorn`` or ``Waitress`` WSGI server (``--server gunicorn`` or - ``--server waitress``) +**--threads** <*n*> + The number of worker threads for handling requests. Only applicable when + using the **Gunicorn** or **Waitress** WSGI server. -Bugs -==== +BUGS +---- -Bugs can be reported to your distribution's bug tracker or upstream -at https://github.com/spl0k/supysonic/issues. +Bugs can be reported to your distribution's bug tracker or upstream at +https://github.com/spl0k/supysonic/issues. -See Also -======== +SEE ALSO +-------- -``supysonic-cli``\ (1) +``supysonic-cli (1)`` From 5de715f756fa98153fa530aa7bd0080196ebf460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis-Philippe=20V=C3=A9ronneau?= Date: Thu, 16 Dec 2021 15:23:54 -0500 Subject: [PATCH 100/237] Disable smartquotes dashes in Sphinx. Smarquotes transform "--" into em-dashes, and we don't want that. --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index a28cadbe..1446adbf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ source_suffix = ".rst" master_doc = "index" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +smartquotes_action = "qe" primary_domain = None highlight_language = "none" From c24ee94a441933e3339cd00ee32ee37da8580f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 19 Dec 2021 13:43:40 +0100 Subject: [PATCH 101/237] Don't list sections on manpages index --- docs/man/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/man/index.rst b/docs/man/index.rst index df1b3075..da4a353c 100644 --- a/docs/man/index.rst +++ b/docs/man/index.rst @@ -4,7 +4,7 @@ Man pages .. rubric:: Command-line interface .. toctree:: - :maxdepth: 2 + :maxdepth: 1 supysonic-cli supysonic-cli-user @@ -13,13 +13,13 @@ Man pages .. rubric:: Web server .. toctree:: - :maxdepth: 2 + :maxdepth: 1 supysonic-server .. rubric:: Daemon .. toctree:: - :maxdepth: 2 + :maxdepth: 1 supysonic-daemon From ec92dec9ab911230f343213c732fd09d78adb70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 31 Dec 2021 18:05:43 +0100 Subject: [PATCH 102/237] Build and include man pages in distributions I do not fully understand how the building process works, and have some doubts on what a "source distribution" should be. The sdist might be polluted if a "man" directory exists at the project root when building the distribution. The inclusion of man pages in the wheel requires it to be built from the sdist, so it's best to build both at the same time using "python -m build". Closes #215 --- docs/conf.py | 15 ++++++++++++--- pyproject.toml | 2 +- setup.cfg | 3 +++ setup.py | 20 ++++++++++++++++++-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1446adbf..2b0e4aab 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,13 @@ -import supysonic +import os.path + +# Simulate import of the "supysonic" package +supy_module_path = os.path.join( + os.path.dirname(__file__), "..", "supysonic", "__init__.py" +) +with open(supy_module_path, "rt", encoding="utf-8") as f: + supysonic = type("", (), {})() + exec(f.read(), supysonic.__dict__) + # -- Project information ----------------------------------------------------- @@ -99,6 +108,6 @@ "supysonic-server", "Python implementation of the Subsonic server API", [author], - 1 - ) + 1, + ), ] diff --git a/pyproject.toml b/pyproject.toml index 3607e0fb..396b209a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [build-system] -requires = ["setuptools>=51.0.0", "wheel"] +requires = ["setuptools>=51.0.0", "wheel", "sphinx"] build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg index 430be0d7..254b218d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -71,3 +71,6 @@ console_scripts = supysonic-cli = supysonic.cli:main supysonic-daemon = supysonic.daemon:main supysonic-server = supysonic.server:main + +[options.data_files] +share/man/man1 = man/*.1 diff --git a/setup.py b/setup.py index e51e2d7b..190e0ba7 100644 --- a/setup.py +++ b/setup.py @@ -2,11 +2,27 @@ # Supysonic is a Python implementation of the Subsonic server API. # # Copyright (C) 2013-2021 Alban 'spl0k' Féron -# 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. +import os.path + +from distutils import dir_util from setuptools import setup +from setuptools.command.sdist import sdist as _sdist + + +class sdist(_sdist): + def make_release_tree(self, base_dir, files): + super().make_release_tree(base_dir, files) + + man_dir = os.path.join(base_dir, "man") + doctrees_dir = os.path.join(man_dir, ".doctrees") + self.spawn(["sphinx-build", "-q", "-b", "man", "docs", man_dir]) + dir_util.remove_tree(doctrees_dir) + if __name__ == "__main__": - setup() + setup( + cmdclass={"sdist": sdist}, + ) From 62bad3b9878a1d22cf040f25dab0fa28a252ba38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 1 Jan 2022 19:39:55 +0100 Subject: [PATCH 103/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index aeebfa07..6631fb04 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.0" +VERSION = "0.7.1" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From f387cce9ca668ab06e2843994323253dd127503c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 29 Jan 2022 17:15:03 +0100 Subject: [PATCH 104/237] Prevent naming collisions when zipping albums --- supysonic/api/media.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 471cb435..e8162032 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -255,9 +255,20 @@ def download_media(): # Add the entire folder tree to the zip z.add_path(rv.path, recurse=True) else: - # Add tracks + cover art to the zip + # Add tracks + cover art to the zip, preventing potential naming collisions + seen = set() for track in rv.tracks: - z.add_path(track.path) + filename = os.path.basename(track.path) + name, ext = os.path.splitext(filename) + index = 0 + while filename in seen: + index += 1 + filename = f"{name} ({index})" + if ext: + filename += ext + + z.add_path(track.path, filename) + seen.add(filename) cover_path = _cover_from_collection(rv, extract=False) if cover_path: From 4bee23ce23f43382ba72cbe22abc38bf06295438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 30 Jan 2022 16:30:05 +0100 Subject: [PATCH 105/237] Fix covers not being scanned as such when modified --- supysonic/scanner.py | 4 ++-- supysonic/watcher.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/supysonic/scanner.py b/supysonic/scanner.py index bde31928..24e60b7b 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -345,7 +345,7 @@ def add_cover(self, path): cover_name = os.path.basename(path) if not folder.cover_art: folder.cover_art = cover_name - else: + elif folder.cover_art != cover_name: album_name = None track = folder.tracks.select().first() if track is not None: diff --git a/supysonic/watcher.py b/supysonic/watcher.py index f55e461a..64b90011 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2014-2019 Alban 'spl0k' Féron +# Copyright (C) 2014-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -68,8 +68,10 @@ def on_deleted(self, event): def on_modified(self, event): logger.debug("File modified: '%s'", event.src_path) - if not covers.is_valid_cover(event.src_path): - self.queue.put(event.src_path, OP_SCAN) + op = OP_SCAN + if covers.is_valid_cover(event.src_path): + op |= FLAG_COVER + self.queue.put(event.src_path, op) def on_moved(self, event): logger.debug("File moved: '%s' -> '%s'", event.src_path, event.dest_path) From e11e775baf99035899f22aa358b4a42fde88f3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 30 Jan 2022 16:37:09 +0100 Subject: [PATCH 106/237] Pony now has a stable release for Python 3.10 so we're compatible on this version too Closes #230 --- .github/workflows/tests.yaml | 2 +- README.md | 2 +- docs/setup/install.rst | 5 ++--- setup.cfg | 3 ++- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e38830a2..6f8fa600 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -29,7 +29,7 @@ jobs: - 3.7 - 3.8 - 3.9 - #- "3.10" + - "3.10" fail-fast: false steps: - name: Checkout diff --git a/README.md b/README.md index 10756788..7cfcc06f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Supysonic is a Python implementation of the [Subsonic][] server API. ![Build Status](https://github.com/spl0k/supysonic/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) -![Python](https://img.shields.io/badge/python-3.6--3.9-blue.svg) +![Python](https://img.shields.io/badge/python-3.6--3.10-blue.svg) Current supported features are: * browsing (by folders or tags) diff --git a/docs/setup/install.rst b/docs/setup/install.rst index 693b5749..089bae1d 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -1,8 +1,7 @@ Installing Supysonic ==================== -Supysonic is written in Python and supports Python 3.6 through 3.9. Python 3.10 -and later are not yet supported. +Supysonic is written in Python and supports Python 3.6 through 3.10. Linux ----- @@ -47,7 +46,7 @@ Once the command prompt is open, type :command:`python --version` and press Enter. If Python is installed, you will see the version of Python printed to the screen. If you do not have Python installed, head over to the `Python website`__ and install one of the `compatible Python versions`__. You need at -least Python 3.6, but you can go up to the latest 3.9. +least Python 3.6, but you can go up to the latest 3.10. Once Python is installed, you can install Supysonic using :command:`pip`. Refer to the `installation instructions `_ below for more information. diff --git a/setup.cfg b/setup.cfg index 254b218d..341be85d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,10 +45,11 @@ classifiers = Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Topic :: Multimedia :: Sound/Audio [options] -python_requires = >=3.6,<3.10 +python_requires = >=3.6,<3.11 install_requires = click flask >=0.11 From 8d6821df991a87df317b4be92bcb8042b61246a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 30 Jan 2022 17:00:32 +0100 Subject: [PATCH 107/237] Try to fix flaky test Closes #229 --- tests/base/test_watcher.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index d1ff35e2..63434bed 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -113,6 +113,8 @@ def test_add(self): def test_add_nowait_stop(self): self._addfile() + # Add a small delay (< wait_delay) so wathdog can pick up that a file was added + time.sleep(0.1) self._stop() self.assertTrackCountEqual(1) From 65a7131c05edde1b4a95bec197be01939ebc60c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 27 Feb 2022 15:27:08 +0100 Subject: [PATCH 108/237] Version bump --- docs/conf.py | 4 ---- supysonic/__init__.py | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2b0e4aab..2bb023c2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -69,10 +69,6 @@ _man_authors = ["Louis-Philippe Véronneau", author] -# Man pages, they are writter to be generated directly by `rst2man` so using -# Sphinx to build them will give weird sections, but if we ever need it it's -# there - # (source start file, name, description, authors, manual section). man_pages = [ ( diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 6631fb04..bcbc9c11 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.1" +VERSION = "0.7.2" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From bc373e595f38cd782b96b988835d9977621b9f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 9 Sep 2022 15:14:50 +0200 Subject: [PATCH 109/237] Factor in index construction --- supysonic/api/browse.py | 57 +++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index af3b5204..e278a390 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -49,6 +49,27 @@ def ignored_articles_str(): return " ".join(articles.split()) +def build_indexes(source): + indexes = {} + pattern = build_ignored_articles_pattern() + for item in source: + name = item.name + if pattern: + name = re.sub(pattern, "", name, flags=re.I) + index = name[0].upper() + if index in string.digits: + index = "#" + elif index not in string.ascii_letters: + index = "?" + + if index not in indexes: + indexes[index] = [] + + indexes[index].append((item, name)) + + return indexes + + @api_routing("/getIndexes") def list_indexes(): musicFolderId = request.values.get("musicFolderId") @@ -83,23 +104,7 @@ def list_indexes(): artists += f.children.select()[:] children += f.tracks.select()[:] - indexes = {} - pattern = build_ignored_articles_pattern() - for artist in artists: - name = artist.name - if pattern: - name = re.sub(pattern, "", name, flags=re.I) - index = name[0].upper() - if index in string.digits: - index = "#" - elif index not in string.ascii_letters: - index = "?" - - if index not in indexes: - indexes[index] = [] - - indexes[index].append((artist, name)) - + indexes = build_indexes(artists) return request.formatter( "indexes", { @@ -149,23 +154,7 @@ def list_genres(): @api_routing("/getArtists") def list_artists(): # According to the API page, there are no parameters? - indexes = {} - pattern = build_ignored_articles_pattern() - for artist in Artist.select(): - name = artist.name or "?" - if pattern: - name = re.sub(pattern, "", name, flags=re.I) - index = name[0].upper() - if index in string.digits: - index = "#" - elif index not in string.ascii_letters: - index = "?" - - if index not in indexes: - indexes[index] = [] - - indexes[index].append((artist, name)) - + indexes = build_indexes(Artist.select()) return request.formatter( "artists", { From e52a7043b0ceafd7ae65c759fd288d979bd4eb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Sep 2022 15:50:10 +0200 Subject: [PATCH 110/237] Implement musicFolderId paramenter on various endpoints Bumped API version to 1.12.0 along the way. `getArtits` also got it event if it seems it has been added with version 1.14.0, but I'm a bit concerned as to how clients will behave on authentication if the server advertise itself as 1.13.0+ Closes #235 Ref #74 --- README.md | 2 +- docs/api.rst | 18 +-- supysonic/api/__init__.py | 20 ++- supysonic/api/albums_songs.py | 119 ++++++++++-------- supysonic/api/browse.py | 23 ++-- supysonic/api/search.py | 46 ++++--- tests/api/apitestbase.py | 2 +- tests/api/test_album_songs.py | 49 +++++++- tests/api/test_browse.py | 25 +++- tests/api/test_search.py | 35 +++++- ....10.2.xsd => subsonic-rest-api-1.12.0.xsd} | 69 +++++++++- 11 files changed, 301 insertions(+), 107 deletions(-) rename tests/assets/{subsonic-rest-api-1.10.2.xsd => subsonic-rest-api-1.12.0.xsd} (86%) diff --git a/README.md b/README.md index 7cfcc06f..f7f2e49d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Current supported features are: * [Last.fm][lastfm] scrobbling * Jukebox mode -Supysonic currently targets the version 1.10.2 of the Subsonic API. For more +Supysonic currently targets the version 1.12.0 of the Subsonic API. For more details, go check the [API implementation status][docs-api]. [subsonic]: http://www.subsonic.org/ diff --git a/docs/api.rst b/docs/api.rst index dd3e40ee..a1c51e91 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,7 +5,7 @@ This page lists all the API methods and their parameters up to the version 1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features Supysonic support, plan on supporting, or won't. -At the moment, the current target API version is 1.10.2. +At the moment, the current target API version is 1.12.0. The following information was gathered by *diff*-ing various snapshots of the `Subsonic API page`__. @@ -227,7 +227,7 @@ Browsing ================= ====== = Parameter Vers. ================= ====== = - ``musicFolderId`` 1.14.0 📅 + ``musicFolderId`` 1.14.0 ✔️ ================= ====== = .. _getArtist: @@ -418,7 +418,7 @@ Album/song lists ``fromYear`` ✔️ ``toYear`` ✔️ ``genre`` ✔️ - ``musicFolderId`` 1.12.0 📅 + ``musicFolderId`` 1.12.0 ✔️ ================= ====== = .. versionadded:: 1.10.1 @@ -441,7 +441,7 @@ Album/song lists ``fromYear`` ✔️ ``toYear`` ✔️ ``genre`` ✔️ - ``musicFolderId`` 1.12.0 📅 + ``musicFolderId`` 1.12.0 ✔️ ================= ====== = .. versionadded:: 1.10.1 @@ -479,7 +479,7 @@ Album/song lists ``genre`` 1.9.0 ✔️ ``count`` 1.9.0 ✔️ ``offset`` 1.9.0 ✔️ - ``musicFolderId`` 1.12.0 📅 + ``musicFolderId`` 1.12.0 ✔️ ================= ====== = .. _getNowPlaying: @@ -500,7 +500,7 @@ Album/song lists ================= ====== = Parameter Vers. ================= ====== = - ``musicFolderId`` 1.12.0 📅 + ``musicFolderId`` 1.12.0 ✔️ ================= ====== = .. _getStarred2: @@ -514,7 +514,7 @@ Album/song lists ================= ====== = Parameter Vers. ================= ====== = - ``musicFolderId`` 1.12.0 📅 + ``musicFolderId`` 1.12.0 ✔️ ================= ====== = Searching @@ -558,7 +558,7 @@ Searching ``albumOffset`` ✔️ ``songCount`` ✔️ ``songOffset`` ✔️ - ``musicFolderId`` 1.12.0 📅 + ``musicFolderId`` 1.12.0 ✔️ ================= ====== = .. _search3: @@ -579,7 +579,7 @@ Searching ``albumOffset`` ✔️ ``songCount`` ✔️ ``songOffset`` ✔️ - ``musicFolderId`` 1.12.0 📅 + ``musicFolderId`` 1.12.0 ✔️ ================= ====== = Playlists diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index ddbdf9e6..e40e65b7 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -17,7 +17,7 @@ from ..db import ClientPrefs, Folder from ..managers.user import UserManager -from .exceptions import GenericError, Unauthorized +from .exceptions import GenericError, Unauthorized, NotFound from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter api = Blueprint("api", __name__) @@ -119,6 +119,22 @@ def get_entity_id(cls, eid): raise GenericError("Invalid ID") +def get_root_folder(id): + if id is None: + return None + + try: + fid = int(id) + except ValueError: + raise ValueError("Invalid folder ID") + + folder = Folder.get(id=fid, root=True) + if folder is None: + raise NotFound("Folder") + + return folder + + from .errors import * from .system import * diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index f71295bf..25e9ed31 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -21,8 +21,8 @@ ) from ..db import now -from . import api_routing -from .exceptions import GenericError, NotFound +from . import api_routing, get_root_folder +from .exceptions import GenericError @api_routing("/getRandomSongs") @@ -35,12 +35,7 @@ def rand_songs(): size = int(size) if size else 10 fromYear = int(fromYear) if fromYear else None toYear = int(toYear) if toYear else None - fid = None - if musicFolderId: - try: - fid = int(musicFolderId) - except ValueError: - raise ValueError("Invalid folder ID") + root = get_root_folder(musicFolderId) query = Track.select() if fromYear: @@ -49,11 +44,8 @@ def rand_songs(): query = query.filter(lambda t: t.year <= toYear) if genre: query = query.filter(lambda t: t.genre == genre) - if fid: - if not Folder.exists(id=fid, root=True): - raise NotFound("Folder") - - query = query.filter(lambda t: t.root_folder.id == fid) + if root: + query = query.filter(lambda t: t.root_folder == root) return request.formatter( "randomSongs", @@ -70,11 +62,15 @@ def rand_songs(): def album_list(): ltype = request.values["type"] - size, offset = map(request.values.get, ("size", "offset")) + size, offset, mfid = map(request.values.get, ("size", "offset", "musicFolderId")) size = int(size) if size else 10 offset = int(offset) if offset else 0 + root = get_root_folder(mfid) query = select(t.folder for t in Track) + if root is not None: + query = select(t.folder for t in Track if t.root_folder == root) + if ltype == "random": return request.formatter( "albumList", @@ -94,13 +90,18 @@ def album_list(): elif ltype == "recent": query = select( t.folder for t in Track if max(t.folder.tracks.last_play) is not None - ).sort_by(lambda f: desc(max(f.tracks.last_play))) + ) + if root is not None: + query = query.where(lambda t: t.root_folder == root) + query = query.sort_by(lambda f: desc(max(f.tracks.last_play))) elif ltype == "starred": query = select( s.starred for s in StarredFolder if s.user.id == request.user.id and count(s.starred.tracks) > 0 ) + if root is not None: + query = query.filter(lambda f: f.path.startswith(root.path)) elif ltype == "alphabeticalByName": query = query.sort_by(Folder.name).distinct() elif ltype == "alphabeticalByArtist": @@ -135,11 +136,15 @@ def album_list(): def album_list_id3(): ltype = request.values["type"] - size, offset = map(request.values.get, ("size", "offset")) + size, offset, mfid = map(request.values.get, ("size", "offset", "musicFolderId")) size = int(size) if size else 10 offset = int(offset) if offset else 0 + root = get_root_folder(mfid) query = Album.select() + if root is not None: + query = query.where(lambda a: root in a.tracks.root_folder) + if ltype == "random": return request.formatter( "albumList2", @@ -150,11 +155,13 @@ def album_list_id3(): elif ltype == "frequent": query = query.order_by(lambda a: desc(avg(a.tracks.play_count))) elif ltype == "recent": - query = Album.select(lambda a: max(a.tracks.last_play) is not None).order_by( + query = query.where(lambda a: max(a.tracks.last_play) is not None).order_by( lambda a: desc(max(a.tracks.last_play)) ) elif ltype == "starred": query = select(s.starred for s in StarredAlbum if s.user.id == request.user.id) + if root is not None: + query = query.filter(lambda a: root in a.tracks.root_folder) elif ltype == "alphabeticalByName": query = query.order_by(Album.name) elif ltype == "alphabeticalByArtist": @@ -191,14 +198,22 @@ def album_list_id3(): def songs_by_genre(): genre = request.values["genre"] - count, offset = map(request.values.get, ("count", "offset")) + count, offset, mfid = map(request.values.get, ("count", "offset", "musicFolderId")) count = int(count) if count else 10 offset = int(offset) if offset else 0 + root = get_root_folder(mfid) - query = select(t for t in Track if t.genre == genre).limit(count, offset) + query = select(t for t in Track if t.genre == genre) + if root is not None: + query = query.where(lambda t: t.root_folder == root) return request.formatter( "songsByGenre", - {"song": [t.as_subsonic_child(request.user, request.client) for t in query]}, + { + "song": [ + t.as_subsonic_child(request.user, request.client) + for t in query.limit(count, offset) + ] + }, ) @@ -227,51 +242,49 @@ def now_playing(): @api_routing("/getStarred") def get_starred(): + mfid = request.values.get("musicFolderId") + root = get_root_folder(mfid) + folders = select(s.starred for s in StarredFolder if s.user.id == request.user.id) + if root is not None: + folders = folders.filter(lambda f: f.path.startswith(root.path)) + + arq = folders.filter(lambda f: count(f.tracks) == 0) + alq = folders.filter(lambda f: count(f.tracks) > 0) + trq = select(s.starred for s in StarredTrack if s.user.id == request.user.id) + + if root is not None: + trq = trq.filter(lambda t: t.root_folder == root) return request.formatter( "starred", { - "artist": [ - sf.as_subsonic_artist(request.user) - for sf in folders.filter(lambda f: count(f.tracks) == 0) - ], - "album": [ - sf.as_subsonic_child(request.user) - for sf in folders.filter(lambda f: count(f.tracks) > 0) - ], - "song": [ - st.as_subsonic_child(request.user, request.client) - for st in select( - s.starred for s in StarredTrack if s.user.id == request.user.id - ) - ], + "artist": [sf.as_subsonic_artist(request.user) for sf in arq], + "album": [sf.as_subsonic_child(request.user) for sf in alq], + "song": [st.as_subsonic_child(request.user, request.client) for st in trq], }, ) @api_routing("/getStarred2") def get_starred_id3(): + mfid = request.values.get("musicFolderId") + root = get_root_folder(mfid) + + arq = select(s.starred for s in StarredArtist if s.user.id == request.user.id) + alq = select(s.starred for s in StarredAlbum if s.user.id == request.user.id) + trq = select(s.starred for s in StarredTrack if s.user.id == request.user.id) + + if root is not None: + arq = arq.filter(lambda a: root in a.tracks.root_folder) + alq = alq.filter(lambda a: root in a.tracks.root_folder) + trq = trq.filter(lambda t: t.root_folder == root) + return request.formatter( "starred2", { - "artist": [ - sa.as_subsonic_artist(request.user) - for sa in select( - s.starred for s in StarredArtist if s.user.id == request.user.id - ) - ], - "album": [ - sa.as_subsonic_album(request.user) - for sa in select( - s.starred for s in StarredAlbum if s.user.id == request.user.id - ) - ], - "song": [ - st.as_subsonic_child(request.user, request.client) - for st in select( - s.starred for s in StarredTrack if s.user.id == request.user.id - ) - ], + "artist": [sa.as_subsonic_artist(request.user) for sa in arq], + "album": [sa.as_subsonic_album(request.user) for sa in alq], + "song": [st.as_subsonic_child(request.user, request.client) for st in trq], }, ) diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index e278a390..c25746b4 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -9,11 +9,11 @@ import string from flask import current_app, request -from pony.orm import ObjectNotFound, select, count +from pony.orm import select, count from ..db import Folder, Artist, Album, Track -from . import get_entity, get_entity_id, api_routing +from . import get_entity, get_root_folder, api_routing @api_routing("/getMusicFolders") @@ -80,12 +80,7 @@ def list_indexes(): if musicFolderId is None: folders = Folder.select(lambda f: f.root)[:] else: - mfid = get_entity_id(Folder, musicFolderId) - folder = Folder[mfid] - if not folder.root: - raise ObjectNotFound(Folder, mfid) - - folders = [folder] + folders = [get_root_folder(musicFolderId)] last_modif = max(f.last_scan for f in folders) if ifModifiedSince is not None and last_modif < ifModifiedSince: @@ -153,8 +148,14 @@ def list_genres(): @api_routing("/getArtists") def list_artists(): - # According to the API page, there are no parameters? - indexes = build_indexes(Artist.select()) + mfid = request.values.get("musicFolderId") + + query = Artist.select() + if mfid is not None: + folder = get_root_folder(mfid) + query = Artist.select(lambda a: folder in a.tracks.root_folder) + + indexes = build_indexes(query) return request.formatter( "artists", { diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 4091e75d..605acda2 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -12,7 +12,7 @@ from ..db import Folder, Track, Artist, Album -from . import api_routing +from . import api_routing, get_root_folder from .exceptions import MissingParameter @@ -93,6 +93,7 @@ def new_search(): album_offset, song_count, song_offset, + mfid, ) = map( request.values.get, ( @@ -102,6 +103,7 @@ def new_search(): "albumOffset", "songCount", "songOffset", + "musicFolderId", ), ) @@ -111,14 +113,20 @@ def new_search(): album_offset = int(album_offset) if album_offset else 0 song_count = int(song_count) if song_count else 20 song_offset = int(song_offset) if song_offset else 0 + root = get_root_folder(mfid) - artists = select( - t.folder.parent for t in Track if query in t.folder.parent.name - ).limit(artist_count, artist_offset) - albums = select(t.folder for t in Track if query in t.folder.name).limit( - album_count, album_offset - ) - songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset) + artists = select(t.folder.parent for t in Track if query in t.folder.parent.name) + albums = select(t.folder for t in Track if query in t.folder.name) + songs = Track.select(lambda t: query in t.title) + + if root is not None: + artists = artists.where(lambda t: t.root_folder == root) + albums = albums.where(lambda t: t.root_folder == root) + songs = songs.where(lambda t: t.root_folder == root) + + artists = artists.limit(artist_count, artist_offset) + albums = albums.limit(album_count, album_offset) + songs = songs.limit(song_count, song_offset) return request.formatter( "searchResult2", @@ -145,6 +153,7 @@ def search_id3(): album_offset, song_count, song_offset, + mfid, ) = map( request.values.get, ( @@ -154,6 +163,7 @@ def search_id3(): "albumOffset", "songCount", "songOffset", + "musicFolderId", ), ) @@ -163,12 +173,20 @@ def search_id3(): album_offset = int(album_offset) if album_offset else 0 song_count = int(song_count) if song_count else 20 song_offset = int(song_offset) if song_offset else 0 + root = get_root_folder(mfid) - artists = Artist.select(lambda a: query in a.name).limit( - artist_count, artist_offset - ) - albums = Album.select(lambda a: query in a.name).limit(album_count, album_offset) - songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset) + artists = Artist.select(lambda a: query in a.name) + albums = Album.select(lambda a: query in a.name) + songs = Track.select(lambda t: query in t.title) + + if root is not None: + artists = artists.where(lambda a: root in a.tracks.root_folder) + albums = albums.where(lambda a: root in a.tracks.root_folder) + songs = songs.where(lambda t: t.root_folder == root) + + artists = artists.limit(artist_count, artist_offset) + albums = albums.limit(album_count, album_offset) + songs = songs.limit(song_count, song_offset) return request.formatter( "searchResult3", diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index 6f9ab505..7cca930f 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -20,7 +20,7 @@ class ApiTestBase(TestBase): __with_api__ = True - def setUp(self, apiVersion="1.10.2"): + def setUp(self, apiVersion="1.12.0"): super().setUp() self.apiVersion = apiVersion xsd = etree.parse( diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index c5da99b9..6e0302cc 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -23,6 +23,7 @@ def setUp(self): with db_session: folder = Folder(name="Root", root=True, path="tests/assets") + empty = Folder(name="Root", root=True, path="/tmp") artist = Artist(name="Artist") album = Album(name="Album", artist=artist) @@ -70,6 +71,12 @@ def test_get_album_list(self): error=0, ) self._make_request("getAlbumList", {"type": "byGenre"}, error=10) + self._make_request( + "getAlbumList", {"type": "random", "musicFolderId": "id"}, error=0 + ) + self._make_request( + "getAlbumList", {"type": "random", "musicFolderId": 12}, error=70 + ) types_and_count = [ ("random", 1), @@ -120,8 +127,21 @@ def test_get_album_list(self): ) self.assertEqual(len(child), 1) + _, child = self._make_request( + "getAlbumList", + {"musicFolderId": 1, "type": "alphabeticalByName"}, + tag="albumList", + ) + self.assertEqual(len(child), 1) + _, child = self._make_request( + "getAlbumList", + {"musicFolderId": 2, "type": "alphabeticalByName"}, + tag="albumList", + ) + self.assertEqual(len(child), 0) + with db_session: - Folder.get().delete() + Folder[1].delete() rv, child = self._make_request( "getAlbumList", {"type": "random"}, tag="albumList" ) @@ -143,6 +163,12 @@ def test_get_album_list2(self): error=0, ) self._make_request("getAlbumList2", {"type": "byGenre"}, error=10) + self._make_request( + "getAlbumList2", {"type": "random", "musicFolderId": "id"}, error=0 + ) + self._make_request( + "getAlbumList2", {"type": "random", "musicFolderId": 12}, error=70 + ) types = [ "random", @@ -192,6 +218,19 @@ def test_get_album_list2(self): ) self.assertEqual(len(child), 1) + _, child = self._make_request( + "getAlbumList2", + {"musicFolderId": 1, "type": "alphabeticalByName"}, + tag="albumList2", + ) + self.assertEqual(len(child), 1) + _, child = self._make_request( + "getAlbumList2", + {"musicFolderId": 2, "type": "alphabeticalByName"}, + tag="albumList2", + ) + self.assertEqual(len(child), 0) + with db_session: Track.select().delete() Album.get().delete() @@ -211,15 +250,13 @@ def test_get_random_songs(self): "getRandomSongs", tag="randomSongs", skip_post=True ) - with db_session: - fid = Folder.get().id self._make_request( "getRandomSongs", { "fromYear": -52, "toYear": "1984", "genre": "some cryptic subgenre youve never heard of", - "musicFolderId": fid, + "musicFolderId": 1, }, tag="randomSongs", ) @@ -229,9 +266,11 @@ def test_now_playing(self): def test_get_starred(self): self._make_request("getStarred", tag="starred") + self._make_request("getStarred", {"musicFolderId": 1}, tag="starred") def test_get_starred2(self): self._make_request("getStarred2", tag="starred2") + self._make_request("getStarred2", {"musicFolderId": 1}, tag="starred2") if __name__ == "__main__": diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index c976f6fb..bacb5bb9 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -21,14 +21,14 @@ def setUp(self): super().setUp() with db_session: - Folder(root=True, name="Empty root", path="/tmp") - root = Folder(root=True, name="Root folder", path="tests/assets") + self.empty_root = Folder(root=True, name="Empty root", path="/tmp") + self.root = Folder(root=True, name="Root folder", path="tests/assets") for letter in "ABC": folder = Folder( name=letter + "rtist", path="tests/assets/{}rtist".format(letter), - parent=root, + parent=self.root, ) artist = Artist(name=letter + "rtist") @@ -56,7 +56,7 @@ def setUp(self): letter, lether, song ), last_modification=0, - root_folder=root, + root_folder=self.root, folder=afolder, ) @@ -132,13 +132,26 @@ def test_get_artists(self): # same as getIndexes standard case # dataset should be improved to have a different directory structure than /root/Artist/Album/Track - rv, child = self._make_request("getArtists", tag="artists") + _, child = self._make_request("getArtists", tag="artists") self.assertEqual(len(child), 3) for i, letter in enumerate(["A", "B", "C"]): self.assertEqual(child[i].get("name"), letter) self.assertEqual(len(child[i]), 1) self.assertEqual(child[i][0].get("name"), letter + "rtist") + self._make_request("getArtists", {"musicFolderId": "id"}, error=0) + self._make_request("getArtists", {"musicFolderId": -3}, error=70) + + _, child = self._make_request( + "getArtists", {"musicFolderId": str(self.empty_root.id)}, tag="artists" + ) + self.assertEqual(len(child), 0) + + _, child = self._make_request( + "getArtists", {"musicFolderId": str(self.root.id)}, tag="artists" + ) + self.assertEqual(len(child), 3) + def test_get_artist(self): # dataset should be improved to have tracks by a different artist than the album's artist self._make_request("getArtist", error=10) diff --git a/tests/api/test_search.py b/tests/api/test_search.py index 77bece28..af633919 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -21,6 +21,7 @@ def setUp(self): with db_session: root = Folder(root=True, name="Root folder", path="tests/assets") + Folder(root=True, name="Empty", path="/tmp") for letter in "ABC": folder = Folder( @@ -58,7 +59,7 @@ def setUp(self): commit() - self.assertEqual(Folder.select().count(), 10) + self.assertEqual(Folder.select().count(), 11) self.assertEqual(Artist.select().count(), 3) self.assertEqual(Album.select().count(), 6) self.assertEqual(Track.select().count(), 18) @@ -196,6 +197,10 @@ def test_search2(self): self._make_request("search2", {"query": "a", "albumOffset": "sstring"}, error=0) self._make_request("search2", {"query": "a", "songCount": "string"}, error=0) self._make_request("search2", {"query": "a", "songOffset": "sstring"}, error=0) + self._make_request( + "search2", {"query": "a", "musicFolderId": "sstring"}, error=0 + ) + self._make_request("search2", {"query": "a", "musicFolderId": -2}, error=70) # no search self._make_request("search2", error=10) @@ -288,6 +293,17 @@ def test_search2(self): self.assertNotIn(song, songs) songs.append(song) + # root filtering + _, child = self._make_request( + "search2", {"query": "One", "musicFolderId": 1}, tag="searchResult2" + ) + self.assertEqual(len(self._xpath(child, "./song")), 6) + + _, child = self._make_request( + "search2", {"query": "One", "musicFolderId": 2}, tag="searchResult2" + ) + self.assertEqual(len(self._xpath(child, "./song")), 0) + # Almost identical as above. Test dataset (and tests) should probably be changed # to have folders that don't share names with artists or albums def test_search3(self): @@ -300,6 +316,10 @@ def test_search3(self): self._make_request("search3", {"query": "a", "albumOffset": "sstring"}, error=0) self._make_request("search3", {"query": "a", "songCount": "string"}, error=0) self._make_request("search3", {"query": "a", "songOffset": "sstring"}, error=0) + self._make_request( + "search3", {"query": "a", "musicFolderId": "sstring"}, error=0 + ) + self._make_request("search3", {"query": "a", "musicFolderId": -2}, error=70) # no search self._make_request("search3", error=10) @@ -392,6 +412,17 @@ def test_search3(self): self.assertNotIn(song, songs) songs.append(song) + # root filtering + _, child = self._make_request( + "search3", {"query": "One", "musicFolderId": 1}, tag="searchResult3" + ) + self.assertEqual(len(self._xpath(child, "./song")), 6) + + _, child = self._make_request( + "search3", {"query": "One", "musicFolderId": 2}, tag="searchResult3" + ) + self.assertEqual(len(self._xpath(child, "./song")), 0) + if __name__ == "__main__": unittest.main() diff --git a/tests/assets/subsonic-rest-api-1.10.2.xsd b/tests/assets/subsonic-rest-api-1.12.0.xsd similarity index 86% rename from tests/assets/subsonic-rest-api-1.10.2.xsd rename to tests/assets/subsonic-rest-api-1.12.0.xsd index a409c45a..61043487 100644 --- a/tests/assets/subsonic-rest-api-1.10.2.xsd +++ b/tests/assets/subsonic-rest-api-1.12.0.xsd @@ -4,7 +4,7 @@ targetNamespace="http://subsonic.org/restapi" attributeFormDefault="unqualified" elementFormDefault="qualified" - version="1.10.2"> + version="1.12.0"> @@ -39,9 +39,14 @@ + + + + + @@ -287,11 +292,12 @@ - + + @@ -426,6 +432,17 @@ + + + + + + + + + + + @@ -454,6 +471,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -476,8 +536,11 @@ + + + - + From 3b0aa20b515889e31a2663cc2bd6a08826c92c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Sep 2022 15:58:28 +0200 Subject: [PATCH 111/237] Advertise hls endpoint as not supported --- docs/api.rst | 2 +- supysonic/api/unsupported.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index a1c51e91..7c4a7faf 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -72,7 +72,7 @@ All methods / pseudo-TOC deletePlaylist_ ✔️ stream_ ✔️ download_ ✔️ - hls_ 1.9.0 🔴 + hls_ 1.9.0 ❌ getCaptions_ 1.15.0 🔴 getCoverArt_ ✔️ getLyrics_ ✔️ diff --git a/supysonic/api/unsupported.py b/supysonic/api/unsupported.py index ee5a4151..3a8e3108 100644 --- a/supysonic/api/unsupported.py +++ b/supysonic/api/unsupported.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2018-2020 Alban 'spl0k' Féron +# Copyright (C) 2018-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -15,6 +15,7 @@ "createShare", "updateShare", "deleteShare", + "hls", ) From 9db3549734fb02092e571c6b6f6cae1af1be0787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 1 Oct 2022 14:02:16 +0200 Subject: [PATCH 112/237] Bump API version to 1.12.0 Ref #235 --- supysonic/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index e40e65b7..afff6e9a 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -5,7 +5,7 @@ # # Distributed under terms of the GNU AGPLv3 license. -API_VERSION = "1.10.2" +API_VERSION = "1.12.0" import binascii import uuid From 0b6891a5c4c9d11c6790896622e7780a223702d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 27 Nov 2022 16:37:56 +0100 Subject: [PATCH 113/237] Redefined models using peewee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obviously untested, and breaks everything 🙃 --- setup.cfg | 2 +- supysonic/db.py | 317 +++++++++++++++++++----------------------------- 2 files changed, 126 insertions(+), 193 deletions(-) diff --git a/setup.cfg b/setup.cfg index 341be85d..8e13d85a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,7 +53,7 @@ python_requires = >=3.6,<3.11 install_requires = click flask >=0.11 - pony >=0.7.6 + peewee Pillow requests >=1.0.0 mediafile diff --git a/supysonic/db.py b/supysonic/db.py index 9c8a4fb2..607f39d5 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -13,11 +13,20 @@ from datetime import datetime from hashlib import sha1 -from pony.orm import Database, Required, Optional, Set, PrimaryKey, LongStr -from pony.orm import ObjectNotFound, DatabaseError -from pony.orm import buffer -from pony.orm import min, avg, sum, count, exists -from pony.orm import db_session +from peewee import ( + AutoField, + BinaryUUIDField, + BlobField, + BooleanField, + CharField, + DateTimeField, + FixedCharField, + ForeignKeyField, + IntegerField, + TextField, +) +from peewee import CompositeKey +from playhouse.flask_utils import FlaskDB from urllib.parse import urlparse, parse_qsl from uuid import UUID, uuid4 @@ -28,22 +37,16 @@ def now(): return datetime.now().replace(microsecond=0) -metadb = Database() +def PrimaryKeyField(**kwargs): + return BinaryUUIDField(primary_key=True, default=uuid4, **kwargs) -class Meta(metadb.Entity): - _table_ = "meta" - key = PrimaryKey(str, 32) - value = Required(str, 256) +db = FlaskDB() -db = Database() - - -@db.on_connect(provider="sqlite") -def sqlite_case_insensitive_like(db, connection): - cursor = connection.cursor() - cursor.execute("PRAGMA case_sensitive_like = OFF") +class Meta(db.Model): + key = CharField(32, primary_key=True) + value = CharField(256) class PathMixin: @@ -53,43 +56,32 @@ def get(cls, *args, **kwargs): path = kwargs.pop("path", None) if path: kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest() - return db.Entity.get.__func__(cls, *args, **kwargs) + return db.Model.get.__func__(cls, *args, **kwargs) def __init__(self, *args, **kwargs): path = kwargs["path"] kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest() - db.Entity.__init__(self, *args, **kwargs) + db.Model.__init__(self, *args, **kwargs) def __setattr__(self, attr, value): - db.Entity.__setattr__(self, attr, value) + db.Model.__setattr__(self, attr, value) if attr == "path": - db.Entity.__setattr__( + db.Model.__setattr__( self, "_path_hash", sha1(value.encode("utf-8")).digest() ) -class Folder(PathMixin, db.Entity): - _table_ = "folder" - - id = PrimaryKey(int, auto=True) - root = Required(bool, default=False) - name = Required(str, autostrip=False) - path = Required(str, 4096, autostrip=False) # unique - _path_hash = Required(buffer, column="path_hash") - created = Required(datetime, precision=0, default=now) - cover_art = Optional(str, nullable=True, autostrip=False) - last_scan = Required(int, default=0) - - parent = Optional(lambda: Folder, reverse="children", column="parent_id") - children = Set(lambda: Folder, reverse="parent") +class Folder(PathMixin, db.Model): + id = AutoField() + root = BooleanField() + name = CharField() + path = CharField(4096) # unique + _path_hash = BlobField(column_name="path_hash", unique=True) + created = DateTimeField(default=now) + cover_art = CharField(null=True) + last_scan = IntegerField(default=0) - __alltracks = Set( - lambda: Track, lazy=True, reverse="root_folder" - ) # Never used, hide it. Could be huge, lazy load - tracks = Set(lambda: Track, reverse="folder") - - stars = Set(lambda: StarredFolder) - ratings = Set(lambda: RatingFolder) + parent = ForeignKeyField("self", null=True, backref="children") def as_subsonic_child(self, user): info = { @@ -172,15 +164,9 @@ def prune(cls): return total -class Artist(db.Entity): - _table_ = "artist" - - id = PrimaryKey(UUID, default=uuid4) - name = Required(str) # unique - albums = Set(lambda: Album) - tracks = Set(lambda: Track) - - stars = Set(lambda: StarredArtist) +class Artist(db.Model): + id = PrimaryKeyField() + name = CharField() def as_subsonic_artist(self, user): info = { @@ -206,15 +192,10 @@ def prune(cls): ).delete() -class Album(db.Entity): - _table_ = "album" - - id = PrimaryKey(UUID, default=uuid4) - name = Required(str) - artist = Required(Artist, column="artist_id") - tracks = Set(lambda: Track) - - stars = Set(lambda: StarredAlbum) +class Album(db.Model): + id = PrimaryKeyField() + name = CharField() + artist = ForeignKeyField(Artist, backref="albums") def as_subsonic_album(self, user): # "AlbumID3" type in XSD info = { @@ -263,38 +244,31 @@ def prune(cls): ).delete() -class Track(PathMixin, db.Entity): - _table_ = "track" - - id = PrimaryKey(UUID, default=uuid4) - disc = Required(int) - number = Required(int) - title = Required(str) - year = Optional(int) - genre = Optional(str, nullable=True) - duration = Required(int) - has_art = Required(bool, default=False) - - album = Required(Album, column="album_id") - artist = Required(Artist, column="artist_id") +class Track(PathMixin, db.Model): + id = PrimaryKeyField() + disc = IntegerField() + number = IntegerField() + title = CharField() + year = IntegerField(null=True) + genre = CharField(null=True) + duration = IntegerField() + has_art = BooleanField(default=False) - bitrate = Required(int) + album = ForeignKeyField(Album, backref="tracks") + artist = ForeignKeyField(Artist, backref="tracks") - path = Required(str, 4096, autostrip=False) # unique - _path_hash = Required(buffer, column="path_hash") - created = Required(datetime, precision=0, default=now) - last_modification = Required(int) + bitrate = IntegerField() - play_count = Required(int, default=0) - last_play = Optional(datetime, precision=0) + path = CharField(4096) # unique + _path_hash = BlobField(column_name="path_hash", unique=True) + created = DateTimeField(default=now) + last_modification = IntegerField() - root_folder = Required(Folder, column="root_folder_id") - folder = Required(Folder, column="folder_id") + play_count = IntegerField(default=0) + last_play = DateTimeField(null=True) - __lastly_played_by = Set(lambda: User) # Never used, hide it - - stars = Set(lambda: StarredTrack) - ratings = Set(lambda: RatingTrack) + root_folder = ForeignKeyField(Folder, backref="+") + folder = ForeignKeyField(Folder, backref="tracks") def as_subsonic_child(self, user, prefs): info = { @@ -374,36 +348,23 @@ def sort_key(self): return f"{self.album.artist.name}{self.album.name}{self.disc:02}{self.number:02}{self.title}".lower() -class User(db.Entity): - _table_ = "user" - - id = PrimaryKey(UUID, default=uuid4) - name = Required(str, 64) # unique - mail = Optional(str) - password = Required(str, 40) - salt = Required(str, 6) +class User(db.Model): + id = PrimaryKeyField() + name = CharField(64, unique=True) + mail = CharField(null=True) + password = FixedCharField(40) + salt = FixedCharField(6) - admin = Required(bool, default=False) - jukebox = Required(bool, default=False) + admin = BooleanField(default=False) + jukebox = BooleanField(default=False) - lastfm_session = Optional(str, 32, nullable=True) - lastfm_status = Required( - bool, default=True + lastfm_session = FixedCharField(32, null=True) + lastfm_status = BooleanField( + default=True ) # True: ok/unlinked, False: invalid session - last_play = Optional(Track, column="last_play_id") - last_play_date = Optional(datetime, precision=0) - - clients = Set(lambda: ClientPrefs) - playlists = Set(lambda: Playlist) - __messages = Set(lambda: ChatMessage, lazy=True) # Never used, hide it - - starred_folders = Set(lambda: StarredFolder, lazy=True) - starred_artists = Set(lambda: StarredArtist, lazy=True) - starred_albums = Set(lambda: StarredAlbum, lazy=True) - starred_tracks = Set(lambda: StarredTrack, lazy=True) - folder_ratings = Set(lambda: RatingFolder, lazy=True) - track_ratings = Set(lambda: RatingTrack, lazy=True) + last_play = ForeignKeyField(Track, null=True, backref="+") + last_play_date = DateTimeField(null=True) def as_subsonic_user(self): return { @@ -424,81 +385,57 @@ def as_subsonic_user(self): } -class ClientPrefs(db.Entity): - _table_ = "client_prefs" - - user = Required(User, column="user_id") - client_name = Required(str, 32) - PrimaryKey(user, client_name) - format = Optional(str, 8, nullable=True) - bitrate = Optional(int) - - -class StarredFolder(db.Entity): - _table_ = "starred_folder" - - user = Required(User, column="user_id") - starred = Required(Folder, column="starred_id") - date = Required(datetime, precision=0, default=now) - - PrimaryKey(user, starred) - +class ClientPrefs(db.Model): + user = ForeignKeyField(User, backref="clients") + client_name = CharField(32) + format = CharField(8, null=True) + bitrate = IntegerField(null=True) -class StarredArtist(db.Entity): - _table_ = "starred_artist" + class Meta: + primary_key = CompositeKey("user", "client_name") - user = Required(User, column="user_id") - starred = Required(Artist, column="starred_id") - date = Required(datetime, precision=0, default=now) - PrimaryKey(user, starred) +def _make_starred_model(target_model): + class Starred(db.Model): + user = ForeignKeyField(User, backref="+") + starred = ForeignKeyField(target_model, backref="+") + date = DateTimeField(default=now) + class Meta: + primary_key = CompositeKey("user", "starred") + table_name = "starred_" + target_model._meta.table_name -class StarredAlbum(db.Entity): - _table_ = "starred_album" + return Starred - user = Required(User, column="user_id") - starred = Required(Album, column="starred_id") - date = Required(datetime, precision=0, default=now) - PrimaryKey(user, starred) +StarredFolder = _make_starred_model(Folder) +StarredArtist = _make_starred_model(Artist) +StarredAlbum = _make_starred_model(Album) +StarredTrack = _make_starred_model(Track) -class StarredTrack(db.Entity): - _table_ = "starred_track" +def _make_rating_model(target_model): + class Rating(db.Model): + user = ForeignKeyField(User, backref="+") + rated = ForeignKeyField(target_model, backref="+") + rating = IntegerField() # min=1, max=5 - user = Required(User, column="user_id") - starred = Required(Track, column="starred_id") - date = Required(datetime, precision=0, default=now) + class Meta: + primary_key = CompositeKey("user", "rated") + table_name = "rating_" + target_model._meta.table_name - PrimaryKey(user, starred) + return Rating -class RatingFolder(db.Entity): - _table_ = "rating_folder" - user = Required(User, column="user_id") - rated = Required(Folder, column="rated_id") - rating = Required(int, min=1, max=5) +RatingFolder = _make_rating_model(Folder) +RatingTrack = _make_rating_model(Track) - PrimaryKey(user, rated) - -class RatingTrack(db.Entity): - _table_ = "rating_track" - user = Required(User, column="user_id") - rated = Required(Track, column="rated_id") - rating = Required(int, min=1, max=5) - - PrimaryKey(user, rated) - - -class ChatMessage(db.Entity): - _table_ = "chat_message" - - id = PrimaryKey(UUID, default=uuid4) - user = Required(User, column="user_id") - time = Required(int, default=lambda: int(time.time())) - message = Required(str, 512) +class ChatMessage(db.Model): + id = PrimaryKeyField() + user = ForeignKeyField(User, backref="+") + time = IntegerField(default=lambda: int(time.time())) + message = CharField(512) def responsize(self): return { @@ -508,16 +445,14 @@ def responsize(self): } -class Playlist(db.Entity): - _table_ = "playlist" - - id = PrimaryKey(UUID, default=uuid4) - user = Required(User, column="user_id") - name = Required(str) - comment = Optional(str) - public = Required(bool, default=False) - created = Required(datetime, precision=0, default=now) - tracks = Optional(LongStr) +class Playlist(db.Model): + id = PrimaryKeyField() + user = ForeignKeyField(User, backref="playlists") + name = CharField() + comment = CharField(null=True) + public = BooleanField(default=False) + created = DateTimeField(default=now) + tracks = TextField(null=True) def as_subsonic_playlist(self, user): tracks = self.get_tracks() @@ -583,14 +518,12 @@ def remove_at_indexes(self, indexes): self.tracks = ",".join(t for t in tracks if t) -class RadioStation(db.Entity): - _table_ = "radio_station" - - id = PrimaryKey(UUID, default=uuid4) - stream_url = Required(str) - name = Required(str) - homepage_url = Optional(str, nullable=True) - created = Required(datetime, precision=0, default=now) +class RadioStation(db.Model): + id = PrimaryKeyField() + stream_url = CharField() + name = CharField() + homepage_url = CharField(null=True) + created = DateTimeField(default=now) def as_subsonic_station(self): info = { From 6bdee81e57eb95ccbe9f2fb6605d09d04c88b8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Dec 2022 15:14:37 +0100 Subject: [PATCH 114/237] Fixing a good chunk of supysonic.db --- supysonic/db.py | 194 ++++++++++++++++++------------------------ tests/base/test_db.py | 71 ++++++---------- 2 files changed, 108 insertions(+), 157 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index 607f39d5..3fef24fe 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -25,9 +25,10 @@ IntegerField, TextField, ) -from peewee import CompositeKey -from playhouse.flask_utils import FlaskDB -from urllib.parse import urlparse, parse_qsl +from peewee import CompositeKey, DatabaseProxy +from peewee import fn +from playhouse.db_url import parseresult_to_dict, schemes +from urllib.parse import urlparse from uuid import UUID, uuid4 SCHEMA_VERSION = "20200607" @@ -41,7 +42,7 @@ def PrimaryKeyField(**kwargs): return BinaryUUIDField(primary_key=True, default=uuid4, **kwargs) -db = FlaskDB() +db = DatabaseProxy() class Meta(db.Model): @@ -105,16 +106,20 @@ def as_subsonic_child(self, user): try: starred = StarredFolder[user.id, self.id] info["starred"] = starred.date.isoformat() - except ObjectNotFound: + except StarredFolder.DoesNotExist: pass try: rating = RatingFolder[user.id, self.id] info["userRating"] = rating.rating - except ObjectNotFound: + except RatingFolder.DoesNotExist: pass - avgRating = avg(self.ratings.rating) + avgRating = ( + RatingFolder.select(fn.avg(RatingFolder.rating)) + .where(RatingFolder.rated == self) + .scalar() + ) if avgRating: info["averageRating"] = avgRating @@ -126,7 +131,7 @@ def as_subsonic_artist(self, user): # "Artist" type in XSD try: starred = StarredFolder[user.id, self.id] info["starred"] = starred.date.isoformat() - except ObjectNotFound: + except StarredFolder.DoesNotExist: pass return info @@ -179,7 +184,7 @@ def as_subsonic_artist(self, user): try: starred = StarredArtist[user.id, self.id] info["starred"] = starred.date.isoformat() - except ObjectNotFound: + except StarredArtist.DoesNotExist: pass return info @@ -198,43 +203,53 @@ class Album(db.Model): artist = ForeignKeyField(Artist, backref="albums") def as_subsonic_album(self, user): # "AlbumID3" type in XSD + duration, created, year = self.tracks.select( + fn.sum(Track.duration), fn.min(Track.created), fn.min(Track.year) + ).scalar(as_tuple=True) + info = { "id": str(self.id), "name": self.name, "artist": self.artist.name, "artistId": str(self.artist.id), "songCount": self.tracks.count(), - "duration": sum(self.tracks.duration), - "created": min(self.tracks.created).isoformat(), + "duration": duration, + "created": created.isoformat(), } - track_with_cover = self.tracks.select( - lambda t: t.folder.cover_art is not None - ).first() + track_with_cover = ( + self.tracks.join(Folder).where(Folder.cover_art.is_null(False)).first() + ) if track_with_cover is not None: info["coverArt"] = str(track_with_cover.folder.id) else: - track_with_cover = self.tracks.select(lambda t: t.has_art).first() + track_with_cover = self.tracks.where(Track.has_art).first() if track_with_cover is not None: info["coverArt"] = str(track_with_cover.id) - if count(self.tracks.year) > 0: - info["year"] = min(self.tracks.year) + if year: + info["year"] = year - genre = ", ".join(self.tracks.genre.distinct()) + genre = ", ".join( + g + for (g,) in self.tracks.select(Track.genre) + .where(Track.genre.is_null(False)) + .distinct() + .tuples() + ) if genre: info["genre"] = genre try: starred = StarredAlbum[user.id, self.id] info["starred"] = starred.date.isoformat() - except ObjectNotFound: + except StarredAlbum.DoesNotExist: pass return info def sort_key(self): - year = min(t.year if t.year else 9999 for t in self.tracks) + year = self.tracks.select(fn.min(Track.year)).scalar() or 9999 return f"{year}{self.name.lower()}" @classmethod @@ -305,16 +320,20 @@ def as_subsonic_child(self, user, prefs): try: starred = StarredTrack[user.id, self.id] info["starred"] = starred.date.isoformat() - except ObjectNotFound: + except StarredTrack.DoesNotExist: pass try: rating = RatingTrack[user.id, self.id] info["userRating"] = rating.rating - except ObjectNotFound: + except RatingTrack.DoesNotExist: pass - avgRating = avg(self.ratings.rating) + avgRating = ( + RatingTrack.select(fn.avg(RatingTrack.rating)) + .where(RatingTrack.rated == self) + .scalar() + ) if avgRating: info["averageRating"] = avgRating @@ -483,7 +502,7 @@ def get_tracks(self): tid = UUID(t) track = Track[tid] tracks.append(track) - except (ValueError, ObjectNotFound): + except (ValueError, Track.DoesNotExist): should_fix = True if should_fix: @@ -535,108 +554,61 @@ def as_subsonic_station(self): return info -def parse_uri(database_uri): - if not isinstance(database_uri, str): - raise TypeError("Expecting a string") - - uri = urlparse(database_uri) - args = dict(parse_qsl(uri.query)) - if uri.port is not None: - args["port"] = uri.port - - if uri.scheme == "sqlite": - path = uri.path - if not path: - path = ":memory:" - elif path[0] == "/": - path = path[1:] - - return {"provider": "sqlite", "filename": path, "create_db": True, **args} - elif uri.scheme in ("postgres", "postgresql"): - return { - "provider": "postgres", - "user": uri.username, - "password": uri.password, - "host": uri.hostname, - "dbname": uri.path[1:], - **args, - } - elif uri.scheme == "mysql": - args.setdefault("charset", "utf8mb4") - args.setdefault("binary_prefix", True) - return { - "provider": "mysql", - "user": uri.username, - "passwd": uri.password, - "host": uri.hostname, - "db": uri.path[1:], - **args, - } - return {} - - def execute_sql_resource_script(respath): sql = pkg_resources.resource_string(__package__, respath).decode("utf-8") for statement in sql.split(";"): statement = statement.strip() if statement and not statement.startswith("--"): - metadb.execute(statement) + db.execute_sql(statement) def init_database(database_uri): - settings = parse_uri(database_uri) + uri = urlparse(database_uri) + args = parseresult_to_dict(uri) + if uri.scheme.startswith("mysql"): + args.setdefault("charset", "utf8mb4") + args.setdefault("binary_prefix", True) - metadb.bind(**settings) - metadb.generate_mapping(check_tables=False) + if uri.scheme.startswith("mysql"): + provider = "mysql" + elif uri.scheme.startswith("postgres"): + provider = "postgres" + elif uri.scheme.startswith("sqlite"): + provider = "sqlite" + else: + raise RuntimeError(f"Unsupported database: {uri.scheme}") + + db_class = schemes.get(uri.scheme) + db.initialize(db_class(**args)) + db.connect() # Check if we should create the tables - try: - metadb.check_tables() - except DatabaseError: - with db_session: - execute_sql_resource_script("schema/" + settings["provider"] + ".sql") - Meta(key="schema_version", value=SCHEMA_VERSION) + if not db.table_exists("meta"): + execute_sql_resource_script(f"schema/{provider}.sql") + Meta.create(key="schema_version", value=SCHEMA_VERSION) # Check for schema changes - with db_session: - version = Meta["schema_version"] - if version.value < SCHEMA_VERSION: - migrations = sorted( - pkg_resources.resource_listdir( - __package__, "schema/migration/" + settings["provider"] + version = Meta["schema_version"] + if version.value < SCHEMA_VERSION: + migrations = sorted( + pkg_resources.resource_listdir(__package__, f"schema/migration/{provider}") + ) + for migration in migrations: + date, ext = os.path.splitext(migration) + if date <= version.value: + continue + if ext == ".sql": + execute_sql_resource_script(f"schema/migration/{provider}/{migration}") + elif ext == ".py": + m = importlib.import_module( + f".schema.migration.{provider}.{date}", __package__ ) - ) - for migration in migrations: - date, ext = os.path.splitext(migration) - if date <= version.value: - continue - if ext == ".sql": - execute_sql_resource_script( - "schema/migration/{}/{}".format(settings["provider"], migration) - ) - elif ext == ".py": - m = importlib.import_module( - ".schema.migration.{}.{}".format(settings["provider"], date), - __package__, - ) - m.apply(settings.copy()) - version.value = SCHEMA_VERSION - - # Hack for in-memory SQLite databases (used in tests), otherwise 'db' and 'metadb' would be two distinct databases - # and 'db' wouldn't have any table - if settings["provider"] == "sqlite" and settings["filename"] == ":memory:": - db.provider = metadb.provider - else: - metadb.disconnect() - db.bind(**settings) - # Force requests to Meta to use the same connection as other tables - metadb.provider = db.provider + m.apply(args.copy()) - db.generate_mapping(check_tables=False) + version.value = SCHEMA_VERSION + version.save() def release_database(): - metadb.disconnect() - db.disconnect() - db.provider = metadb.provider = None - db.schema = metadb.schema = None + db.close() + db.initialize(None) diff --git a/tests/base/test_db.py b/tests/base/test_db.py index 9fd5d099..7acbc345 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -10,7 +10,6 @@ import uuid from collections import namedtuple -from pony.orm import db_session from supysonic import db @@ -30,9 +29,9 @@ def tearDown(self): db.release_database() def create_some_folders(self): - root_folder = db.Folder(root=True, name="Root folder", path="tests") + root_folder = db.Folder.create(root=True, name="Root folder", path="tests") - db.Folder( + f1 = db.Folder.create( root=False, name="Child folder", path="tests/assets", @@ -40,30 +39,25 @@ def create_some_folders(self): parent=root_folder, ) - db.Folder( + f2 = db.Folder.create( root=False, name="Child folder (No Art)", path="tests/formats", parent=root_folder, ) - # Folder IDs don't get populated until we query the db. - return ( - db.Folder.get(name="Root folder"), - db.Folder.get(name="Child folder"), - db.Folder.get(name="Child Folder (No Art)"), - ) + return root_folder, f1, f2 def create_some_tracks(self, artist=None, album=None): root, child, child_2 = self.create_some_folders() if not artist: - artist = db.Artist(name="Test artist") + artist = db.Artist.create(name="Test artist") if not album: - album = db.Album(artist=artist, name="Test Album") + album = db.Album.create(artist=artist, name="Test Album") - track1 = db.Track( + track1 = db.Track.create( title="Track Title", album=album, artist=artist, @@ -78,7 +72,7 @@ def create_some_tracks(self, artist=None, album=None): folder=child, ) - track2 = db.Track( + track2 = db.Track.create( title="One Awesome Song", album=album, artist=artist, @@ -95,9 +89,9 @@ def create_some_tracks(self, artist=None, album=None): return track1, track2 def create_track_in(self, folder, root, artist=None, album=None, has_art=True): - artist = artist or db.Artist(name="Snazzy Artist") - album = album or db.Album(artist=artist, name="Rockin' Album") - return db.Track( + artist = artist or db.Artist.create(name="Snazzy Artist") + album = album or db.Album.create(artist=artist, name="Rockin' Album") + return db.Track.create( title="Nifty Number", album=album, artist=artist, @@ -113,15 +107,13 @@ def create_track_in(self, folder, root, artist=None, album=None, has_art=True): ) def create_user(self, name="Test User"): - return db.User(name=name, password="secret", salt="ABC+") + return db.User.create(name=name, password="secret", salt="ABC+") def create_playlist(self): - - playlist = db.Playlist(user=self.create_user(), name="Playlist!") + playlist = db.Playlist.create(user=self.create_user(), name="Playlist!") return playlist - @db_session def test_folder_base(self): root_folder, child_folder, child_noart = self.create_some_folders() track_embededart = self.create_track_in(child_noart, root_folder) @@ -153,15 +145,14 @@ def test_folder_base(self): self.assertIn("coverArt", noart) self.assertEqual(noart["coverArt"], str(track_embededart.id)) - @db_session def test_folder_annotation(self): root_folder, child_folder, _ = self.create_some_folders() user = self.create_user() - db.StarredFolder(user=user, starred=root_folder) - db.RatingFolder(user=user, rated=root_folder, rating=2) + db.StarredFolder.create(user=user, starred=root_folder) + db.RatingFolder.create(user=user, rated=root_folder, rating=2) other = self.create_user("Other") - db.RatingFolder(user=other, rated=root_folder, rating=5) + db.RatingFolder.create(user=other, rated=root_folder, rating=5) root = root_folder.as_subsonic_child(user) self.assertIn("starred", root) @@ -175,12 +166,11 @@ def test_folder_annotation(self): self.assertNotIn("starred", child) self.assertNotIn("userRating", child) - @db_session def test_artist(self): - artist = db.Artist(name="Test Artist") + artist = db.Artist.create(name="Test Artist") user = self.create_user() - db.StarredArtist(user=user, starred=artist) + db.StarredArtist.create(user=user, starred=artist) artist_dict = artist.as_subsonic_artist(user) self.assertIsInstance(artist_dict, dict) @@ -192,22 +182,18 @@ def test_artist(self): self.assertEqual(artist_dict["albumCount"], 0) self.assertRegex(artist_dict["starred"], date_regex) - db.Album(name="Test Artist", artist=artist) # self-titled - db.Album(name="The Album After The First One", artist=artist) + db.Album.create(name="Test Artist", artist=artist) # self-titled + db.Album.create(name="The Album After The First One", artist=artist) artist_dict = artist.as_subsonic_artist(user) self.assertEqual(artist_dict["albumCount"], 2) - @db_session def test_album(self): - artist = db.Artist(name="Test Artist") - album = db.Album(artist=artist, name="Test Album") + artist = db.Artist.create(name="Test Artist") + album = db.Album.create(artist=artist, name="Test Album") user = self.create_user() - db.StarredAlbum(user=user, starred=album) - - # No tracks, shouldn't be stored under normal circumstances - self.assertRaises(ValueError, album.as_subsonic_album, user) + db.StarredAlbum.create(user=user, starred=album) root_folder, folder_art, folder_noart = self.create_some_folders() track1 = self.create_track_in( @@ -234,7 +220,6 @@ def test_album(self): self.assertRegex(album_dict["created"], date_regex) self.assertRegex(album_dict["starred"], date_regex) - @db_session def test_track(self): track1, track2 = self.create_some_tracks() @@ -256,14 +241,12 @@ def test_track(self): self.assertEqual(track2_dict["coverArt"], track2_dict["parent"]) # ... we'll test the rest against the API XSD. - @db_session def test_user(self): user = self.create_user() user_dict = user.as_subsonic_user() self.assertIsInstance(user_dict, dict) - @db_session def test_chat(self): user = self.create_user() @@ -274,13 +257,11 @@ def test_chat(self): self.assertIn("username", line_dict) self.assertEqual(line_dict["username"], user.name) - @db_session def test_playlist(self): playlist = self.create_playlist() playlist_dict = playlist.as_subsonic_playlist(playlist.user) self.assertIsInstance(playlist_dict, dict) - @db_session def test_playlist_tracks(self): playlist = self.create_playlist() track1, track2 = self.create_some_tracks() @@ -304,7 +285,6 @@ def test_playlist_tracks(self): self.assertRaises(ValueError, playlist.add, "some string") self.assertRaises(NameError, playlist.add, 2345) - @db_session def test_playlist_remove_tracks(self): playlist = self.create_playlist() track1, track2 = self.create_some_tracks() @@ -324,7 +304,6 @@ def test_playlist_remove_tracks(self): playlist.remove_at_indexes([1, 1]) self.assertSequenceEqual(playlist.get_tracks(), [track2, track1]) - @db_session def test_playlist_fixing(self): playlist = self.create_playlist() track1, track2 = self.create_some_tracks() @@ -334,7 +313,7 @@ def test_playlist_fixing(self): playlist.add(track2) self.assertSequenceEqual(playlist.get_tracks(), [track1, track2]) - track2.delete() + track2.delete_instance() self.assertSequenceEqual(playlist.get_tracks(), [track1]) playlist.tracks = "{0},{0},some random garbage,{0}".format(track1.id) From 64cf272887dc3862808c499a84417e8527c7605b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Dec 2022 15:48:06 +0100 Subject: [PATCH 115/237] Fix supysonic.utils.get_secret_key --- supysonic/managers/user.py | 4 +--- supysonic/utils.py | 18 ++++++------------ supysonic/web.py | 8 +++----- tests/testbase.py | 9 +++------ 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index dceb237a..144386b0 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -11,8 +11,6 @@ import string import uuid -from pony.orm import ObjectNotFound - from ..db import User diff --git a/supysonic/utils.py b/supysonic/utils.py index 266da2b8..e8fa3914 100644 --- a/supysonic/utils.py +++ b/supysonic/utils.py @@ -1,13 +1,12 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019-2020 Alban 'spl0k' Féron +# Copyright (C) 2019-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. from base64 import b64encode, b64decode from os import urandom -from pony.orm import db_session, commit, ObjectNotFound from supysonic.db import Meta @@ -19,16 +18,11 @@ def get_secret_key(keyname): if keyname in __key_cache: return __key_cache[keyname] - with db_session(): - # Commit both at enter and exit. The metadb/db split (from supysonic.db) - # confuses Pony which can either error or hang when this method is called - commit() - try: - key = b64decode(Meta[keyname].value) - except ObjectNotFound: - key = urandom(128) - Meta(key=keyname, value=b64encode(key).decode()) - commit() + try: + key = b64decode(Meta[keyname].value) + except Meta.DoesNotExist: + key = urandom(128) + Meta.create(key=keyname, value=b64encode(key).decode()) __key_cache[keyname] = key return key diff --git a/supysonic/web.py b/supysonic/web.py index 9a128c96..eab41aed 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # 2018-2019 Carey 'pR0Ps' Metcalfe # 2017 Óscar García Amor # @@ -12,7 +12,6 @@ from flask import Flask from os import makedirs, path -from pony.orm import db_session from .config import IniConfig from .cache import Cache @@ -49,7 +48,6 @@ def create_application(config=None): # Initialize database init_database(app.config["BASE"]["database_uri"]) - app.wsgi_app = db_session(app.wsgi_app) # Insert unknown mimetypes for k, v in app.config["MIMETYPES"].items(): @@ -60,8 +58,8 @@ def create_application(config=None): # Initialize Cache objects # Max size is MB in the config file but Cache expects bytes cache_dir = app.config["WEBAPP"]["cache_dir"] - max_size_cache = app.config["WEBAPP"]["cache_size"] * 1024 ** 2 - max_size_transcodes = app.config["WEBAPP"]["transcode_cache_size"] * 1024 ** 2 + max_size_cache = app.config["WEBAPP"]["cache_size"] * 1024**2 + max_size_transcodes = app.config["WEBAPP"]["transcode_cache_size"] * 1024**2 app.cache = Cache(path.join(cache_dir, "cache"), max_size_cache) app.transcode_cache = Cache(path.join(cache_dir, "transcodes"), max_size_transcodes) diff --git a/tests/testbase.py b/tests/testbase.py index df0654e5..88ebb237 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -13,8 +13,6 @@ import tempfile import unittest -from pony.orm import db_session - from supysonic.db import init_database, release_database from supysonic.config import DefaultConfig from supysonic.managers.user import UserManager @@ -101,9 +99,8 @@ def setUp(self): self.__app = create_application(self.config) self.client = self.__app.test_client() - with db_session: - UserManager.add("alice", "Alic3", admin=True) - UserManager.add("bob", "B0b") + UserManager.add("alice", "Alic3", admin=True) + UserManager.add("bob", "B0b") def _patch_client(self): self.client.get = patch_method(self.client.get) From ccdd73f8a012d006549798b90b727417f1f613d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Dec 2022 16:55:02 +0100 Subject: [PATCH 116/237] Port supysonic.managers.folder.FolderManager --- supysonic/daemon/server.py | 6 +- supysonic/db.py | 12 ++- supysonic/jukebox.py | 14 ++-- supysonic/managers/folder.py | 52 +++++++------ supysonic/scanner.py | 6 -- supysonic/watcher.py | 1 - tests/managers/test_manager_folder.py | 103 ++++++++++++-------------- 7 files changed, 89 insertions(+), 105 deletions(-) diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index 5111aff9..dc63ee2e 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019 Alban 'spl0k' Féron +# Copyright (C) 2019-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -9,7 +9,6 @@ import time from multiprocessing.connection import Listener, Client -from pony.orm import db_session, select from threading import Thread, Event from .client import DaemonCommand @@ -73,8 +72,7 @@ def __listen(self): def start_scan(self, folders=[], force=False): if not folders: - with db_session: - folders = select(f.name for f in Folder if f.root)[:] + folders = Folder.select().where(Folder.root)[:] if self.__scanner is not None and self.__scanner.is_alive(): for f in folders: diff --git a/supysonic/db.py b/supysonic/db.py index 3fef24fe..a1f283a0 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -191,10 +191,10 @@ def as_subsonic_artist(self, user): @classmethod def prune(cls): - return cls.select( - lambda self: not exists(a for a in Album if a.artist == self) - and not exists(t for t in Track if t.artist == self) - ).delete() + cls.delete().where( + cls.id.not_in(Album.select(Album.artist)), + cls.id.not_in(Track.select(Track.artist)), + ).execute() class Album(db.Model): @@ -254,9 +254,7 @@ def sort_key(self): @classmethod def prune(cls): - return cls.select( - lambda self: not exists(t for t in Track if t.album == self) - ).delete() + cls.delete().where(cls.id.not_in(Track.select(Track.album))).execute() class Track(PathMixin, db.Model): diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 7de6f548..329ff6a2 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019 Alban 'spl0k' Féron +# Copyright (C) 2019-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -10,7 +10,6 @@ import time from datetime import datetime, timedelta -from pony.orm import db_session, ObjectNotFound from random import shuffle from subprocess import Popen, DEVNULL from threading import Thread, Event, RLock @@ -81,12 +80,11 @@ def skip(self, index, offset): def add(self, *tracks): with self.__lock: - with db_session: - for t in tracks: - try: - self.__playlist.append(Track[t].path) - except ObjectNotFound: - pass + for t in tracks: + try: + self.__playlist.append(Track[t].path) + except Track.DoesNotExist: + pass def clear(self): with self.__lock: diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index 2d6930f5..b4bc76a8 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -1,15 +1,12 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import os.path -from pony.orm import select -from pony.orm import ObjectNotFound - from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError from ..db import Folder, Track, Artist, Album, User, RatingTrack, StarredTrack @@ -27,20 +24,31 @@ def get(id): @staticmethod def add(name, path): - if Folder.get(name=name, root=True) is not None: + try: + Folder.get(name=name, root=True) raise ValueError("Folder '{}' exists".format(name)) + except Folder.DoesNotExist: + pass path = os.path.abspath(os.path.expanduser(path)) if not os.path.isdir(path): raise ValueError("The path doesn't exits or isn't a directory") - if Folder.get(path=path) is not None: + + try: + Folder.get(path=path) raise ValueError("This path is already registered") - if any(path.startswith(p) for p in select(f.path for f in Folder if f.root)): + except Folder.DoesNotExist: + pass + + if any( + path.startswith(p) + for (p,) in Folder.select(Folder.path).where(Folder.root).tuples() + ): raise ValueError("This path is already registered") - if Folder.exists(lambda f: f.path.startswith(path)): + if Folder.select().where(Folder.path.startswith(path)).exists(): raise ValueError("This path contains a folder that is already registered") - folder = Folder(root=True, name=name, path=path) + folder = Folder.create(root=True, name=name, path=path) try: DaemonClient().add_watched_folder(path) except DaemonUnavailableError: @@ -52,30 +60,30 @@ def add(name, path): def delete(id): folder = FolderManager.get(id) if not folder.root: - raise ObjectNotFound(Folder) + raise Folder.DoesNotExist(id) try: DaemonClient().remove_watched_folder(folder.path) except DaemonUnavailableError: pass - for user in User.select(lambda u: u.last_play.root_folder == folder): - user.last_play = None - RatingTrack.select(lambda r: r.rated.root_folder == folder).delete(bulk=True) - StarredTrack.select(lambda s: s.starred.root_folder == folder).delete(bulk=True) + users = User.select(User.id).join(Track).where(Track.root_folder == folder) + User.update(last_play=None).where(User.id.in_(users)).execute() - Track.select(lambda t: t.root_folder == folder).delete(bulk=True) + deleted_tracks_query = Track.select(Track.id).where(Track.root_folder == folder) + RatingTrack.delete().where( + RatingTrack.rated.in_(deleted_tracks_query) + ).execute() + StarredTrack.delete().where( + StarredTrack.starred.in_(deleted_tracks_query) + ).execute() + + Track.delete().where(Track.root_folder == folder).execute() Album.prune() Artist.prune() - Folder.select(lambda f: not f.root and f.path.startswith(folder.path)).delete( - bulk=True - ) - - folder.delete() + Folder.delete().where(Folder.path.startswith(folder.path)).execute() @staticmethod def delete_by_name(name): folder = Folder.get(name=name, root=True) - if not folder: - raise ObjectNotFound(Folder) FolderManager.delete(folder.id) diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 24e60b7b..2f48f4c0 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -12,7 +12,6 @@ import time from datetime import datetime -from pony.orm import db_session from queue import Queue, Empty as QueueEmpty from threading import Thread, Event @@ -189,7 +188,6 @@ def __check_extension(self, path): return True return os.path.splitext(path)[1][1:].lower() in self.__extensions - @db_session def scan_file(self, path_or_direntry): if isinstance(path_or_direntry, str): path = path_or_direntry @@ -273,7 +271,6 @@ def scan_file(self, path_or_direntry): # Field validation error self.__stats.errors.append(path) - @db_session def remove_file(self, path): if not isinstance(path, str): raise TypeError("Expecting string, got " + str(type(path))) @@ -285,7 +282,6 @@ def remove_file(self, path): self.__stats.deleted.tracks += 1 tr.delete() - @db_session def move_file(self, src_path, dst_path): if not isinstance(src_path, str): raise TypeError("Expecting string, got " + str(type(src_path))) @@ -313,7 +309,6 @@ def move_file(self, src_path, dst_path): tr.folder = folder tr.path = dst_path - @db_session def find_cover(self, dirpath): if not isinstance(dirpath, str): # pragma: nocover raise TypeError("Expecting string, got " + str(type(dirpath))) @@ -333,7 +328,6 @@ def find_cover(self, dirpath): cover = find_cover_in_folder(folder.path, album_name) folder.cover_art = cover.name if cover is not None else None - @db_session def add_cover(self, path): if not isinstance(path, str): # pragma: nocover raise TypeError("Expecting string, got " + str(type(path))) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 64b90011..b90f66fe 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -9,7 +9,6 @@ import os.path import time -from pony.orm import db_session from threading import Thread, Condition, Timer from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler diff --git a/tests/managers/test_manager_folder.py b/tests/managers/test_manager_folder.py index b9e86edd..cccb47b7 100644 --- a/tests/managers/test_manager_folder.py +++ b/tests/managers/test_manager_folder.py @@ -1,12 +1,12 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. -from supysonic import db +from supysonic.db import Folder, Album, Artist, Track, init_database, release_database from supysonic.managers.folder import FolderManager import os @@ -14,20 +14,18 @@ import tempfile import unittest -from pony.orm import db_session, ObjectNotFound - class FolderManagerTestCase(unittest.TestCase): def setUp(self): # Create an empty sqlite database in memory - db.init_database("sqlite:") + init_database("sqlite:") # Create some temporary directories self.media_dir = tempfile.mkdtemp() self.music_dir = tempfile.mkdtemp() def tearDown(self): - db.release_database() + release_database() shutil.rmtree(self.media_dir) shutil.rmtree(self.music_dir) @@ -36,15 +34,15 @@ def create_folders(self): self.assertIsNotNone(FolderManager.add("media", self.media_dir)) self.assertIsNotNone(FolderManager.add("music", self.music_dir)) - db.Folder( + Folder.create( root=False, name="non-root", path=os.path.join(self.music_dir, "subfolder") ) - artist = db.Artist(name="Artist") - album = db.Album(name="Album", artist=artist) + artist = Artist.create(name="Artist") + album = Album.create(name="Album", artist=artist) - root = db.Folder.get(name="media") - db.Track( + root = Folder.get(name="media") + Track( title="Track", artist=artist, album=album, @@ -58,95 +56,86 @@ def create_folders(self): last_modification=0, ) - @db_session def test_get_folder(self): self.create_folders() # Get existing folders for name in ["media", "music"]: - folder = db.Folder.get(name=name, root=True) + folder = Folder.get(name=name, root=True) self.assertEqual(FolderManager.get(folder.id), folder) - # Get with invalid UUID + # Get with invalid id self.assertRaises(ValueError, FolderManager.get, "invalid-uuid") - self.assertRaises(ValueError, FolderManager.get, 0xDEADBEEF) # Non-existent folder - self.assertRaises(ObjectNotFound, FolderManager.get, 1234567890) + self.assertRaises(Folder.DoesNotExist, FolderManager.get, 1234567890) - @db_session def test_add_folder(self): self.create_folders() - self.assertEqual(db.Folder.select().count(), 3) + self.assertEqual(Folder.select().count(), 3) # Create duplicate self.assertRaises(ValueError, FolderManager.add, "media", self.media_dir) - self.assertEqual(db.Folder.select(lambda f: f.name == "media").count(), 1) + self.assertEqual(Folder.select().where(Folder.name == "media").count(), 1) # Duplicate path self.assertRaises(ValueError, FolderManager.add, "new-folder", self.media_dir) self.assertEqual( - db.Folder.select(lambda f: f.path == self.media_dir).count(), 1 + Folder.select().where(Folder.path == self.media_dir).count(), 1 ) # Invalid path path = os.path.abspath("/this/not/is/valid") self.assertRaises(ValueError, FolderManager.add, "invalid-path", path) - self.assertFalse(db.Folder.exists(path=path)) + self.assertFalse(Folder.select().where(Folder.path == path).exists()) # Subfolder of already added path path = os.path.join(self.media_dir, "subfolder") os.mkdir(path) self.assertRaises(ValueError, FolderManager.add, "subfolder", path) - self.assertEqual(db.Folder.select().count(), 3) + self.assertEqual(Folder.select().count(), 3) # Parent folder of an already added path path = os.path.join(self.media_dir, "..") self.assertRaises(ValueError, FolderManager.add, "parent", path) - self.assertEqual(db.Folder.select().count(), 3) + self.assertEqual(Folder.select().count(), 3) def test_delete_folder(self): - with db_session: - self.create_folders() + self.create_folders() - with db_session: - # Delete invalid Folder ID - self.assertRaises(ValueError, FolderManager.delete, "invalid-uuid") - self.assertEqual(db.Folder.select().count(), 3) + # Delete invalid Folder ID + self.assertRaises(ValueError, FolderManager.delete, "invalid-uuid") + self.assertEqual(Folder.select().count(), 3) - # Delete non-existent folder - self.assertRaises(ObjectNotFound, FolderManager.delete, 1234567890) - self.assertEqual(db.Folder.select().count(), 3) + # Delete non-existent folder + self.assertRaises(Folder.DoesNotExist, FolderManager.delete, 1234567890) + self.assertEqual(Folder.select().count(), 3) - # Delete non-root folder - folder = db.Folder.get(name="non-root") - self.assertRaises(ObjectNotFound, FolderManager.delete, folder.id) - self.assertEqual(db.Folder.select().count(), 3) + # Delete non-root folder + folder = Folder.get(name="non-root") + self.assertRaises(Folder.DoesNotExist, FolderManager.delete, folder.id) + self.assertEqual(Folder.select().count(), 3) - with db_session: - # Delete existing folders - for name in ["media", "music"]: - folder = db.Folder.get(name=name, root=True) - FolderManager.delete(folder.id) - self.assertRaises(ObjectNotFound, db.Folder.__getitem__, folder.id) + # Delete existing folders + for name in ["media", "music"]: + folder = Folder.get(name=name, root=True) + FolderManager.delete(folder.id) + self.assertRaises(Folder.DoesNotExist, Folder.__getitem__, folder.id) - # Even if we have only 2 root folders, non-root should never exist and be cleaned anyway - self.assertEqual(db.Folder.select().count(), 0) + # Even if we have only 2 root folders, non-root should never exist and be cleaned anyway + self.assertEqual(Folder.select().count(), 0) def test_delete_by_name(self): - with db_session: - self.create_folders() - - with db_session: - # Delete non-existent folder - self.assertRaises(ObjectNotFound, FolderManager.delete_by_name, "null") - self.assertEqual(db.Folder.select().count(), 3) - - with db_session: - # Delete existing folders - for name in ["media", "music"]: - FolderManager.delete_by_name(name) - self.assertFalse(db.Folder.exists(name=name)) + self.create_folders() + + # Delete non-existent folder + self.assertRaises(Folder.DoesNotExist, FolderManager.delete_by_name, "null") + self.assertEqual(Folder.select().count(), 3) + + # Delete existing folders + for name in ["media", "music"]: + FolderManager.delete_by_name(name) + self.assertFalse(Folder.select().where(Folder.name == name).exists()) if __name__ == "__main__": From e589247458f787a947ae58a90bd919ca32c019fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Dec 2022 17:12:44 +0100 Subject: [PATCH 117/237] Port supysonic.managers.user.UserManager --- supysonic/managers/user.py | 16 ++++++------- tests/managers/test_manager_user.py | 36 ++++++++++------------------- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index 144386b0..1a59e70c 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -28,27 +28,25 @@ def get(uid): @staticmethod def add(name, password, **kwargs): - if User.exists(name=name): + if User.select().where(User.name == name).exists(): raise ValueError("User '{}' exists".format(name)) crypt, salt = UserManager.__encrypt_password(password) - return User(name=name, password=crypt, salt=salt, **kwargs) + return User.create(name=name, password=crypt, salt=salt, **kwargs) @staticmethod def delete(uid): user = UserManager.get(uid) - user.delete() + user.delete_instance() @staticmethod def delete_by_name(name): user = User.get(name=name) - if user is None: - raise ObjectNotFound(User) - user.delete() + user.delete_instance() @staticmethod def try_auth(name, password): - user = User.get(name=name) + user = User.get_or_none(name=name) if user is None: return None elif UserManager.__encrypt_password(password, user.salt)[0] != user.password: @@ -63,6 +61,7 @@ def change_password(uid, old_pass, new_pass): raise ValueError("Wrong password") user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] + user.save() @staticmethod def change_password2(name_or_user, new_pass): @@ -70,12 +69,11 @@ def change_password2(name_or_user, new_pass): user = name_or_user elif isinstance(name_or_user, str): user = User.get(name=name_or_user) - if user is None: - raise ObjectNotFound(User) else: raise TypeError("Requires a User instance or a user name (string)") user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] + user.save() @staticmethod def __encrypt_password(password, salt=None): diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index 3b5d1fa6..7c873eb9 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -12,9 +12,6 @@ import unittest import uuid -from pony.orm import db_session, commit -from pony.orm import ObjectNotFound - class UserManagerTestCase(unittest.TestCase): def setUp(self): @@ -24,7 +21,6 @@ def setUp(self): def tearDown(self): db.release_database() - @db_session def create_data(self): # Create some users alice = UserManager.add("alice", "ALICE", admin=True) @@ -37,10 +33,10 @@ def create_data(self): self.assertIsInstance(UserManager.add("charlie", "CHARLIE"), db.User) - folder = db.Folder(name="Root", path="tests/assets", root=True) - artist = db.Artist(name="Artist") - album = db.Album(name="Album", artist=artist) - track = db.Track( + folder = db.Folder.create(name="Root", path="tests/assets", root=True) + artist = db.Artist.create(name="Artist") + album = db.Album.create(name="Album", artist=artist) + track = db.Track.create( title="Track", disc=1, number=1, @@ -71,7 +67,6 @@ def test_encrypt_password(self): func("éèàïô", "ABC+"), ("b639ba5217b89c906019d89d5816b407d8730898", "ABC+") ) - @db_session def test_get_user(self): self.create_data() @@ -85,9 +80,8 @@ def test_get_user(self): self.assertRaises(TypeError, UserManager.get, 0xFEE1BAD) # Non-existent user - self.assertRaises(ObjectNotFound, UserManager.get, uuid.uuid4()) + self.assertRaises(db.User.DoesNotExist, UserManager.get, uuid.uuid4()) - @db_session def test_add_user(self): self.create_data() self.assertEqual(db.User.select().count(), 3) @@ -95,7 +89,6 @@ def test_add_user(self): # Create duplicate self.assertRaises(ValueError, UserManager.add, "alice", "Alic3", admin=True) - @db_session def test_delete_user(self): self.create_data() @@ -105,30 +98,27 @@ def test_delete_user(self): self.assertEqual(db.User.select().count(), 3) # Delete non-existent user - self.assertRaises(ObjectNotFound, UserManager.delete, uuid.uuid4()) + self.assertRaises(db.User.DoesNotExist, UserManager.delete, uuid.uuid4()) self.assertEqual(db.User.select().count(), 3) # Delete existing users for name in ["alice", "bob", "charlie"]: user = db.User.get(name=name) UserManager.delete(user.id) - self.assertRaises(ObjectNotFound, db.User.__getitem__, user.id) - commit() + self.assertRaises(db.User.DoesNotExist, db.User.__getitem__, user.id) self.assertEqual(db.User.select().count(), 0) - @db_session def test_delete_by_name(self): self.create_data() # Delete existing users for name in ["alice", "bob", "charlie"]: UserManager.delete_by_name(name) - self.assertFalse(db.User.exists(name=name)) + self.assertFalse(db.User.select().where(db.User.name == name).exists()) # Delete non-existent user - self.assertRaises(ObjectNotFound, UserManager.delete_by_name, "null") + self.assertRaises(db.User.DoesNotExist, UserManager.delete_by_name, "null") - @db_session def test_try_auth(self): self.create_data() @@ -145,7 +135,6 @@ def test_try_auth(self): # Non-existent user self.assertIsNone(UserManager.try_auth("null", "null")) - @db_session def test_change_password(self): self.create_data() @@ -176,14 +165,13 @@ def test_change_password(self): # Non-existent user self.assertRaises( - ObjectNotFound, + db.User.DoesNotExist, UserManager.change_password, uuid.uuid4(), "oldpass", "newpass", ) - @db_session def test_change_password2(self): self.create_data() @@ -202,7 +190,7 @@ def test_change_password2(self): # Non-existent user self.assertRaises( - ObjectNotFound, UserManager.change_password2, "null", "newpass" + db.User.DoesNotExist, UserManager.change_password2, "null", "newpass" ) From 83ba85aaf151088733b495f5d6b476f018f9c866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 10 Dec 2022 18:04:09 +0100 Subject: [PATCH 118/237] Port supysonic.scanner --- supysonic/db.py | 14 +++--- supysonic/scanner.py | 87 +++++++++++++++++--------------------- tests/base/test_scanner.py | 26 ++---------- 3 files changed, 50 insertions(+), 77 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index a1f283a0..d83fae49 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -156,14 +156,14 @@ def as_subsonic_directory(self, user, client): # "Directory" type in XSD @classmethod def prune(cls): - query = cls.select( - lambda self: not exists(t for t in Track if t.folder == self) - and not exists(f for f in Folder if f.parent == self) - and not self.root + query = cls.delete().where( + ~cls.root, + cls.id.not_in(Track.select(Track.folder)), + cls.id.not_in(cls.select(cls.parent)), ) total = 0 while True: - count = query.delete() + count = query.execute() total += count if not count: return total @@ -191,7 +191,7 @@ def as_subsonic_artist(self, user): @classmethod def prune(cls): - cls.delete().where( + return cls.delete().where( cls.id.not_in(Album.select(Album.artist)), cls.id.not_in(Track.select(Track.artist)), ).execute() @@ -254,7 +254,7 @@ def sort_key(self): @classmethod def prune(cls): - cls.delete().where(cls.id.not_in(Track.select(Track.album))).execute() + return cls.delete().where(cls.id.not_in(Track.select(Track.album))).execute() class Track(PathMixin, db.Model): diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 2f48f4c0..4c2d4a91 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -101,10 +101,10 @@ def run(self): except QueueEmpty: break - with db_session: + try: folder = Folder.get(name=folder_name, root=True) - if folder is None: - continue + except Folder.DoesNotExist: + continue self.__scan_folder(folder) @@ -144,32 +144,27 @@ def __scan_folder(self, folder): # Remove files that have been deleted # Could be more efficient if done above if not self.__stopped.is_set(): - with db_session: - for track in Track.select(lambda t: t.root_folder == folder): - if not os.path.exists(track.path) or not self.__check_extension( - track.path - ): - self.remove_file(track.path) + for track in Track.select().where(Track.root_folder == folder): + if not os.path.exists(track.path) or not self.__check_extension( + track.path + ): + self.remove_file(track.path) # Remove deleted/moved folders and update cover art info folders = [folder] while not self.__stopped.is_set() and folders: f = folders.pop() - with db_session: - # f has been fetched from another session, refetch or Pony will complain - f = Folder[f.id] - - if not f.root and not os.path.isdir(f.path): - f.delete() # Pony will cascade - continue + if not f.root and not os.path.isdir(f.path): + f.delete_instance(recursive=True) + continue - self.find_cover(f.path) - folders += f.children + self.find_cover(f.path) + folders += f.children[:] if not self.__stopped.is_set(): - with db_session: - Folder[folder.id].last_scan = int(time.time()) + folder.last_scan = int(time.time()) + folder.save() if self.__on_folder_end is not None: self.__on_folder_end(folder) @@ -178,10 +173,9 @@ def prune(self): if self.__stopped.is_set(): return - with db_session: - self.__stats.deleted.albums = Album.prune() - self.__stats.deleted.artists = Artist.prune() - Folder.prune() + self.__stats.deleted.albums = Album.prune() + self.__stats.deleted.artists = Artist.prune() + Folder.prune() def __check_extension(self, path): if not self.__extensions: @@ -210,7 +204,7 @@ def scan_file(self, path_or_direntry): mtime = int(stat.st_mtime) - tr = Track.get(path=path) + tr = Track.get_or_none(path=path) if tr is not None: if not self.__force and not mtime > tr.last_modification: return @@ -253,7 +247,7 @@ def scan_file(self, path_or_direntry): trdict["created"] = datetime.fromtimestamp(mtime) try: - Track(**trdict) + Track.create(**trdict) self.__stats.added.tracks += 1 except ValueError: # Field validation error @@ -266,7 +260,9 @@ def scan_file(self, path_or_direntry): trdict["artist"] = trartist try: - tr.set(**trdict) + for attr, value in trdict.items(): + setattr(tr, attr, value) + tr.save() except ValueError: # Field validation error self.__stats.errors.append(path) @@ -275,12 +271,11 @@ def remove_file(self, path): if not isinstance(path, str): raise TypeError("Expecting string, got " + str(type(path))) - tr = Track.get(path=path) - if not tr: - return - - self.__stats.deleted.tracks += 1 - tr.delete() + try: + Track.get(path=path).delete_instance() + self.__stats.deleted.tracks += 1 + except Track.DoesNotExist: + pass def move_file(self, src_path, dst_path): if not isinstance(src_path, str): @@ -291,8 +286,9 @@ def move_file(self, src_path, dst_path): if src_path == dst_path: return - tr = Track.get(path=src_path) - if tr is None: + try: + tr = Track.get(path=src_path) + except Track.DoesNotExist: return tr_dst = Track.get(path=dst_path) @@ -352,28 +348,23 @@ def add_cover(self, path): def __find_album(self, artist, album): ar = self.__find_artist(artist) - al = ar.albums.select(lambda a: a.name == album).first() + al = ar.albums.where(Album.name == album).first() if al: return al - al = Album(name=album, artist=ar) self.__stats.added.albums += 1 - - return al + return Album.create(name=album, artist=ar) def __find_artist(self, artist): - ar = Artist.get(name=artist) - if ar: - return ar - - ar = Artist(name=artist) - self.__stats.added.artists += 1 - - return ar + try: + return Artist.get(name=artist) + except Artist.DoesNotExist: + self.__stats.added.artists += 1 + return Artist.create(name=artist) def __find_root_folder(self, path): path = os.path.dirname(path) - for folder in Folder.select(lambda f: f.root): + for folder in Folder.select().where(Folder.root): if path.startswith(folder.path): return folder diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index feb3496c..aa40cabe 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -12,7 +12,6 @@ import unittest from contextlib import contextmanager -from pony.orm import db_session, commit from supysonic import db from supysonic.managers.folder import FolderManager @@ -23,9 +22,8 @@ class ScannerTestCase(unittest.TestCase): def setUp(self): db.init_database("sqlite:") - with db_session: - folder = FolderManager.add("folder", os.path.abspath("tests/assets/folder")) - self.assertIsNotNone(folder) + folder = FolderManager.add("folder", os.path.abspath("tests/assets/folder")) + self.assertIsNotNone(folder) self.folderid = folder.id self.__scan() @@ -50,9 +48,7 @@ def __scan(self, force=False): self.scanner = Scanner(force=force) self.scanner.queue_folder("folder") self.scanner.run() - commit() - @db_session def test_scan(self): self.assertEqual(db.Track.select().count(), 1) @@ -61,40 +57,32 @@ def test_scan(self): TypeError, self.scanner.queue_folder, db.Folder[self.folderid] ) - @db_session def test_rescan(self): self.__scan() self.assertEqual(db.Track.select().count(), 1) - @db_session def test_force_rescan(self): self.__scan(True) self.assertEqual(db.Track.select().count(), 1) - @db_session def test_scan_file(self): self.scanner.scan_file("/some/inexistent/path") - commit() self.assertEqual(db.Track.select().count(), 1) - @db_session def test_remove_file(self): track = db.Track.select().first() self.assertRaises(TypeError, self.scanner.remove_file, None) self.assertRaises(TypeError, self.scanner.remove_file, track) self.scanner.remove_file("/some/inexistent/path") - commit() self.assertEqual(db.Track.select().count(), 1) self.scanner.remove_file(track.path) self.scanner.prune() - commit() self.assertEqual(db.Track.select().count(), 0) self.assertEqual(db.Album.select().count(), 0) self.assertEqual(db.Artist.select().count(), 0) - @db_session def test_move_file(self): track = db.Track.select().first() self.assertRaises(TypeError, self.scanner.move_file, None, "string") @@ -103,11 +91,9 @@ def test_move_file(self): self.assertRaises(TypeError, self.scanner.move_file, "string", track) self.scanner.move_file("/some/inexistent/path", track.path) - commit() self.assertEqual(db.Track.select().count(), 1) self.scanner.move_file(track.path, track.path) - commit() self.assertEqual(db.Track.select().count(), 1) self.assertRaises( @@ -118,17 +104,14 @@ def test_move_file(self): self.__scan() self.assertEqual(db.Track.select().count(), 2) self.scanner.move_file(tf, track.path) - commit() self.assertEqual(db.Track.select().count(), 1) track = db.Track.select().first() new_path = track.path.replace("silence", "silence_moved") self.scanner.move_file(track.path, new_path) - commit() self.assertEqual(db.Track.select().count(), 1) self.assertEqual(track.path, new_path) - @db_session def test_rescan_corrupt_file(self): with self.__temporary_track_copy() as tf: self.__scan() @@ -142,7 +125,6 @@ def test_rescan_corrupt_file(self): self.__scan(True) self.assertEqual(db.Track.select().count(), 1) - @db_session def test_rescan_removed_file(self): with self.__temporary_track_copy(): self.__scan() @@ -151,7 +133,6 @@ def test_rescan_removed_file(self): self.__scan() self.assertEqual(db.Track.select().count(), 1) - @db_session def test_scan_tag_change(self): with self.__temporary_track_copy() as tf: self.__scan() @@ -165,6 +146,7 @@ def test_scan_tag_change(self): tags.save() self.__scan(True) + copy = db.Track.get(path=tf) self.assertEqual(copy.artist.name, "Renamed artist") self.assertEqual(copy.album.name, "Crappy album") self.assertIsNotNone(db.Artist.get(name="Some artist")) From cd369f6c7fa4b99a99e7ab55574afa6124bfe6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 11 Dec 2022 15:12:03 +0100 Subject: [PATCH 119/237] Porting supysonic.cli --- supysonic/cli.py | 55 +++++++++++++++++++++--------------------- supysonic/db.py | 17 ++++++++----- tests/base/test_cli.py | 45 ++++++++++++---------------------- 3 files changed, 54 insertions(+), 63 deletions(-) diff --git a/supysonic/cli.py b/supysonic/cli.py index d22b148c..2eacb5fa 100644 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2021 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -9,8 +9,6 @@ import time from click.exceptions import ClickException -from pony.orm import db_session, select -from pony.orm.core import ObjectNotFound from .config import IniConfig from .daemon.client import DaemonClient @@ -52,12 +50,11 @@ def folder(): @folder.command("list") -@db_session def folder_list(): """Lists folders.""" click.echo("Name\t\tPath\n----\t\t----") - for f in Folder.select(lambda f: f.root): + for f in Folder.select().where(Folder.root): click.echo("{: <16}{}".format(f.name, f.path)) @@ -67,7 +64,6 @@ def folder_list(): "path", type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True), ) -@db_session def folder_add(name, path): """Adds a folder. @@ -87,7 +83,6 @@ def folder_add(name, path): @folder.command("delete") @click.argument("name") -@db_session def folder_delete(name): """Deletes a folder. @@ -97,7 +92,7 @@ def folder_delete(name): try: FolderManager.delete_by_name(name) click.echo("Deleted folder '{}'".format(name)) - except ObjectNotFound as e: + except Folder.DoesNotExist as e: raise ClickException("Folder '{}' does not exist.".format(name)) from e @@ -196,17 +191,20 @@ def watch_folder(folder): if folders: fstrs = folders - with db_session: - folders = select(f.name for f in Folder if f.root and f.name in fstrs)[:] + folders = [ + f + for f, in Folder.select(Folder.name) + .where(Folder.root, Folder.name.in_(fstrs)) + .tuples() + ] notfound = set(fstrs) - set(folders) if notfound: click.echo("No such folder(s): " + " ".join(notfound)) for folder in folders: scanner.queue_folder(folder) else: - with db_session: - for folder in select(f.name for f in Folder if f.root): - scanner.queue_folder(folder) + for (folder,) in Folder.select(Folder.name).where(Folder.root).tuples(): + scanner.queue_folder(folder) scanner.run() stats = scanner.stats() @@ -235,7 +233,6 @@ def user(): @user.command("list") -@db_session def user_list(): """Lists users.""" @@ -253,7 +250,6 @@ def user_list(): @click.argument("name") @click.password_option("-p", "--password", help="Specifies the user's password") @click.option("-e", "--email", default="", help="Sets the user's email address") -@db_session def user_add(name, password, email): """Adds a new user. @@ -268,7 +264,6 @@ def user_add(name, password, email): @user.command("delete") @click.argument("name") -@db_session def user_delete(name): """Deletes a user. @@ -278,7 +273,7 @@ def user_delete(name): try: UserManager.delete_by_name(name) click.echo("Deleted user '{}'".format(name)) - except ObjectNotFound as e: + except User.DoesNotExist as e: raise ClickException("User '{}' does not exist.".format(name)) from e @@ -299,16 +294,16 @@ def _echo_role_change(username, name, value): default=None, help="Grant or revoke jukebox rights", ) -@db_session def user_roles(name, admin, jukebox): """Enable/disable rights for a user. NAME is the login of the user to which grant or revoke rights. """ - user = User.get(name=name) - if user is None: - raise ClickException("No such user") + try: + user = User.get(name=name) + except User.DoesNotExist as e: + raise ClickException("No such user") from e if admin is not None: user.admin = admin @@ -316,12 +311,12 @@ def user_roles(name, admin, jukebox): if jukebox is not None: user.jukebox = jukebox _echo_role_change(name, "jukebox", jukebox) + user.save() @user.command("changepass") @click.argument("name") @click.password_option("-p", "--password", help="New password") -@db_session def user_changepass(name, password): """Changes a user's password. @@ -331,14 +326,13 @@ def user_changepass(name, password): try: UserManager.change_password2(name, password) click.echo("Successfully changed '{}' password".format(name)) - except ObjectNotFound as e: + except User.DoesNotExist as e: raise ClickException("User '{}' does not exist.".format(name)) from e @user.command("rename") @click.argument("name") @click.argument("newname") -@db_session def user_rename(name, newname): """Renames a user. @@ -351,14 +345,19 @@ def user_rename(name, newname): if name == newname: return - user = User.get(name=name) - if user is None: - raise ClickException("No such user") + try: + user = User.get(name=name) + except User.DoesNotExist as e: + raise ClickException("No such user") from e - if User.get(name=newname) is not None: + try: + User.get(name=newname) raise ClickException("This name is already taken") + except User.DoesNotExist: + pass user.name = newname + user.save() click.echo("User '{}' renamed to '{}'".format(name, newname)) diff --git a/supysonic/db.py b/supysonic/db.py index d83fae49..ef99907b 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -60,8 +60,9 @@ def get(cls, *args, **kwargs): return db.Model.get.__func__(cls, *args, **kwargs) def __init__(self, *args, **kwargs): - path = kwargs["path"] - kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest() + if "path" in kwargs: + path = kwargs["path"] + kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest() db.Model.__init__(self, *args, **kwargs) def __setattr__(self, attr, value): @@ -191,10 +192,14 @@ def as_subsonic_artist(self, user): @classmethod def prune(cls): - return cls.delete().where( - cls.id.not_in(Album.select(Album.artist)), - cls.id.not_in(Track.select(Track.artist)), - ).execute() + return ( + cls.delete() + .where( + cls.id.not_in(Album.select(Album.artist)), + cls.id.not_in(Track.select(Track.artist)), + ) + .execute() + ) class Album(db.Model): diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index 51e055ff..27dfbffb 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2021 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -11,7 +11,6 @@ import unittest from click.testing import CliRunner -from pony.orm import db_session from supysonic.db import Folder, User, init_database, release_database from supysonic.cli import cli @@ -48,10 +47,9 @@ def test_folder_add(self): with tempfile.TemporaryDirectory() as d: self.__add_folder("tmpfolder", d) - with db_session: - f = Folder.select().first() - self.assertIsNotNone(f) - self.assertEqual(f.path, d) + f = Folder.select().first() + self.assertIsNotNone(f) + self.assertEqual(f.path, d) def test_folder_add_errors(self): with tempfile.TemporaryDirectory() as d: @@ -61,8 +59,7 @@ def test_folder_add_errors(self): self.__add_folder("f1", d, True) self.__invoke("folder add f3 /invalid/path", True) - with db_session: - self.assertEqual(Folder.select().count(), 1) + self.assertEqual(Folder.select().count(), 1) def test_folder_delete(self): with tempfile.TemporaryDirectory() as d: @@ -70,8 +67,7 @@ def test_folder_delete(self): self.__invoke("folder delete randomfolder", True) self.__invoke("folder delete tmpfolder") - with db_session: - self.assertEqual(Folder.select().count(), 0) + self.assertEqual(Folder.select().count(), 0) def test_folder_list(self): with tempfile.TemporaryDirectory() as d: @@ -91,16 +87,14 @@ def test_user_add(self): self.__invoke("user add -p Alic3 alice") self.__invoke("user add -p alice alice", True) - with db_session: - self.assertEqual(User.select().count(), 1) + self.assertEqual(User.select().count(), 1) def test_user_delete(self): self.__invoke("user add -p Alic3 alice") self.__invoke("user delete alice") self.__invoke("user delete bob", True) - with db_session: - self.assertEqual(User.select().count(), 0) + self.assertEqual(User.select().count(), 0) def test_user_list(self): self.__invoke("user add -p Alic3 alice") @@ -111,28 +105,24 @@ def test_user_setadmin(self): self.__invoke("user add -p Alic3 alice") self.__invoke("user setroles -A alice") self.__invoke("user setroles -A bob", True) - with db_session: - self.assertTrue(User.get(name="alice").admin) + self.assertTrue(User.get(name="alice").admin) def test_user_unsetadmin(self): self.__invoke("user add -p Alic3 alice") self.__invoke("user setroles -A alice") self.__invoke("user setroles -a alice") - with db_session: - self.assertFalse(User.get(name="alice").admin) + self.assertFalse(User.get(name="alice").admin) def test_user_setjukebox(self): self.__invoke("user add -p Alic3 alice") self.__invoke("user setroles -J alice") - with db_session: - self.assertTrue(User.get(name="alice").jukebox) + self.assertTrue(User.get(name="alice").jukebox) def test_user_unsetjukebox(self): self.__invoke("user add -p Alic3 alice") self.__invoke("user setroles -J alice") self.__invoke("user setroles -j alice") - with db_session: - self.assertFalse(User.get(name="alice").jukebox) + self.assertFalse(User.get(name="alice").jukebox) def test_user_changepass(self): self.__invoke("user add -p Alic3 alice") @@ -145,18 +135,15 @@ def test_user_rename(self): self.__invoke("user rename bob charles", True) self.__invoke("user rename alice ''", True) - with db_session: - self.assertEqual(User.select().first().name, "alice") + self.assertEqual(User.select().first().name, "alice") self.__invoke("user rename alice bob") - with db_session: - self.assertEqual(User.select().first().name, "bob") + self.assertEqual(User.select().first().name, "bob") self.__invoke("user add -p Ch4rl3s charles") self.__invoke("user rename bob charles", True) - with db_session: - self.assertEqual(User.select(lambda u: u.name == "bob").count(), 1) - self.assertEqual(User.select(lambda u: u.name == "charles").count(), 1) + self.assertEqual(User.select().where(User.name == "bob").count(), 1) + self.assertEqual(User.select().where(User.name == "charles").count(), 1) if __name__ == "__main__": From c5246c74bb2e4eb1c5dd189352b46fb002d8a453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 11 Dec 2022 15:40:23 +0100 Subject: [PATCH 120/237] Porting supysonic.watcher Which mostly means fixing the scanner --- supysonic/scanner.py | 28 ++++++++----- supysonic/watcher.py | 11 +++-- tests/base/test_scanner.py | 2 + tests/base/test_watcher.py | 84 +++++++++++++++----------------------- 4 files changed, 57 insertions(+), 68 deletions(-) diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 4c2d4a91..a6bbd485 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -291,19 +291,20 @@ def move_file(self, src_path, dst_path): except Track.DoesNotExist: return - tr_dst = Track.get(path=dst_path) - if tr_dst is not None: + try: + tr_dst = Track.get(path=dst_path) root = tr_dst.root_folder folder = tr_dst.folder self.remove_file(dst_path) tr.root_folder = root tr.folder = folder - else: + except Track.DoesNotExist: root = self.__find_root_folder(dst_path) folder = self.__find_folder(dst_path) tr.root_folder = root tr.folder = folder tr.path = dst_path + tr.save() def find_cover(self, dirpath): if not isinstance(dirpath, str): # pragma: nocover @@ -312,8 +313,9 @@ def find_cover(self, dirpath): if not os.path.exists(dirpath): return - folder = Folder.get(path=dirpath) - if folder is None: + try: + folder = Folder.get(path=dirpath) + except Folder.DoesNotExist: return album_name = None @@ -323,18 +325,21 @@ def find_cover(self, dirpath): cover = find_cover_in_folder(folder.path, album_name) folder.cover_art = cover.name if cover is not None else None + folder.save() def add_cover(self, path): if not isinstance(path, str): # pragma: nocover raise TypeError("Expecting string, got " + str(type(path))) - folder = Folder.get(path=os.path.dirname(path)) - if folder is None: + try: + folder = Folder.get(path=os.path.dirname(path)) + except Folder.DoesNotExist: return cover_name = os.path.basename(path) if not folder.cover_art: folder.cover_art = cover_name + folder.save() elif folder.cover_art != cover_name: album_name = None track = folder.tracks.select().first() @@ -345,6 +350,7 @@ def add_cover(self, path): new_cover = CoverFile(cover_name, album_name) if new_cover.score > current_cover.score: folder.cover_art = cover_name + folder.save() def __find_album(self, artist, album): ar = self.__find_artist(artist) @@ -379,9 +385,11 @@ def __find_folder(self, path): drive, _ = os.path.splitdrive(path) path = os.path.dirname(path) while path not in (drive, "/"): - folder = Folder.get(path=path) - if folder is not None: + try: + folder = Folder.get(path=path) break + except Folder.DoesNotExist: + pass created = datetime.fromtimestamp(os.path.getmtime(path)) children.append( @@ -396,7 +404,7 @@ def __find_folder(self, path): assert folder is not None while children: - folder = Folder(parent=folder, **children.pop()) + folder = Folder.create(parent=folder, **children.pop()) return folder diff --git a/supysonic/watcher.py b/supysonic/watcher.py index b90f66fe..9307843f 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -49,9 +49,9 @@ def on_created(self, event): self.queue.put(event.src_path, op) dirname = os.path.dirname(event.src_path) - with db_session: - folder = Folder.get(path=dirname) - if folder is None: + try: + Folder.get(path=dirname) + except Folder.DoesNotExist: self.queue.put(dirname, op | FLAG_COVER) else: self.queue.put(event.src_path, op | FLAG_COVER) @@ -289,9 +289,8 @@ def start(self): self.__observer = Observer() self.__handler.queue = self.__queue - with db_session: - for folder in Folder.select(lambda f: f.root): - self.add_folder(folder) + for folder in Folder.select().where(Folder.root): + self.add_folder(folder) logger.info("Starting watcher") self.__queue.start() diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index aa40cabe..b5fb212e 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -109,6 +109,8 @@ def test_move_file(self): track = db.Track.select().first() new_path = track.path.replace("silence", "silence_moved") self.scanner.move_file(track.path, new_path) + + track = db.Track.select().first() self.assertEqual(db.Track.select().count(), 1) self.assertEqual(track.path, new_path) diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index 63434bed..f7e26e41 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -13,7 +13,6 @@ import unittest from hashlib import sha1 -from pony.orm import db_session from supysonic.db import init_database, release_database, Track, Artist, Folder from supysonic.managers.folder import FolderManager @@ -64,8 +63,7 @@ class WatcherTestCase(WatcherTestBase): def setUp(self): super().setUp() self.__dir = tempfile.mkdtemp() - with db_session: - FolderManager.add("Folder", self.__dir) + FolderManager.add("Folder", self.__dir) self._start() def tearDown(self): @@ -101,7 +99,6 @@ def _addcover(self, suffix=None, depth=0): class AudioWatcherTestCase(WatcherTestCase): - @db_session def assertTrackCountEqual(self, expected): self.assertEqual(Track.select().count(), expected) @@ -124,57 +121,49 @@ def test_add_multiple(self): self._addfile() self.assertTrackCountEqual(0) self._sleep() - with db_session: - self.assertEqual(Track.select().count(), 3) - self.assertEqual(Artist.select().count(), 1) + + self.assertEqual(Track.select().count(), 3) + self.assertEqual(Artist.select().count(), 1) def test_change(self): path = self._addfile() self._sleep() trackid = None - with db_session: - self.assertEqual(Track.select().count(), 1) - self.assertEqual( - Artist.select(lambda a: a.name == "Some artist").count(), 1 - ) - trackid = Track.select().first().id + self.assertEqual(Track.select().count(), 1) + self.assertEqual(Artist.select().where(Artist.name == "Some artist").count(), 1) + trackid = Track.select().first().id tags = mutagen.File(path, easy=True) tags["artist"] = "Renamed" tags.save() self._sleep() - with db_session: - self.assertEqual(Track.select().count(), 1) - self.assertEqual( - Artist.select(lambda a: a.name == "Some artist").count(), 0 - ) - self.assertEqual(Artist.select(lambda a: a.name == "Renamed").count(), 1) - self.assertEqual(Track.select().first().id, trackid) + self.assertEqual(Track.select().count(), 1) + self.assertEqual(Artist.select().where(Artist.name == "Some artist").count(), 0) + self.assertEqual(Artist.select().where(Artist.name == "Renamed").count(), 1) + self.assertEqual(Track.select().first().id, trackid) def test_rename(self): path = self._addfile() self._sleep() trackid = None - with db_session: - self.assertEqual(Track.select().count(), 1) - trackid = Track.select().first().id + self.assertEqual(Track.select().count(), 1) + trackid = Track.select().first().id newpath = self._temppath(".mp3") shutil.move(path, newpath) self._sleep() - with db_session: - track = Track.select().first() - self.assertIsNotNone(track) - self.assertNotEqual(track.path, path) - self.assertEqual(track.path, newpath) - self.assertEqual( - track._path_hash, memoryview(sha1(newpath.encode("utf-8")).digest()) - ) - self.assertEqual(track.id, trackid) + track = Track.select().first() + self.assertIsNotNone(track) + self.assertNotEqual(track.path, path) + self.assertEqual(track.path, newpath) + self.assertEqual( + track._path_hash, memoryview(sha1(newpath.encode("utf-8")).digest()) + ) + self.assertEqual(track.id, trackid) def test_move_in(self): filename = self._tempname() + ".mp3" @@ -255,16 +244,14 @@ def test_add_file_then_cover(self): path = self._addcover() self._sleep() - with db_session: - self.assertEqual(Folder.select().first().cover_art, os.path.basename(path)) + self.assertEqual(Folder.select().first().cover_art, os.path.basename(path)) def test_add_cover_then_file(self): path = self._addcover() self._addfile() self._sleep() - with db_session: - self.assertEqual(Folder.select().first().cover_art, os.path.basename(path)) + self.assertEqual(Folder.select().first().cover_art, os.path.basename(path)) def test_remove_cover(self): self._addfile() @@ -274,8 +261,7 @@ def test_remove_cover(self): os.unlink(path) self._sleep() - with db_session: - self.assertIsNone(Folder.select().first().cover_art) + self.assertIsNone(Folder.select().first().cover_art) def test_naming_add_good(self): self._addcover() @@ -283,8 +269,7 @@ def test_naming_add_good(self): good = os.path.basename(self._addcover("cover")) self._sleep() - with db_session: - self.assertEqual(Folder.select().first().cover_art, good) + self.assertEqual(Folder.select().first().cover_art, good) def test_naming_add_bad(self): good = os.path.basename(self._addcover("cover")) @@ -292,8 +277,7 @@ def test_naming_add_bad(self): self._addcover() self._sleep() - with db_session: - self.assertEqual(Folder.select().first().cover_art, good) + self.assertEqual(Folder.select().first().cover_art, good) def test_naming_remove_good(self): bad = self._addcover() @@ -302,8 +286,7 @@ def test_naming_remove_good(self): os.unlink(good) self._sleep() - with db_session: - self.assertEqual(Folder.select().first().cover_art, os.path.basename(bad)) + self.assertEqual(Folder.select().first().cover_art, os.path.basename(bad)) def test_naming_remove_bad(self): bad = self._addcover() @@ -312,8 +295,7 @@ def test_naming_remove_bad(self): os.unlink(bad) self._sleep() - with db_session: - self.assertEqual(Folder.select().first().cover_art, os.path.basename(good)) + self.assertEqual(Folder.select().first().cover_art, os.path.basename(good)) def test_rename(self): path = self._addcover() @@ -322,17 +304,15 @@ def test_rename(self): shutil.move(path, newpath) self._sleep() - with db_session: - self.assertEqual( - Folder.select().first().cover_art, os.path.basename(newpath) - ) + self.assertEqual(Folder.select().first().cover_art, os.path.basename(newpath)) def test_add_to_folder_without_track(self): path = self._addcover(depth=1) self._sleep() - with db_session: - self.assertFalse(Folder.exists(cover_art=os.path.basename(path))) + self.assertFalse( + Folder.select().where(Folder.cover_art == os.path.basename(path)).exists() + ) def test_remove_from_folder_without_track(self): path = self._addcover(depth=1) From 2b472b4d9721b9f918aff3966bf7f0a4a9a42ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 18 Dec 2022 16:50:03 +0100 Subject: [PATCH 121/237] Porting supysonic.api.albums_songs --- supysonic/api/__init__.py | 12 +-- supysonic/api/albums_songs.py | 167 +++++++++++++++++++--------------- supysonic/api/annotation.py | 4 +- supysonic/api/browse.py | 1 - supysonic/api/errors.py | 13 +-- supysonic/api/jukebox.py | 3 +- supysonic/api/media.py | 3 +- supysonic/api/search.py | 1 - supysonic/db.py | 9 +- tests/api/test_album_songs.py | 79 ++++++++-------- tests/testbase.py | 5 +- 11 files changed, 152 insertions(+), 145 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index afff6e9a..29ae0476 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -11,8 +11,7 @@ import uuid from flask import request from flask import Blueprint -from pony.orm import ObjectNotFound, TransactionIntegrityError -from pony.orm import commit +from peewee import IntegrityError from ..db import ClientPrefs, Folder from ..managers.user import UserManager @@ -82,15 +81,12 @@ def get_client_prefs(): client = request.values["c"] try: request.client = ClientPrefs[request.user, client] - except ObjectNotFound: + except ClientPrefs.DoesNotExist: try: - request.client = ClientPrefs(user=request.user, client_name=client) - commit() - except TransactionIntegrityError: + request.client = ClientPrefs.create(user=request.user, client_name=client) + except IntegrityError: # We might have hit a race condition here, another request already created # the ClientPrefs. Issue #220 - # Reload the user or Pony will complain about different transactions - request.user = UserManager.get(request.user.id) request.client = ClientPrefs[request.user, client] diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 25e9ed31..434b8d98 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -7,19 +7,21 @@ from datetime import timedelta from flask import request -from pony.orm import select, desc, avg, max, min, count, between, distinct +from peewee import fn, JOIN from ..db import ( Folder, + Artist, Album, Track, StarredFolder, StarredArtist, StarredAlbum, StarredTrack, + RatingFolder, User, ) -from ..db import now +from ..db import now, random from . import api_routing, get_root_folder from .exceptions import GenericError @@ -39,20 +41,20 @@ def rand_songs(): query = Track.select() if fromYear: - query = query.filter(lambda t: t.year >= fromYear) + query = query.where(Track.year >= fromYear) if toYear: - query = query.filter(lambda t: t.year <= toYear) + query = query.where(Track.year <= toYear) if genre: - query = query.filter(lambda t: t.genre == genre) + query = query.where(Track.genre == genre) if root: - query = query.filter(lambda t: t.root_folder == root) + query = query.where(Track.root_folder == root) return request.formatter( "randomSongs", { "song": [ t.as_subsonic_child(request.user, request.client) - for t in query.without_distinct().random(size) + for t in query.order_by(random()).limit(size) ] }, ) @@ -67,58 +69,52 @@ def album_list(): offset = int(offset) if offset else 0 root = get_root_folder(mfid) - query = select(t.folder for t in Track) + query = Track.select(Track.folder).join(Folder).group_by(Track.folder) if root is not None: - query = select(t.folder for t in Track if t.root_folder == root) + query = query.where(Track.root_folder == root) if ltype == "random": return request.formatter( "albumList", { "album": [ - a.as_subsonic_child(request.user) - for a in distinct(query.random(size)) + t.folder.as_subsonic_child(request.user) + for t in query.order_by(random()).limit(size) ] }, ) elif ltype == "newest": - query = query.sort_by(desc(Folder.created)).distinct() + query = query.order_by(Folder.created.desc()).distinct() elif ltype == "highest": - query = query.sort_by(lambda f: desc(avg(f.ratings.rating))) + query = query.join(RatingFolder, JOIN.LEFT_OUTER).order_by( + fn.avg(RatingFolder.rating).desc() + ) elif ltype == "frequent": - query = query.sort_by(lambda f: desc(avg(f.tracks.play_count))) + query = query.order_by(fn.avg(Track.play_count).desc()) elif ltype == "recent": - query = select( - t.folder for t in Track if max(t.folder.tracks.last_play) is not None + query = query.where(Track.last_play.is_null(False)).order_by( + fn.max(Track.last_play).desc() ) - if root is not None: - query = query.where(lambda t: t.root_folder == root) - query = query.sort_by(lambda f: desc(max(f.tracks.last_play))) elif ltype == "starred": - query = select( - s.starred - for s in StarredFolder - if s.user.id == request.user.id and count(s.starred.tracks) > 0 - ) - if root is not None: - query = query.filter(lambda f: f.path.startswith(root.path)) + query = query.join(StarredFolder).where(StarredFolder.user == request.user) elif ltype == "alphabeticalByName": - query = query.sort_by(Folder.name).distinct() + query = query.order_by(Folder.name).distinct() elif ltype == "alphabeticalByArtist": - query = query.sort_by(lambda f: f.parent.name + f.name) + parent = Folder.alias() + query = query.join(parent).order_by(parent.name, Folder.name) elif ltype == "byYear": startyear = int(request.values["fromYear"]) endyear = int(request.values["toYear"]) query = query.where( - lambda t: between(t.year, min(startyear, endyear), max(startyear, endyear)) + Track.year.between(min(startyear, endyear), max(startyear, endyear)) ) + order = fn.min(Track.year) if endyear < startyear: - query = query.sort_by(lambda f: desc(min(f.tracks.year))) - else: - query = query.sort_by(lambda f: min(f.tracks.year)) + order = order.desc() + query = query.order_by(order) elif ltype == "byGenre": genre = request.values["genre"] - query = query.where(lambda t: t.genre == genre) + query = query.where(Track.genre == genre) else: raise GenericError("Unknown search type") @@ -126,7 +122,8 @@ def album_list(): "albumList", { "album": [ - f.as_subsonic_child(request.user) for f in query.limit(size, offset) + t.folder.as_subsonic_child(request.user) + for t in query.limit(size).offset(offset) ] }, ) @@ -141,46 +138,49 @@ def album_list_id3(): offset = int(offset) if offset else 0 root = get_root_folder(mfid) - query = Album.select() + query = Album.select().join(Track).group_by(Album) if root is not None: - query = query.where(lambda a: root in a.tracks.root_folder) + query = query.where(Track.root_folder == root) if ltype == "random": return request.formatter( "albumList2", - {"album": [a.as_subsonic_album(request.user) for a in query.random(size)]}, + { + "album": [ + a.as_subsonic_album(request.user) + for a in query.order_by(random()).limit(size) + ] + }, ) elif ltype == "newest": - query = query.order_by(lambda a: desc(min(a.tracks.created))) + query = query.order_by(fn.min(Track.created).desc()) elif ltype == "frequent": - query = query.order_by(lambda a: desc(avg(a.tracks.play_count))) + query = query.order_by(fn.avg(Track.play_count).desc()) elif ltype == "recent": - query = query.where(lambda a: max(a.tracks.last_play) is not None).order_by( - lambda a: desc(max(a.tracks.last_play)) + query = query.where(Track.last_play.is_null(False)).order_by( + fn.max(Track.last_play).desc() ) elif ltype == "starred": - query = select(s.starred for s in StarredAlbum if s.user.id == request.user.id) - if root is not None: - query = query.filter(lambda a: root in a.tracks.root_folder) + query = ( + query.switch().join(StarredAlbum).where(StarredAlbum.user == request.user) + ) elif ltype == "alphabeticalByName": query = query.order_by(Album.name) elif ltype == "alphabeticalByArtist": - query = query.order_by(lambda a: a.artist.name + a.name) + query = query.switch().join(Artist).order_by(Artist.name, Album.name) elif ltype == "byYear": startyear = int(request.values["fromYear"]) endyear = int(request.values["toYear"]) - query = query.where( - lambda a: between( - min(a.tracks.year), min(startyear, endyear), max(startyear, endyear) - ) + query = query.having( + fn.min(Track.year).between(min(startyear, endyear), max(startyear, endyear)) ) + order = fn.min(Track.year) if endyear < startyear: - query = query.order_by(lambda a: desc(min(a.tracks.year))) - else: - query = query.order_by(lambda a: min(a.tracks.year)) + order = order.desc() + query = query.order_by(order) elif ltype == "byGenre": genre = request.values["genre"] - query = query.where(lambda a: genre in a.tracks.genre) + query = query.where(Track.genre == genre) else: raise GenericError("Unknown search type") @@ -188,7 +188,8 @@ def album_list_id3(): "albumList2", { "album": [ - f.as_subsonic_album(request.user) for f in query.limit(size, offset) + a.as_subsonic_album(request.user) + for a in query.limit(size).offset(offset) ] }, ) @@ -203,9 +204,9 @@ def songs_by_genre(): offset = int(offset) if offset else 0 root = get_root_folder(mfid) - query = select(t for t in Track if t.genre == genre) + query = Track.select().where(Track.genre == genre) if root is not None: - query = query.where(lambda t: t.root_folder == root) + query = query.where(Track.root_folder == root) return request.formatter( "songsByGenre", { @@ -219,9 +220,9 @@ def songs_by_genre(): @api_routing("/getNowPlaying") def now_playing(): - query = User.select( - lambda u: u.last_play is not None - and u.last_play_date + timedelta(minutes=3) > now() + query = User.select().where( + User.last_play.is_null(False), + User.last_play_date > now() - timedelta(minutes=3), ) return request.formatter( @@ -245,16 +246,26 @@ def get_starred(): mfid = request.values.get("musicFolderId") root = get_root_folder(mfid) - folders = select(s.starred for s in StarredFolder if s.user.id == request.user.id) + folders = ( + StarredFolder.select(StarredFolder.starred) + .join(Folder) + .join(Track, on=Track.folder) + .where(StarredFolder.user == request.user) + .group_by(Folder) + ) if root is not None: - folders = folders.filter(lambda f: f.path.startswith(root.path)) - - arq = folders.filter(lambda f: count(f.tracks) == 0) - alq = folders.filter(lambda f: count(f.tracks) > 0) - trq = select(s.starred for s in StarredTrack if s.user.id == request.user.id) + folders = folders.where(Folder.path.startswith(root.path)) + + arq = folders.having(fn.count(Track.id) == 0) + alq = folders.having(fn.count(Track.id) > 0) + trq = ( + StarredTrack.select(StarredTrack.starred) + .join(Track) + .where(StarredTrack.user == request.user) + ) if root is not None: - trq = trq.filter(lambda t: t.root_folder == root) + trq = trq.where(Track.root_folder == root) return request.formatter( "starred", @@ -271,14 +282,26 @@ def get_starred_id3(): mfid = request.values.get("musicFolderId") root = get_root_folder(mfid) - arq = select(s.starred for s in StarredArtist if s.user.id == request.user.id) - alq = select(s.starred for s in StarredAlbum if s.user.id == request.user.id) - trq = select(s.starred for s in StarredTrack if s.user.id == request.user.id) + arq = ( + StarredArtist.select(StarredArtist.starred) + .join(Artist) + .where(StarredArtist.user == request.user) + ) + alq = ( + StarredAlbum.select(StarredAlbum.starred) + .join(Album) + .where(StarredAlbum.user == request.user) + ) + trq = ( + StarredTrack.select(StarredTrack.starred) + .join(Track) + .where(StarredTrack.user == request.user) + ) if root is not None: - arq = arq.filter(lambda a: root in a.tracks.root_folder) - alq = alq.filter(lambda a: root in a.tracks.root_folder) - trq = trq.filter(lambda t: t.root_folder == root) + arq = arq.join(Track).where(Track.root_folder == root) + alq = alq.join(Track).where(Track.root_folder == root) + trq = trq.where(Track.root_folder == root) return request.formatter( "starred2", diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index 4b905339..a8c8bb0b 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -1,15 +1,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import time from flask import current_app, request -from pony.orm import delete -from pony.orm import ObjectNotFound from ..db import Track, Album, Artist, Folder from ..db import StarredTrack, StarredAlbum, StarredArtist, StarredFolder diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index c25746b4..f1c11c77 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -9,7 +9,6 @@ import string from flask import current_app, request -from pony.orm import select, count from ..db import Folder, Artist, Album, Track diff --git a/supysonic/api/errors.py b/supysonic/api/errors.py index 7050b9e4..5f2e8fe7 100644 --- a/supysonic/api/errors.py +++ b/supysonic/api/errors.py @@ -1,12 +1,11 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2018-2019 Alban 'spl0k' Féron +# Copyright (C) 2018-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. -from pony.orm import rollback -from pony.orm import ObjectNotFound +from peewee import DoesNotExist from werkzeug.exceptions import BadRequestKeyError from . import api @@ -15,25 +14,21 @@ @api.errorhandler(ValueError) def value_error(e): - rollback() return GenericError("{0.__class__.__name__}: {0}".format(e)) @api.errorhandler(BadRequestKeyError) def key_error(e): - rollback() return MissingParameter() -@api.errorhandler(ObjectNotFound) +@api.errorhandler(DoesNotExist) def object_not_found(e): - rollback() - return NotFound(e.entity.__name__) + return NotFound(e.__class__.__name__[: -len("DoesNotExist")]) @api.errorhandler(500) def generic_error(e): # pragma: nocover - rollback() return ServerError("{0.__class__.__name__}: {0}".format(e)) diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 4dd9972a..70a53eb6 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -1,14 +1,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019 Alban 'spl0k' Féron +# Copyright (C) 2019-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import uuid from flask import current_app, request -from pony.orm import ObjectNotFound from ..daemon import DaemonClient from ..daemon.exceptions import DaemonUnavailableError diff --git a/supysonic/api/media.py b/supysonic/api/media.py index e8162032..16702e28 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # 2018-2019 Carey 'pR0Ps' Metcalfe # # Distributed under terms of the GNU AGPLv3 license. @@ -20,7 +20,6 @@ from flask import request, Response, send_file from flask import current_app from PIL import Image -from pony.orm import ObjectNotFound from xml.etree import ElementTree from zipstream import ZipStream diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 605acda2..a56072d2 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -8,7 +8,6 @@ from collections import OrderedDict from datetime import datetime from flask import request -from pony.orm import select from ..db import Folder, Track, Artist, Album diff --git a/supysonic/db.py b/supysonic/db.py index ef99907b..e23f858a 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -25,7 +25,7 @@ IntegerField, TextField, ) -from peewee import CompositeKey, DatabaseProxy +from peewee import CompositeKey, DatabaseProxy, MySQLDatabase from peewee import fn from playhouse.db_url import parseresult_to_dict, schemes from urllib.parse import urlparse @@ -38,11 +38,18 @@ def now(): return datetime.now().replace(microsecond=0) +def random(): + if isinstance(db.obj, MySQLDatabase): + return fn.rand() + return fn.random() + + def PrimaryKeyField(**kwargs): return BinaryUUIDField(primary_key=True, default=uuid4, **kwargs) db = DatabaseProxy() +db.Model._meta.legacy_table_names = False class Meta(db.Model): diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index 6e0302cc..848f8a51 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -7,8 +7,6 @@ import unittest -from pony.orm import db_session - from supysonic.db import Folder, Artist, Album, Track from .apitestbase import ApiTestBase @@ -21,41 +19,40 @@ class AlbumSongsTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - folder = Folder(name="Root", root=True, path="tests/assets") - empty = Folder(name="Root", root=True, path="/tmp") - artist = Artist(name="Artist") - album = Album(name="Album", artist=artist) - - Track( - title="Track 1", - album=album, - artist=artist, - disc=1, - number=1, - year=123, - path="tests/assets/folder/1", - folder=folder, - root_folder=folder, - duration=2, - bitrate=320, - last_modification=0, - ) - Track( - title="Track 2", - album=album, - artist=artist, - disc=1, - number=1, - year=124, - genre="Lampshade", - path="tests/assets/folder/2", - folder=folder, - root_folder=folder, - duration=2, - bitrate=320, - last_modification=0, - ) + folder = Folder.create(name="Root", root=True, path="tests/assets") + empty = Folder.create(name="Root", root=True, path="/tmp") + artist = Artist.create(name="Artist") + album = Album.create(name="Album", artist=artist) + + Track.create( + title="Track 1", + album=album, + artist=artist, + disc=1, + number=1, + year=123, + path="tests/assets/folder/1", + folder=folder, + root_folder=folder, + duration=2, + bitrate=320, + last_modification=0, + ) + Track.create( + title="Track 2", + album=album, + artist=artist, + disc=1, + number=1, + year=124, + genre="Lampshade", + path="tests/assets/folder/2", + folder=folder, + root_folder=folder, + duration=2, + bitrate=320, + last_modification=0, + ) def test_get_album_list(self): self._make_request("getAlbumList", error=10) @@ -140,8 +137,7 @@ def test_get_album_list(self): ) self.assertEqual(len(child), 0) - with db_session: - Folder[1].delete() + Folder[1].delete_instance() rv, child = self._make_request( "getAlbumList", {"type": "random"}, tag="albumList" ) @@ -231,9 +227,8 @@ def test_get_album_list2(self): ) self.assertEqual(len(child), 0) - with db_session: - Track.select().delete() - Album.get().delete() + Track.delete().execute() + Album.delete().execute() rv, child = self._make_request( "getAlbumList2", {"type": "random"}, tag="albumList2" ) diff --git a/tests/testbase.py b/tests/testbase.py index 88ebb237..b6682011 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -13,7 +13,7 @@ import tempfile import unittest -from supysonic.db import init_database, release_database +from supysonic.db import release_database from supysonic.config import DefaultConfig from supysonic.managers.user import UserManager from supysonic.web import create_application @@ -93,9 +93,6 @@ def setUp(self): self.config.BASE["database_uri"] = "sqlite:///" + self.__db[1] self.config.WEBAPP["cache_dir"] = self.__dir - init_database(self.config.BASE["database_uri"]) - release_database() - self.__app = create_application(self.config) self.client = self.__app.test_client() From 7401b4dec9014b6df2b5a0cfe0a8d67ca10db53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 18 Dec 2022 17:24:05 +0100 Subject: [PATCH 122/237] Porting supysonic.api.annotation --- supysonic/api/annotation.py | 31 ++++--- tests/api/test_annotation.py | 164 ++++++++++++++--------------------- 2 files changed, 79 insertions(+), 116 deletions(-) diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index a8c8bb0b..ae1fa4fd 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -28,16 +28,16 @@ def star_single(cls, starcls, eid): try: e = cls[eid] - except ObjectNotFound: + except cls.DoesNotExist: raise NotFound("{} {}".format(cls.__name__, eid)) try: starcls[request.user, eid] raise GenericError("{} {} already starred".format(cls.__name__, eid)) - except ObjectNotFound: + except starcls.DoesNotExist: pass - starcls(user=request.user, starred=e) + starcls.create(user=request.user, starred=e) def unstar_single(cls, starcls, eid): @@ -48,7 +48,9 @@ def unstar_single(cls, starcls, eid): :param eid: id of the entity to unstar """ - delete(s for s in starcls if s.user.id == request.user.id and s.starred.id == eid) + starcls.delete().where( + starcls.user == request.user, starcls.starred == eid + ).execute() def handle_star_request(func): @@ -139,17 +141,13 @@ def rate(): if rating == 0: if tid is not None: - delete( - r - for r in RatingTrack - if r.user.id == request.user.id and r.rated.id == tid - ) + RatingTrack.delete().where( + RatingTrack.user == request.user, RatingTrack.rated == tid + ).execute() else: - delete( - r - for r in RatingFolder - if r.user.id == request.user.id and r.rated.id == fid - ) + RatingFolder.delete().where( + RatingFolder.user == request.user, RatingFolder.rated == fid + ).execute() else: if tid is not None: rated = Track[tid] @@ -163,8 +161,9 @@ def rate(): try: rating_info = rating_cls[request.user, uid] rating_info.rating = rating - except ObjectNotFound: - rating_cls(user=request.user, rated=rated, rating=rating) + rating_info.save() + except rating_cls.DoesNotExist: + rating_cls.create(user=request.user, rated=rated, rating=rating) return request.formatter.empty diff --git a/tests/api/test_annotation.py b/tests/api/test_annotation.py index 07b96e7b..845f2427 100644 --- a/tests/api/test_annotation.py +++ b/tests/api/test_annotation.py @@ -1,15 +1,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import unittest import uuid -from pony.orm import db_session - from supysonic.db import Folder, Artist, Album, Track, User, ClientPrefs from .apitestbase import ApiTestBase @@ -19,35 +17,37 @@ class AnnotationTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - root = Folder(name="Root", root=True, path="tests") - folder = Folder(name="Folder", path="tests/assets", parent=root) - artist = Artist(name="Artist") - album = Album(name="Album", artist=artist) - - # Populate folder ids - root = Folder.get(name="Root") - folder = Folder.get(name="Folder") - - track = Track( - title="Track", - album=album, - artist=artist, - disc=1, - number=1, - path="tests/assets/empty", - folder=folder, - root_folder=root, - duration=2, - bitrate=320, - last_modification=0, - ) + root = Folder.create(name="Root", root=True, path="tests") + folder = Folder.create( + name="Folder", root=False, path="tests/assets", parent=root + ) + artist = Artist.create(name="Artist") + album = Album.create(name="Album", artist=artist) + + # Populate folder ids + root = Folder.get(name="Root") + folder = Folder.get(name="Folder") + + track = Track.create( + title="Track", + album=album, + artist=artist, + disc=1, + number=1, + path="tests/assets/empty", + folder=folder, + root_folder=root, + duration=2, + bitrate=320, + last_modification=0, + ) - self.folderid = folder.id - self.artistid = artist.id - self.albumid = album.id - self.trackid = track.id - self.user = User.get(name="alice") + self.folderid = folder.id + self.artistid = artist.id + self.albumid = album.id + self.trackid = track.id + self.user = User.get(name="alice") + self.prefs = ClientPrefs.create(user=self.user, client_name="tests") def test_star(self): self._make_request("star", error=10) @@ -61,36 +61,27 @@ def test_star(self): self._make_request("star", {"id": str(self.artistid)}, error=70) self._make_request("star", {"id": str(self.albumid)}, error=70) self._make_request("star", {"id": str(self.trackid)}, skip_post=True) - with db_session: - prefs = ClientPrefs.get( - lambda p: p.user.name == "alice" and p.client_name == "tests" - ) - self.assertIn( - "starred", Track[self.trackid].as_subsonic_child(self.user, prefs) - ) + self.assertIn( + "starred", Track[self.trackid].as_subsonic_child(self.user, self.prefs) + ) self._make_request("star", {"id": str(self.trackid)}, error=0) self._make_request("star", {"id": str(self.folderid)}, skip_post=True) - with db_session: - self.assertIn("starred", Folder[self.folderid].as_subsonic_child(self.user)) + self.assertIn("starred", Folder[self.folderid].as_subsonic_child(self.user)) self._make_request("star", {"id": str(self.folderid)}, error=0) self._make_request("star", {"albumId": str(self.folderid)}, error=0) self._make_request("star", {"albumId": str(self.artistid)}, error=70) self._make_request("star", {"albumId": str(self.trackid)}, error=70) self._make_request("star", {"albumId": str(self.albumid)}, skip_post=True) - with db_session: - self.assertIn("starred", Album[self.albumid].as_subsonic_album(self.user)) + self.assertIn("starred", Album[self.albumid].as_subsonic_album(self.user)) self._make_request("star", {"albumId": str(self.albumid)}, error=0) self._make_request("star", {"artistId": str(self.folderid)}, error=0) self._make_request("star", {"artistId": str(self.albumid)}, error=70) self._make_request("star", {"artistId": str(self.trackid)}, error=70) self._make_request("star", {"artistId": str(self.artistid)}, skip_post=True) - with db_session: - self.assertIn( - "starred", Artist[self.artistid].as_subsonic_artist(self.user) - ) + self.assertIn("starred", Artist[self.artistid].as_subsonic_artist(self.user)) self._make_request("star", {"artistId": str(self.artistid)}, error=0) def test_unstar(self): @@ -110,31 +101,18 @@ def test_unstar(self): self._make_request("unstar", {"artistId": "unknown"}, error=0) self._make_request("unstar", {"id": str(self.trackid)}, skip_post=True) - with db_session: - prefs = ClientPrefs.get( - lambda p: p.user.name == "alice" and p.client_name == "tests" - ) - self.assertNotIn( - "starred", Track[self.trackid].as_subsonic_child(self.user, prefs) - ) + self.assertNotIn( + "starred", Track[self.trackid].as_subsonic_child(self.user, self.prefs) + ) self._make_request("unstar", {"id": str(self.folderid)}, skip_post=True) - with db_session: - self.assertNotIn( - "starred", Folder[self.folderid].as_subsonic_child(self.user) - ) + self.assertNotIn("starred", Folder[self.folderid].as_subsonic_child(self.user)) self._make_request("unstar", {"albumId": str(self.albumid)}, skip_post=True) - with db_session: - self.assertNotIn( - "starred", Album[self.albumid].as_subsonic_album(self.user) - ) + self.assertNotIn("starred", Album[self.albumid].as_subsonic_album(self.user)) self._make_request("unstar", {"artistId": str(self.artistid)}, skip_post=True) - with db_session: - self.assertNotIn( - "starred", Artist[self.artistid].as_subsonic_artist(self.user) - ) + self.assertNotIn("starred", Artist[self.artistid].as_subsonic_artist(self.user)) def test_set_rating(self): self._make_request("setRating", error=10) @@ -158,58 +136,44 @@ def test_set_rating(self): ) self._make_request("setRating", {"id": str(self.trackid), "rating": 6}, error=0) - with db_session: - prefs = ClientPrefs.get( - lambda p: p.user.name == "alice" and p.client_name == "tests" - ) - self.assertNotIn( - "userRating", Track[self.trackid].as_subsonic_child(self.user, prefs) - ) + self.assertNotIn( + "userRating", Track[self.trackid].as_subsonic_child(self.user, self.prefs) + ) for i in range(1, 6): self._make_request( "setRating", {"id": str(self.trackid), "rating": i}, skip_post=True ) - with db_session: - prefs = ClientPrefs.get( - lambda p: p.user.name == "alice" and p.client_name == "tests" - ) - self.assertEqual( - Track[self.trackid].as_subsonic_child(self.user, prefs)[ - "userRating" - ], - i, - ) + self.assertEqual( + Track[self.trackid].as_subsonic_child(self.user, self.prefs)[ + "userRating" + ], + i, + ) self._make_request( "setRating", {"id": str(self.trackid), "rating": 0}, skip_post=True ) - with db_session: - prefs = ClientPrefs.get( - lambda p: p.user.name == "alice" and p.client_name == "tests" - ) - self.assertNotIn( - "userRating", Track[self.trackid].as_subsonic_child(self.user, prefs) - ) + self.assertNotIn( + "userRating", Track[self.trackid].as_subsonic_child(self.user, self.prefs) + ) - self.assertNotIn( - "userRating", Folder[self.folderid].as_subsonic_child(self.user) - ) + self.assertNotIn( + "userRating", Folder[self.folderid].as_subsonic_child(self.user) + ) for i in range(1, 6): self._make_request( "setRating", {"id": str(self.folderid), "rating": i}, skip_post=True ) - with db_session: - self.assertEqual( - Folder[self.folderid].as_subsonic_child(self.user)["userRating"], i - ) + self.assertEqual( + Folder[self.folderid].as_subsonic_child(self.user)["userRating"], i + ) self._make_request( "setRating", {"id": str(self.folderid), "rating": 0}, skip_post=True ) - with db_session: - self.assertNotIn( - "userRating", Folder[self.folderid].as_subsonic_child(self.user) - ) + self.assertNotIn( + "userRating", Folder[self.folderid].as_subsonic_child(self.user) + ) def test_scrobble(self): self._make_request("scrobble", error=10) From 95f77cc170ab9a66cc359eb787ae300180de8f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 18 Dec 2022 18:03:51 +0100 Subject: [PATCH 123/237] Porting supysonic.api.browse --- supysonic/api/__init__.py | 24 +++-- supysonic/api/browse.py | 17 ++-- supysonic/db.py | 2 +- tests/api/test_browse.py | 190 ++++++++++++++++++-------------------- 4 files changed, 114 insertions(+), 119 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 29ae0476..0d612aa8 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -96,8 +96,7 @@ def get_entity(cls, param="id"): eid = int(eid) else: eid = uuid.UUID(eid) - entity = cls[eid] - return entity + return cls[eid] def get_entity_id(cls, eid): @@ -107,12 +106,12 @@ def get_entity_id(cls, eid): raise GenericError("Invalid ID") try: return int(eid) - except ValueError: - raise GenericError("Invalid ID") + except ValueError as e: + raise GenericError("Invalid ID") from e try: return uuid.UUID(eid) - except (AttributeError, ValueError): - raise GenericError("Invalid ID") + except (AttributeError, ValueError) as e: + raise GenericError("Invalid ID") from e def get_root_folder(id): @@ -121,14 +120,13 @@ def get_root_folder(id): try: fid = int(id) - except ValueError: - raise ValueError("Invalid folder ID") + except ValueError as e: + raise ValueError("Invalid folder ID") from e - folder = Folder.get(id=fid, root=True) - if folder is None: - raise NotFound("Folder") - - return folder + try: + return Folder.get(id=fid, root=True) + except Folder.DoesNotExist as e: + raise NotFound("Folder") from e from .errors import * diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index f1c11c77..2bd13a01 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -9,6 +9,7 @@ import string from flask import current_app, request +from peewee import fn from ..db import Folder, Artist, Album, Track @@ -22,7 +23,7 @@ def list_folders(): { "musicFolder": [ {"id": str(f.id), "name": f.name} - for f in Folder.select(lambda f: f.root).order_by(Folder.name) + for f in Folder.select().where(Folder.root).order_by(Folder.name) ] }, ) @@ -77,7 +78,7 @@ def list_indexes(): ifModifiedSince = int(ifModifiedSince) / 1000 if musicFolderId is None: - folders = Folder.select(lambda f: f.root)[:] + folders = Folder.select().where(Folder.root)[:] else: folders = [get_root_folder(musicFolderId)] @@ -95,8 +96,8 @@ def list_indexes(): artists = [] children = [] for f in folders: - artists += f.children.select()[:] - children += f.tracks.select()[:] + artists += f.children[:] + children += f.tracks[:] indexes = build_indexes(artists) return request.formatter( @@ -137,9 +138,11 @@ def list_genres(): { "genre": [ {"value": genre, "songCount": sc, "albumCount": ac} - for genre, sc, ac in select( - (t.genre, count(), count(t.album)) for t in Track if t.genre + for genre, sc, ac in Track.select( + Track.genre, fn.count(), fn.count(Track.album.distinct()) ) + .group_by(Track.genre) + .tuples() ] }, ) @@ -152,7 +155,7 @@ def list_artists(): query = Artist.select() if mfid is not None: folder = get_root_folder(mfid) - query = Artist.select(lambda a: folder in a.tracks.root_folder) + query = Artist.select().join(Track).where(Track.root_folder == folder) indexes = build_indexes(query) return request.formatter( diff --git a/supysonic/db.py b/supysonic/db.py index e23f858a..6a8d9e6d 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -150,7 +150,7 @@ def as_subsonic_directory(self, user, client): # "Directory" type in XSD "name": self.name, "child": [ f.as_subsonic_child(user) - for f in self.children.order_by(lambda c: c.name.lower()) + for f in self.children.order_by(fn.lower(Folder.name)) ] + [ t.as_subsonic_child(user, client) diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index bacb5bb9..73579e3d 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -9,8 +9,6 @@ import unittest import uuid -from pony.orm import db_session - from supysonic.db import Folder, Artist, Album, Track from .apitestbase import ApiTestBase @@ -20,51 +18,52 @@ class BrowseTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - self.empty_root = Folder(root=True, name="Empty root", path="/tmp") - self.root = Folder(root=True, name="Root folder", path="tests/assets") - - for letter in "ABC": - folder = Folder( - name=letter + "rtist", - path="tests/assets/{}rtist".format(letter), - parent=self.root, + self.empty_root = Folder.create(root=True, name="Empty root", path="/tmp") + self.root = Folder.create(root=True, name="Root folder", path="tests/assets") + + for letter in "ABC": + folder = Folder.create( + name=letter + "rtist", + path="tests/assets/{}rtist".format(letter), + root=False, + parent=self.root, + ) + + artist = Artist.create(name=letter + "rtist") + + for lether in "AB": + afolder = Folder.create( + name=letter + lether + "lbum", + path="tests/assets/{0}rtist/{0}{1}lbum".format(letter, lether), + root=False, + parent=folder, ) - artist = Artist(name=letter + "rtist") - - for lether in "AB": - afolder = Folder( - name=letter + lether + "lbum", - path="tests/assets/{0}rtist/{0}{1}lbum".format(letter, lether), - parent=folder, + album = Album.create(name=letter + lether + "lbum", artist=artist) + + for num, song in enumerate(["One", "Two", "Three"]): + Track.create( + disc=1, + number=num, + title=song, + duration=2, + album=album, + artist=artist, + genre="Music!", + bitrate=320, + path="tests/assets/{0}rtist/{0}{1}lbum/{2}".format( + letter, lether, song + ), + last_modification=0, + root_folder=self.root, + folder=afolder, ) - album = Album(name=letter + lether + "lbum", artist=artist) - - for num, song in enumerate(["One", "Two", "Three"]): - Track( - disc=1, - number=num, - title=song, - duration=2, - album=album, - artist=artist, - genre="Music!", - bitrate=320, - path="tests/assets/{0}rtist/{0}{1}lbum/{2}".format( - letter, lether, song - ), - last_modification=0, - root_folder=self.root, - folder=afolder, - ) - - self.assertEqual(Folder.select().count(), 11) - self.assertEqual(Folder.select(lambda f: f.root).count(), 2) - self.assertEqual(Artist.select().count(), 3) - self.assertEqual(Album.select().count(), 6) - self.assertEqual(Track.select().count(), 18) + self.assertEqual(Folder.select().count(), 11) + self.assertEqual(Folder.select().where(Folder.root).count(), 2) + self.assertEqual(Artist.select().count(), 3) + self.assertEqual(Album.select().count(), 6) + self.assertEqual(Track.select().count(), 18) def test_get_music_folders(self): rv, child = self._make_request("getMusicFolders", tag="musicFolders") @@ -86,8 +85,7 @@ def test_get_indexes(self): ) self.assertEqual(len(child), 0) - with db_session: - fid = Folder.get(name="Empty root").id + fid = Folder.get(name="Empty root").id rv, child = self._make_request( "getIndexes", {"musicFolderId": str(fid)}, tag="indexes" ) @@ -106,27 +104,26 @@ def test_get_music_directory(self): self._make_request("getMusicDirectory", {"id": 1234567890}, error=70) # should test with folders with both children folders and tracks. this code would break in that case - with db_session: - for f in Folder.select(): - rv, child = self._make_request( - "getMusicDirectory", {"id": str(f.id)}, tag="directory" - ) - self.assertEqual(child.get("id"), str(f.id)) - self.assertEqual(child.get("name"), f.name) - self.assertEqual(len(child), f.children.count() + f.tracks.count()) - for dbc, xmlc in zip( - sorted(f.children, key=lambda c: c.name), - sorted(child, key=lambda c: c.get("title")), - ): - self.assertEqual(dbc.name, xmlc.get("title")) - self.assertEqual(xmlc.get("artist"), f.name) - self.assertEqual(xmlc.get("parent"), str(f.id)) - for t, xmlc in zip( - sorted(f.tracks, key=lambda t: t.title), - sorted(child, key=lambda c: c.get("title")), - ): - self.assertEqual(t.title, xmlc.get("title")) - self.assertEqual(xmlc.get("parent"), str(f.id)) + for f in Folder.select(): + rv, child = self._make_request( + "getMusicDirectory", {"id": str(f.id)}, tag="directory" + ) + self.assertEqual(child.get("id"), str(f.id)) + self.assertEqual(child.get("name"), f.name) + self.assertEqual(len(child), f.children.count() + f.tracks.count()) + for dbc, xmlc in zip( + sorted(f.children, key=lambda c: c.name), + sorted(child, key=lambda c: c.get("title")), + ): + self.assertEqual(dbc.name, xmlc.get("title")) + self.assertEqual(xmlc.get("artist"), f.name) + self.assertEqual(xmlc.get("parent"), str(f.id)) + for t, xmlc in zip( + sorted(f.tracks, key=lambda t: t.title), + sorted(child, key=lambda c: c.get("title")), + ): + self.assertEqual(t.title, xmlc.get("title")) + self.assertEqual(xmlc.get("parent"), str(f.id)) def test_get_artists(self): # same as getIndexes standard case @@ -158,51 +155,48 @@ def test_get_artist(self): self._make_request("getArtist", {"id": "artist"}, error=0) self._make_request("getArtist", {"id": str(uuid.uuid4())}, error=70) - with db_session: - for ar in Artist.select(): - rv, child = self._make_request( - "getArtist", {"id": str(ar.id)}, tag="artist" - ) - self.assertEqual(child.get("id"), str(ar.id)) - self.assertEqual(child.get("albumCount"), str(len(child))) - self.assertEqual(len(child), ar.albums.count()) - for dal, xal in zip( - sorted(ar.albums, key=lambda a: a.name), - sorted(child, key=lambda c: c.get("name")), - ): - self.assertEqual(dal.name, xal.get("name")) - self.assertEqual( - xal.get("artist"), ar.name - ) # could break with a better dataset - self.assertEqual(xal.get("artistId"), str(ar.id)) # see above + for ar in Artist.select(): + rv, child = self._make_request( + "getArtist", {"id": str(ar.id)}, tag="artist" + ) + self.assertEqual(child.get("id"), str(ar.id)) + self.assertEqual(child.get("albumCount"), str(len(child))) + self.assertEqual(len(child), ar.albums.count()) + for dal, xal in zip( + sorted(ar.albums, key=lambda a: a.name), + sorted(child, key=lambda c: c.get("name")), + ): + self.assertEqual(dal.name, xal.get("name")) + self.assertEqual( + xal.get("artist"), ar.name + ) # could break with a better dataset + self.assertEqual(xal.get("artistId"), str(ar.id)) # see above def test_get_album(self): self._make_request("getAlbum", error=10) self._make_request("getAlbum", {"id": "nastynasty"}, error=0) self._make_request("getAlbum", {"id": str(uuid.uuid4())}, error=70) - with db_session: - a = Album.select().first() - rv, child = self._make_request("getAlbum", {"id": str(a.id)}, tag="album") - self.assertEqual(child.get("id"), str(a.id)) - self.assertEqual(child.get("songCount"), str(len(child))) + a = Album.select().first() + rv, child = self._make_request("getAlbum", {"id": str(a.id)}, tag="album") + self.assertEqual(child.get("id"), str(a.id)) + self.assertEqual(child.get("songCount"), str(len(child))) - self.assertEqual(len(child), a.tracks.count()) - for dal, xal in zip( - sorted(a.tracks, key=lambda t: t.title), - sorted(child, key=lambda c: c.get("title")), - ): - self.assertEqual(dal.title, xal.get("title")) - self.assertEqual(xal.get("album"), a.name) - self.assertEqual(xal.get("albumId"), str(a.id)) + self.assertEqual(len(child), a.tracks.count()) + for dal, xal in zip( + sorted(a.tracks, key=lambda t: t.title), + sorted(child, key=lambda c: c.get("title")), + ): + self.assertEqual(dal.title, xal.get("title")) + self.assertEqual(xal.get("album"), a.name) + self.assertEqual(xal.get("albumId"), str(a.id)) def test_get_song(self): self._make_request("getSong", error=10) self._make_request("getSong", {"id": "nastynasty"}, error=0) self._make_request("getSong", {"id": str(uuid.uuid4())}, error=70) - with db_session: - s = Track.select().first() + s = Track.select().first() self._make_request("getSong", {"id": str(s.id)}, tag="song") def test_get_videos(self): From 995c2a6ef21af573d966f78766994db25b1ab964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 23 Dec 2022 14:39:15 +0100 Subject: [PATCH 124/237] Porting supysonic.api.chat --- supysonic/api/chat.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index b5a2be9a..5135d716 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -18,7 +18,7 @@ def get_chat(): query = ChatMessage.select().order_by(ChatMessage.time) if since: - query = query.filter(lambda m: m.time > since) + query = query.where(ChatMessage.time > since) return request.formatter( "chatMessages", {"chatMessage": [msg.responsize() for msg in query]} @@ -28,6 +28,6 @@ def get_chat(): @api_routing("/addChatMessage") def add_chat_message(): msg = request.values["message"] - ChatMessage(user=request.user, message=msg) + ChatMessage.create(user=request.user, message=msg) return request.formatter.empty From b2c45ff03f84e46c078dd330f0253b11f24361c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 23 Dec 2022 15:36:40 +0100 Subject: [PATCH 125/237] Porting supysonic.api.media --- setup.cfg | 2 +- supysonic/api/media.py | 72 ++++++++++++++++++++++--------------- tests/api/test_media.py | 78 +++++++++++++++++++---------------------- 3 files changed, 82 insertions(+), 70 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8e13d85a..849a9f2a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,7 +54,7 @@ install_requires = click flask >=0.11 peewee - Pillow + Pillow >=9.1.0 requests >=1.0.0 mediafile watchdog >=0.8.0 diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 16702e28..5b6dfd0a 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -210,9 +210,12 @@ def handle_transcoding(): res.play_count = res.play_count + 1 res.last_play = now() + res.save() + user = request.user user.last_play = res user.last_play_date = now() + user.save() return response @@ -237,16 +240,16 @@ def download_media(): try: rv = Track[uid] return send_file(rv.path, mimetype=rv.mimetype, conditional=True) - except ObjectNotFound: + except Track.DoesNotExist: try: # Album -> stream zipped tracks rv = Album[uid] - except ObjectNotFound: - raise NotFound("Track or Album") + except Album.DoesNotExist as e: + raise NotFound("Track or Album") from e else: try: # Folder -> stream zipped tracks, non recursive rv = Folder[fid] - except ObjectNotFound: - raise NotFound("Folder") + except Folder.DoesNotExist as e: + raise NotFound("Folder") from e # Stream a zip of multiple files to the client z = ZipStream(sized=True) @@ -282,17 +285,16 @@ def download_media(): return resp -def _cover_from_track(tid): +def _cover_from_track(obj): """Extract and return a path to a track's cover art Returns None if no cover art is available. """ cache = current_app.cache - cache_key = "{}-cover".format(tid) + cache_key = "{}-cover".format(obj.id) try: return cache.get(cache_key) except CacheMiss: - obj = Track[tid] try: return cache.set(cache_key, mediafile.MediaFile(obj.path).art) except mediafile.UnreadableFileError: @@ -311,14 +313,16 @@ def _cover_from_collection(obj, extract=True): cover_path = os.path.join(obj.path, obj.cover_art) elif isinstance(obj, Album): - track_with_folder_cover = obj.tracks.select( - lambda t: t.folder.cover_art is not None - ).first() + track_with_folder_cover = ( + obj.tracks.join(Folder, on=Track.folder) + .where(Folder.cover_art.is_null(False)) + .first() + ) if track_with_folder_cover is not None: cover_path = _cover_from_collection(track_with_folder_cover.folder) if not cover_path and extract: - track_with_embedded = obj.tracks.select(lambda t: t.has_art).first() + track_with_embedded = obj.tracks.where(Track.has_art).first() if track_with_embedded is not None: cover_path = _cover_from_track(track_with_embedded.id) @@ -327,11 +331,7 @@ def _cover_from_collection(obj, extract=True): return cover_path -@api_routing("/getCoverArt") -def cover_art(): - cache = current_app.cache - - eid = request.values["id"] +def _get_cover_path(eid): try: fid = get_entity_id(Folder, eid) except GenericError: @@ -344,15 +344,31 @@ def cover_art(): if not fid and not uid: raise GenericError("Invalid ID") - cover_path = None - if fid and Folder.exists(id=eid): - cover_path = _cover_from_collection(get_entity(Folder)) - elif uid and Track.exists(id=eid): - cover_path = _cover_from_track(eid) - elif uid and Album.exists(id=uid): - cover_path = _cover_from_collection(get_entity(Album)) - else: - raise NotFound("Entity") + if fid: + try: + return _cover_from_collection(Folder[fid]) + except Folder.DoesNotExist: + pass + elif uid: + try: + return _cover_from_track(Track[uid]) + except Track.DoesNotExist: + pass + + try: + return _cover_from_collection(Album[uid]) + except Album.DoesNotExist: + pass + + raise NotFound("Entity") + + +@api_routing("/getCoverArt") +def cover_art(): + cache = current_app.cache + + eid = request.values["id"] + cover_path = _get_cover_path(eid) if not cover_path: raise NotFound("Cover art") @@ -365,7 +381,7 @@ def cover_art(): # extension for Flask to derive the mimetype from - derive it from the # contents instead. mimetype = None - if uid and os.path.splitext(cover_path)[1].lower() not in EXTENSIONS: + if os.path.splitext(cover_path)[1].lower() not in EXTENSIONS: with Image.open(cover_path) as im: mimetype = "image/{}".format(im.format.lower()) return send_file(cover_path, mimetype=mimetype) @@ -379,7 +395,7 @@ def cover_art(): try: return send_file(cache.get(cache_key), mimetype=mimetype) except CacheMiss: - im.thumbnail([size, size], Image.ANTIALIAS) + im.thumbnail([size, size], Image.Resampling.LANCZOS) with cache.set_fileobj(cache_key) as fp: im.save(fp, im.format) return send_file(cache.get(cache_key), mimetype=mimetype) diff --git a/tests/api/test_media.py b/tests/api/test_media.py index 6c16ef40..4272e5b0 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -12,7 +12,6 @@ from contextlib import closing from io import BytesIO from PIL import Image -from pony.orm import db_session from supysonic.db import Folder, Artist, Album, Track @@ -23,52 +22,51 @@ class MediaTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - folder = Folder( - name="Root", - path=os.path.abspath("tests/assets"), - root=True, - cover_art="cover.jpg", - ) - folder = Folder.get(name="Root") - self.folderid = folder.id - - artist = Artist(name="Artist") - album = Album(artist=artist, name="Album") + folder = Folder.create( + name="Root", + path=os.path.abspath("tests/assets"), + root=True, + cover_art="cover.jpg", + ) + folder = Folder.get(name="Root") + self.folderid = folder.id + + artist = Artist.create(name="Artist") + album = Album.create(artist=artist, name="Album") + + track = Track.create( + title="23bytes", + number=1, + disc=1, + artist=artist, + album=album, + path=os.path.abspath("tests/assets/23bytes"), + root_folder=folder, + folder=folder, + duration=2, + bitrate=320, + last_modification=0, + ) + self.trackid = track.id - track = Track( - title="23bytes", + self.formats = ["mp3", "flac", "ogg", "m4a"] + for i in range(len(self.formats)): + track_embeded_art = Track.create( + title="[silence]", number=1, disc=1, artist=artist, album=album, - path=os.path.abspath("tests/assets/23bytes"), + path=os.path.abspath( + "tests/assets/formats/silence.{}".format(self.formats[i]) + ), root_folder=folder, folder=folder, duration=2, bitrate=320, last_modification=0, ) - self.trackid = track.id - - self.formats = ["mp3", "flac", "ogg", "m4a"] - for i in range(len(self.formats)): - track_embeded_art = Track( - title="[silence]", - number=1, - disc=1, - artist=artist, - album=album, - path=os.path.abspath( - "tests/assets/formats/silence.{}".format(self.formats[i]) - ), - root_folder=folder, - folder=folder, - duration=2, - bitrate=320, - last_modification=0, - ) - self.formats[i] = track_embeded_art.id + self.formats[i] = track_embeded_art.id def test_stream(self): self._make_request("stream", error=10) @@ -98,8 +96,7 @@ def test_stream(self): ) as rv: self.assertEqual(rv.status_code, 200) self.assertEqual(len(rv.data), 23) - with db_session: - self.assertEqual(Track[self.trackid].play_count, 1) + self.assertEqual(Track[self.trackid].play_count, 1) def test_download(self): self._make_request("download", error=10) @@ -120,8 +117,7 @@ def test_download(self): ) as rv: self.assertEqual(rv.status_code, 200) self.assertEqual(len(rv.data), 23) - with db_session: - self.assertEqual(Track[self.trackid].play_count, 0) + self.assertEqual(Track[self.trackid].play_count, 0) # dowload folder rv = self.client.get( From 50b98e641a39b13b1529e6bd15fb3d32783acb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 23 Dec 2022 17:34:45 +0100 Subject: [PATCH 126/237] Fix for peewee pre-3.15.4 --- supysonic/db.py | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index 6a8d9e6d..05a227dc 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -25,7 +25,7 @@ IntegerField, TextField, ) -from peewee import CompositeKey, DatabaseProxy, MySQLDatabase +from peewee import CompositeKey, DatabaseProxy, Model, MySQLDatabase from peewee import fn from playhouse.db_url import parseresult_to_dict, schemes from urllib.parse import urlparse @@ -49,10 +49,15 @@ def PrimaryKeyField(**kwargs): db = DatabaseProxy() -db.Model._meta.legacy_table_names = False -class Meta(db.Model): +class _Model(Model): + class Meta: + database = db + legacy_table_names = False + + +class Meta(_Model): key = CharField(32, primary_key=True) value = CharField(256) @@ -64,23 +69,21 @@ def get(cls, *args, **kwargs): path = kwargs.pop("path", None) if path: kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest() - return db.Model.get.__func__(cls, *args, **kwargs) + return _Model.get.__func__(cls, *args, **kwargs) def __init__(self, *args, **kwargs): if "path" in kwargs: path = kwargs["path"] kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest() - db.Model.__init__(self, *args, **kwargs) + _Model.__init__(self, *args, **kwargs) def __setattr__(self, attr, value): - db.Model.__setattr__(self, attr, value) + _Model.__setattr__(self, attr, value) if attr == "path": - db.Model.__setattr__( - self, "_path_hash", sha1(value.encode("utf-8")).digest() - ) + _Model.__setattr__(self, "_path_hash", sha1(value.encode("utf-8")).digest()) -class Folder(PathMixin, db.Model): +class Folder(PathMixin, _Model): id = AutoField() root = BooleanField() name = CharField() @@ -177,7 +180,7 @@ def prune(cls): return total -class Artist(db.Model): +class Artist(_Model): id = PrimaryKeyField() name = CharField() @@ -209,7 +212,7 @@ def prune(cls): ) -class Album(db.Model): +class Album(_Model): id = PrimaryKeyField() name = CharField() artist = ForeignKeyField(Artist, backref="albums") @@ -269,7 +272,7 @@ def prune(cls): return cls.delete().where(cls.id.not_in(Track.select(Track.album))).execute() -class Track(PathMixin, db.Model): +class Track(PathMixin, _Model): id = PrimaryKeyField() disc = IntegerField() number = IntegerField() @@ -377,7 +380,7 @@ def sort_key(self): return f"{self.album.artist.name}{self.album.name}{self.disc:02}{self.number:02}{self.title}".lower() -class User(db.Model): +class User(_Model): id = PrimaryKeyField() name = CharField(64, unique=True) mail = CharField(null=True) @@ -414,7 +417,7 @@ def as_subsonic_user(self): } -class ClientPrefs(db.Model): +class ClientPrefs(_Model): user = ForeignKeyField(User, backref="clients") client_name = CharField(32) format = CharField(8, null=True) @@ -425,7 +428,7 @@ class Meta: def _make_starred_model(target_model): - class Starred(db.Model): + class Starred(_Model): user = ForeignKeyField(User, backref="+") starred = ForeignKeyField(target_model, backref="+") date = DateTimeField(default=now) @@ -444,7 +447,7 @@ class Meta: def _make_rating_model(target_model): - class Rating(db.Model): + class Rating(_Model): user = ForeignKeyField(User, backref="+") rated = ForeignKeyField(target_model, backref="+") rating = IntegerField() # min=1, max=5 @@ -460,7 +463,7 @@ class Meta: RatingTrack = _make_rating_model(Track) -class ChatMessage(db.Model): +class ChatMessage(_Model): id = PrimaryKeyField() user = ForeignKeyField(User, backref="+") time = IntegerField(default=lambda: int(time.time())) @@ -474,7 +477,7 @@ def responsize(self): } -class Playlist(db.Model): +class Playlist(_Model): id = PrimaryKeyField() user = ForeignKeyField(User, backref="playlists") name = CharField() @@ -547,7 +550,7 @@ def remove_at_indexes(self, indexes): self.tracks = ",".join(t for t in tracks if t) -class RadioStation(db.Model): +class RadioStation(_Model): id = PrimaryKeyField() stream_url = CharField() name = CharField() From 6179daf4cedd1e6cde11ef98ba6cfdd066aad8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 23 Dec 2022 18:57:14 +0100 Subject: [PATCH 127/237] Porting supysonic.api.playlists --- supysonic/api/playlists.py | 36 ++++++------ tests/api/test_playlist.py | 115 +++++++++++++++++++------------------ 2 files changed, 77 insertions(+), 74 deletions(-) diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index 6ae90794..1eb967b1 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -9,30 +9,29 @@ from flask import request -from ..db import Playlist, User, Track +from ..db import Playlist, User, Track, db from . import get_entity, api_routing -from .exceptions import Forbidden, MissingParameter, NotFound +from .exceptions import Forbidden, MissingParameter @api_routing("/getPlaylists") def list_playlists(): - query = Playlist.select( - lambda p: p.user.id == request.user.id or p.public - ).order_by(Playlist.name) + query = ( + Playlist.select() + .orwhere(Playlist.user == request.user, Playlist.public) + .order_by(Playlist.name) + ) username = request.values.get("username") if username: if not request.user.admin: raise Forbidden() + # get rather than join in the following query to raise an exception if the + # requested user doesn't exist user = User.get(name=username) - if user is None: - raise NotFound("User") - - query = Playlist.select(lambda p: p.user.name == username).order_by( - Playlist.name - ) + query = Playlist.select().where(Playlist.user == user).order_by(Playlist.name) return request.formatter( "playlists", @@ -43,7 +42,7 @@ def list_playlists(): @api_routing("/getPlaylist") def show_playlist(): res = get_entity(Playlist) - if res.user.id != request.user.id and not res.public and not request.user.admin: + if res.user != request.user and not res.public and not request.user.admin: raise Forbidden() info = res.as_subsonic_playlist(request.user) @@ -54,6 +53,7 @@ def show_playlist(): @api_routing("/createPlaylist") +@db.atomic() def create_playlist(): playlist_id, name = map(request.values.get, ("playlistId", "name")) # songId actually doesn't seem to be required @@ -63,14 +63,14 @@ def create_playlist(): if playlist_id: playlist = Playlist[playlist_id] - if playlist.user.id != request.user.id and not request.user.admin: + if playlist.user != request.user and not request.user.admin: raise Forbidden() playlist.clear() if name: playlist.name = name elif name: - playlist = Playlist(user=request.user, name=name) + playlist = Playlist.create(user=request.user, name=name) else: raise MissingParameter("playlistId or name") @@ -78,6 +78,7 @@ def create_playlist(): sid = uuid.UUID(sid) track = Track[sid] playlist.add(track) + playlist.save() return request.formatter.empty @@ -85,17 +86,17 @@ def create_playlist(): @api_routing("/deletePlaylist") def delete_playlist(): res = get_entity(Playlist) - if res.user.id != request.user.id and not request.user.admin: + if res.user != request.user and not request.user.admin: raise Forbidden() - res.delete() + res.delete_instance() return request.formatter.empty @api_routing("/updatePlaylist") def update_playlist(): res = get_entity(Playlist, "playlistId") - if res.user.id != request.user.id and not request.user.admin: + if res.user != request.user and not request.user.admin: raise Forbidden() playlist = res @@ -119,5 +120,6 @@ def update_playlist(): playlist.add(track) playlist.remove_at_indexes(to_remove) + playlist.save() return request.formatter.empty diff --git a/tests/api/test_playlist.py b/tests/api/test_playlist.py index a7c2a71a..d93d379a 100644 --- a/tests/api/test_playlist.py +++ b/tests/api/test_playlist.py @@ -1,15 +1,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import unittest import uuid -from pony.orm import db_session - from supysonic.db import Folder, Artist, Album, Track, Playlist, User from .apitestbase import ApiTestBase @@ -19,41 +17,45 @@ class PlaylistTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - root = Folder(root=True, name="Root folder", path="tests/assets") - artist = Artist(name="Artist") - album = Album(name="Album", artist=artist) - - songs = {} - for num, song in enumerate(["One", "Two", "Three", "Four"]): - track = Track( - disc=1, - number=num, - title=song, - duration=2, - album=album, - artist=artist, - bitrate=320, - path="tests/assets/" + song, - last_modification=0, - root_folder=root, - folder=root, - ) - songs[song] = track - - users = {u.name: u for u in User.select()} - - playlist = Playlist(user=users["alice"], name="Alice's") - playlist.add(songs["One"]) - playlist.add(songs["Three"]) - - playlist = Playlist(user=users["alice"], public=True, name="Alice's public") - playlist.add(songs["One"]) - playlist.add(songs["Two"]) - - playlist = Playlist(user=users["bob"], name="Bob's") - playlist.add(songs["Two"]) - playlist.add(songs["Four"]) + root = Folder.create(root=True, name="Root folder", path="tests/assets") + artist = Artist.create(name="Artist") + album = Album.create(name="Album", artist=artist) + + songs = {} + for num, song in enumerate(["One", "Two", "Three", "Four"]): + track = Track.create( + disc=1, + number=num, + title=song, + duration=2, + album=album, + artist=artist, + bitrate=320, + path="tests/assets/" + song, + last_modification=0, + root_folder=root, + folder=root, + ) + songs[song] = track + + users = {u.name: u for u in User.select()} + + playlist = Playlist.create(user=users["alice"], name="Alice's") + playlist.add(songs["One"]) + playlist.add(songs["Three"]) + playlist.save() + + playlist = Playlist.create( + user=users["alice"], public=True, name="Alice's public" + ) + playlist.add(songs["One"]) + playlist.add(songs["Two"]) + playlist.save() + + playlist = Playlist.create(user=users["bob"], name="Bob's") + playlist.add(songs["Two"]) + playlist.add(songs["Four"]) + playlist.save() def test_get_playlists(self): # get own playlists @@ -99,8 +101,12 @@ def test_get_playlist(self): self._make_request("getPlaylist", {"id": str(uuid.uuid4())}, error=70) # other's private from non admin - with db_session: - playlist = Playlist.get(lambda p: not p.public and p.user.name == "alice") + playlist = ( + Playlist.select() + .join(User) + .where(~Playlist.public, User.name == "alice") + .get() + ) self._make_request( "getPlaylist", {"u": "bob", "p": "B0b", "id": str(playlist.id)}, error=50 ) @@ -166,8 +172,7 @@ def test_create_playlist(self): ) # create more useful playlist - with db_session: - songs = {s.title: str(s.id) for s in Track.select()} + songs = {s.title: str(s.id) for s in Track.select()} self._make_request( "createPlaylist", { @@ -176,8 +181,7 @@ def test_create_playlist(self): }, skip_post=True, ) - with db_session: - playlist = Playlist.get(name="songs") + playlist = Playlist.get(name="songs") self.assertIsNotNone(playlist) rv, child = self._make_request( "getPlaylist", {"id": str(playlist.id)}, tag="playlist" @@ -201,7 +205,6 @@ def test_create_playlist(self): self.assertEqual(self._xpath(child, "count(./entry)"), 1) self.assertEqual(child[0].get("title"), "Two") - @db_session def assertPlaylistCountEqual(self, count): self.assertEqual(Playlist.select().count(), count) @@ -212,8 +215,7 @@ def test_delete_playlist(self): self._make_request("deletePlaylist", {"id": str(uuid.uuid4())}, error=70) # delete unowned when not admin - with db_session: - playlist = Playlist.select(lambda p: p.user.name == "alice").first() + playlist = Playlist.select().join(User).where(User.name == "alice").first() self._make_request( "deletePlaylist", {"u": "bob", "p": "B0b", "id": str(playlist.id)}, error=50 ) @@ -226,8 +228,7 @@ def test_delete_playlist(self): self.assertPlaylistCountEqual(2) # delete unowned when admin - with db_session: - playlist = Playlist.get(lambda p: p.user.name == "bob") + playlist = Playlist.select().join(User).where(User.name == "bob").get() self._make_request("deletePlaylist", {"id": str(playlist.id)}, skip_post=True) self.assertPlaylistCountEqual(1) @@ -238,12 +239,13 @@ def test_update_playlist(self): "updatePlaylist", {"playlistId": str(uuid.uuid4())}, error=70 ) - with db_session: - playlist = ( - Playlist.select(lambda p: p.user.name == "alice") - .order_by(Playlist.created) - .first() - ) + playlist = ( + Playlist.select() + .join(User) + .where(User.name == "alice") + .order_by(Playlist.created) + .first() + ) pid = str(playlist.id) self._make_request( "updatePlaylist", {"playlistId": pid, "songIdToAdd": "string"}, error=0 @@ -288,8 +290,7 @@ def test_update_playlist(self): self.assertEqual(self._xpath(child, "count(./entry)"), 1) self.assertEqual(self._find(child, "./entry").get("title"), "Three") - with db_session: - songs = {s.title: str(s.id) for s in Track.select()} + songs = {s.title: str(s.id) for s in Track.select()} self._make_request( "updatePlaylist", From 4fa744efcdf5ee09871d16c21ab4b7fb41528393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 30 Dec 2022 15:08:13 +0100 Subject: [PATCH 128/237] Porting supysonic.api.radio --- supysonic/api/radio.py | 10 ++++--- tests/api/test_radio.py | 63 ++++++++++++++++------------------------- 2 files changed, 31 insertions(+), 42 deletions(-) diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index d4f2e46a..e97d3cb7 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2020 Alban 'spl0k' Féron +# Copyright (C) 2020-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -15,7 +15,7 @@ @api_routing("/getInternetRadioStations") def get_radio_stations(): - query = RadioStation.select().sort_by(RadioStation.name) + query = RadioStation.select().order_by(RadioStation.name) return request.formatter( "internetRadioStations", {"internetRadioStation": [p.as_subsonic_station() for p in query]}, @@ -32,7 +32,7 @@ def create_radio_station(): ) if stream_url and name: - RadioStation(stream_url=stream_url, name=name, homepage_url=homepage_url) + RadioStation.create(stream_url=stream_url, name=name, homepage_url=homepage_url) else: raise MissingParameter("streamUrl or name") @@ -55,6 +55,8 @@ def update_radio_station(): if homepage_url: res.homepage_url = homepage_url + + res.save() else: raise MissingParameter("streamUrl or name") @@ -67,6 +69,6 @@ def delete_radio_station(): raise Forbidden() res = get_entity(RadioStation) - res.delete() + res.delete_instance() return request.formatter.empty diff --git a/tests/api/test_radio.py b/tests/api/test_radio.py index 5793b320..277da550 100644 --- a/tests/api/test_radio.py +++ b/tests/api/test_radio.py @@ -1,24 +1,18 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2020 Alban 'spl0k' Féron +# Copyright (C) 2020-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import uuid -from pony.orm import db_session - from supysonic.db import RadioStation from .apitestbase import ApiTestBase class RadioStationTestCase(ApiTestBase): - def setUp(self): - super().setUp() - - @db_session def assertRadioStationCountEqual(self, count): self.assertEqual(RadioStation.select().count(), count) @@ -55,11 +49,10 @@ def test_create_radio_station(self): # the correct value is 2 because _make_request uses GET then POST self.assertRadioStationCountEqual(2) - with db_session: - for rs in RadioStation.select(): - self.assertRadioStationEquals(rs, stream_url, name) + for rs in RadioStation.select(): + self.assertRadioStationEquals(rs, stream_url, name) - RadioStation.select().delete(bulk=True) + RadioStation.delete().execute() # create w/ all fields stream_url = "http://example.com/radio/create1" @@ -74,9 +67,8 @@ def test_create_radio_station(self): # the correct value is 2 because _make_request uses GET then POST self.assertRadioStationCountEqual(2) - with db_session: - for rs in RadioStation.select(): - self.assertRadioStationEquals(rs, stream_url, name, homepage_url) + for rs in RadioStation.select(): + self.assertRadioStationEquals(rs, stream_url, name, homepage_url) def test_update_radio_station(self): self._make_request( @@ -98,12 +90,11 @@ def test_update_radio_station(self): } # load a test record - with db_session: - station = RadioStation( - stream_url=test["stream_url"], - name=test["name"], - homepage_url=test["homepage_url"], - ) + station = RadioStation.create( + stream_url=test["stream_url"], + name=test["name"], + homepage_url=test["homepage_url"], + ) # check params self._make_request( @@ -132,8 +123,7 @@ def test_update_radio_station(self): }, ) - with db_session: - rs_update = RadioStation[station.id] + rs_update = RadioStation[station.id] self.assertRadioStationEquals( rs_update, update["stream_url"], update["name"], test["homepage_url"] @@ -150,8 +140,7 @@ def test_update_radio_station(self): }, ) - with db_session: - rs_update = RadioStation[station.id] + rs_update = RadioStation[station.id] self.assertRadioStationEquals( rs_update, update["stream_url"], update["name"], update["homepage_url"] @@ -173,12 +162,11 @@ def test_delete_radio_station(self): ) # delete - with db_session: - station = RadioStation( - stream_url="http://example.com/radio/delete", - name="Radio Delete", - homepage_url="http://example.com/update", - ) + station = RadioStation.create( + stream_url="http://example.com/radio/delete", + name="Radio Delete", + homepage_url="http://example.com/update", + ) self._make_request( "deleteInternetRadioStation", {"id": station.id}, skip_post=True @@ -188,13 +176,12 @@ def test_delete_radio_station(self): def test_get_radio_stations(self): test_range = 3 - with db_session: - for x in range(0, test_range): - RadioStation( - stream_url="http://example.com/radio-{}".format(x), - name="Radio {}".format(x), - homepage_url="http://example.com/update-{}".format(x), - ) + for x in range(test_range): + RadioStation.create( + stream_url="http://example.com/radio-{}".format(x), + name="Radio {}".format(x), + homepage_url="http://example.com/update-{}".format(x), + ) # verify happy path is clean self.assertRadioStationCountEqual(test_range) @@ -204,7 +191,7 @@ def test_get_radio_stations(self): self.assertEqual(len(child), test_range) # This order is guaranteed to work because the api returns in order by name. # Test data is sequential by design. - for x in range(0, test_range): + for x in range(test_range): station = child[x] self.assertTrue(station.get("streamUrl").endswith("radio-{}".format(x))) self.assertTrue(station.get("name").endswith("Radio {}".format(x))) From 8b93e0bc6e4749daedf71e96a2dbea5dd62b03e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 30 Dec 2022 15:21:30 +0100 Subject: [PATCH 129/237] Fix on scanner queuing --- supysonic/daemon/server.py | 4 +++- tests/api/test_scan.py | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index dc63ee2e..d8d6fb6b 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -72,7 +72,9 @@ def __listen(self): def start_scan(self, folders=[], force=False): if not folders: - folders = Folder.select().where(Folder.root)[:] + folders = [ + t[0] for t in Folder.select(Folder.name).where(Folder.root).tuples() + ] if self.__scanner is not None and self.__scanner.is_alive(): for f in folders: diff --git a/tests/api/test_scan.py b/tests/api/test_scan.py index 7b5454f5..9114474d 100644 --- a/tests/api/test_scan.py +++ b/tests/api/test_scan.py @@ -1,11 +1,10 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2020 Alban 'spl0k' Féron +# Copyright (C) 2020-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. -from pony.orm import db_session from time import sleep from threading import Thread @@ -32,8 +31,7 @@ class ScanWithDaemonTestCase(ApiTestBase): def setUp(self): super().setUp(apiVersion="1.16.0") - with db_session: - Folder(name="Root", root=True, path="tests/assets") + Folder.create(name="Root", root=True, path="tests/assets") self._daemon = Daemon(self.config) self._thread = Thread(target=self._daemon.run) From dd2ef2ffeb85b069d9c09bbc01f117b130ecab61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 30 Dec 2022 16:41:00 +0100 Subject: [PATCH 130/237] Porting supysonic.api.search --- supysonic/api/search.py | 80 +++++++++++++++++++++++++--------------- tests/api/test_search.py | 79 +++++++++++++++++++-------------------- 2 files changed, 89 insertions(+), 70 deletions(-) diff --git a/supysonic/api/search.py b/supysonic/api/search.py index a56072d2..90acc860 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -28,22 +28,32 @@ def old_search(): min_date = datetime.fromtimestamp(newer_than) if artist: - query = select( - t.folder.parent - for t in Track - if artist in t.folder.parent.name and t.folder.parent.created > min_date + Child = Folder.alias() + query = ( + Folder.select() + .join(Child, on=Child.parent == Folder.id) + .join(Track, on=Track.folder == Child.id) + .where(Folder.name.contains(artist), Folder.created > min_date) + .distinct() ) elif album: - query = select( - t.folder - for t in Track - if album in t.folder.name and t.folder.created > min_date + query = ( + Folder.select() + .join(Track, on=Track.folder) + .where(Folder.name.contains(album), Folder.created > min_date) + .distinct() ) elif title: - query = Track.select(lambda t: title in t.title and t.created > min_date) + query = Track.select().where( + Track.title.contains(title), Track.created > min_date + ) elif anyf: - folders = Folder.select(lambda f: anyf in f.name and f.created > min_date) - tracks = Track.select(lambda t: anyf in t.title and t.created > min_date) + folders = Folder.select().where( + Folder.name.contains(anyf), Folder.created > min_date + ) + tracks = Track.select().where( + Track.title.contains(anyf), Track.created > min_date + ) res = folders[offset : offset + count] fcount = folders.count() if offset + count > fcount: @@ -114,18 +124,30 @@ def new_search(): song_offset = int(song_offset) if song_offset else 0 root = get_root_folder(mfid) - artists = select(t.folder.parent for t in Track if query in t.folder.parent.name) - albums = select(t.folder for t in Track if query in t.folder.name) - songs = Track.select(lambda t: query in t.title) + Child = Folder.alias() + artists = ( + Folder.select() + .join(Child, on=Child.parent == Folder.id) + .join(Track, on=Track.folder == Child.id) + .where(Folder.name.contains(query)) + .distinct() + ) + albums = ( + Folder.select() + .join(Track, on=Track.folder) + .where(Folder.name.contains(query)) + .distinct() + ) + songs = Track.select().where(Track.title.contains(query)) if root is not None: - artists = artists.where(lambda t: t.root_folder == root) - albums = albums.where(lambda t: t.root_folder == root) - songs = songs.where(lambda t: t.root_folder == root) + artists = artists.where(Track.root_folder == root) + albums = albums.where(Track.root_folder == root) + songs = songs.where(Track.root_folder == root) - artists = artists.limit(artist_count, artist_offset) - albums = albums.limit(album_count, album_offset) - songs = songs.limit(song_count, song_offset) + artists = artists.limit(artist_count).offset(artist_offset) + albums = albums.limit(album_count).offset(album_offset) + songs = songs.limit(song_count).offset(song_offset) return request.formatter( "searchResult2", @@ -174,18 +196,18 @@ def search_id3(): song_offset = int(song_offset) if song_offset else 0 root = get_root_folder(mfid) - artists = Artist.select(lambda a: query in a.name) - albums = Album.select(lambda a: query in a.name) - songs = Track.select(lambda t: query in t.title) + artists = Artist.select().where(Artist.name.contains(query)) + albums = Album.select().where(Album.name.contains(query)) + songs = Track.select().where(Track.title.contains(query)) if root is not None: - artists = artists.where(lambda a: root in a.tracks.root_folder) - albums = albums.where(lambda a: root in a.tracks.root_folder) - songs = songs.where(lambda t: t.root_folder == root) + artists = artists.join(Track).where(Track.root_folder == root) + albums = albums.join(Track).where(Track.root_folder == root) + songs = songs.where(Track.root_folder == root) - artists = artists.limit(artist_count, artist_offset) - albums = albums.limit(album_count, album_offset) - songs = songs.limit(song_count, song_offset) + artists = artists.limit(artist_count).offset(artist_offset) + albums = albums.limit(album_count).offset(album_offset) + songs = songs.limit(song_count).offset(song_offset) return request.formatter( "searchResult3", diff --git a/tests/api/test_search.py b/tests/api/test_search.py index af633919..3aa71e84 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -8,8 +8,6 @@ import time import unittest -from pony.orm import db_session, commit - from supysonic.db import Folder, Artist, Album, Track from .apitestbase import ApiTestBase @@ -19,50 +17,49 @@ class SearchTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - root = Folder(root=True, name="Root folder", path="tests/assets") - Folder(root=True, name="Empty", path="/tmp") + root = Folder.create(root=True, name="Root folder", path="tests/assets") + Folder.create(root=True, name="Empty", path="/tmp") - for letter in "ABC": - folder = Folder( - name=letter + "rtist", - path="tests/assets/{}rtist".format(letter), - parent=root, + for letter in "ABC": + folder = Folder.create( + name=letter + "rtist", + path="tests/assets/{}rtist".format(letter), + root=False, + parent=root, + ) + artist = Artist.create(name=letter + "rtist") + + for lether in "AB": + afolder = Folder.create( + name=letter + lether + "lbum", + path="tests/assets/{0}rtist/{0}{1}lbum".format(letter, lether), + root=False, + parent=folder, ) - artist = Artist(name=letter + "rtist") - for lether in "AB": - afolder = Folder( - name=letter + lether + "lbum", - path="tests/assets/{0}rtist/{0}{1}lbum".format(letter, lether), - parent=folder, + album = Album.create(name=letter + lether + "lbum", artist=artist) + + for num, song in enumerate(["One", "Two", "Three"]): + Track.create( + disc=1, + number=num, + title=song, + duration=2, + album=album, + artist=artist, + bitrate=320, + path="tests/assets/{0}rtist/{0}{1}lbum/{2}".format( + letter, lether, song + ), + last_modification=0, + root_folder=root, + folder=afolder, ) - album = Album(name=letter + lether + "lbum", artist=artist) - - for num, song in enumerate(["One", "Two", "Three"]): - Track( - disc=1, - number=num, - title=song, - duration=2, - album=album, - artist=artist, - bitrate=320, - path="tests/assets/{0}rtist/{0}{1}lbum/{2}".format( - letter, lether, song - ), - last_modification=0, - root_folder=root, - folder=afolder, - ) - - commit() - - self.assertEqual(Folder.select().count(), 11) - self.assertEqual(Artist.select().count(), 3) - self.assertEqual(Album.select().count(), 6) - self.assertEqual(Track.select().count(), 18) + self.assertEqual(Folder.select().count(), 11) + self.assertEqual(Artist.select().count(), 3) + self.assertEqual(Album.select().count(), 6) + self.assertEqual(Track.select().count(), 18) def __track_as_pseudo_unique_str(self, elem): return elem.get("artist") + elem.get("album") + elem.get("title") From 42283817fe5b769a8e259f460a9d134d3476c2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 30 Dec 2022 16:44:09 +0100 Subject: [PATCH 131/237] Fix transcoding tests --- tests/api/test_transcoding.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index ec131fb4..7df37244 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -9,7 +9,6 @@ import sys from flask import current_app -from pony.orm import db_session from supysonic.db import Track from supysonic.managers.folder import FolderManager @@ -22,13 +21,12 @@ class TranscodingTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - FolderManager.add("Folder", "tests/assets/folder") - scanner = Scanner() - scanner.queue_folder("Folder") - scanner.run() + FolderManager.add("Folder", "tests/assets/folder") + scanner = Scanner() + scanner.queue_folder("Folder") + scanner.run() - self.trackid = Track.get().id + self.trackid = Track.get().id def _stream(self, **kwargs): kwargs.update( From 153c5f42baafd825105d1fb7eeebcdc0b26b5d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 30 Dec 2022 16:55:20 +0100 Subject: [PATCH 132/237] Porting supysonic.api.user --- supysonic/api/user.py | 11 ++++------- supysonic/db.py | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/supysonic/api/user.py b/supysonic/api/user.py index df51e882..4b4edf71 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2020 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -12,7 +12,7 @@ from ..managers.user import UserManager from . import decode_password, api_routing -from .exceptions import Forbidden, NotFound +from .exceptions import Forbidden def admin_only(f): @@ -33,9 +33,6 @@ def user_info(): raise Forbidden() user = User.get(name=username) - if user is None: - raise NotFound("User") - return request.formatter("user", user.as_subsonic_user()) @@ -99,8 +96,6 @@ def user_changepass(): def user_edit(): username = request.values["username"] user = User.get(name=username) - if user is None: - raise NotFound("User") if "password" in request.values: password = decode_password(request.values["password"]) @@ -120,4 +115,6 @@ def user_edit(): jukebox = jukebox in (True, "True", "true", 1, "1") user.jukebox = jukebox + user.save() + return request.formatter.empty diff --git a/supysonic/db.py b/supysonic/db.py index 05a227dc..841b6bd0 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -401,7 +401,7 @@ class User(_Model): def as_subsonic_user(self): return { "username": self.name, - "email": self.mail, + "email": self.mail or "", "scrobblingEnabled": self.lastfm_session is not None and self.lastfm_status, "adminRole": self.admin, "settingsRole": True, From e510f9622ad41fd7fc4358e15f0da3d413aaafd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 31 Dec 2022 16:47:24 +0100 Subject: [PATCH 133/237] Porting supysonic.frontend --- supysonic/frontend/__init__.py | 5 +- supysonic/frontend/folder.py | 11 ++-- supysonic/frontend/playlist.py | 95 +++++++++++++-------------------- supysonic/frontend/user.py | 29 +++++----- tests/frontend/test_folder.py | 19 +++---- tests/frontend/test_login.py | 9 ++-- tests/frontend/test_playlist.py | 64 ++++++++++------------ tests/frontend/test_user.py | 42 ++++++--------- 8 files changed, 114 insertions(+), 160 deletions(-) diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index 764d9c44..205aabb7 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2021 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -17,7 +17,6 @@ ) from flask import Blueprint from functools import wraps -from pony.orm import ObjectNotFound from .. import VERSION, DOWNLOAD_URL from ..daemon.client import DaemonClient @@ -42,7 +41,7 @@ def login_check(): user = UserManager.get(session.get("userid")) request.user = user should_login = False - except (ValueError, ObjectNotFound): + except (ValueError, User.DoesNotExist): session.clear() if should_login and request.endpoint != "frontend.login": diff --git a/supysonic/frontend/folder.py b/supysonic/frontend/folder.py index 3622a1f8..d7f1a3ce 100644 --- a/supysonic/frontend/folder.py +++ b/supysonic/frontend/folder.py @@ -1,12 +1,11 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. from flask import current_app, flash, redirect, render_template, request, url_for -from pony.orm import ObjectNotFound from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError @@ -29,7 +28,9 @@ def folder_index(): "warning", ) return render_template( - "folders.html", folders=Folder.select(lambda f: f.root), allow_scan=allow_scan + "folders.html", + folders=Folder.select().where(Folder.root), + allow_scan=allow_scan, ) @@ -71,7 +72,7 @@ def del_folder(id): flash("Deleted folder") except ValueError as e: flash(str(e), "error") - except ObjectNotFound: + except Folder.DoesNotExist: flash("No such folder", "error") return redirect(url_for("frontend.folder_index")) @@ -90,7 +91,7 @@ def scan_folder(id=None): flash("Scanning started") except ValueError as e: flash(str(e), "error") - except ObjectNotFound: + except Folder.DoesNotExist: flash("No such folder", "error") except DaemonUnavailableError: flash("Can't start scan", "error") diff --git a/supysonic/frontend/playlist.py b/supysonic/frontend/playlist.py index 4cd04187..2c1f4f94 100644 --- a/supysonic/frontend/playlist.py +++ b/supysonic/frontend/playlist.py @@ -1,14 +1,14 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import uuid from flask import Response, flash, redirect, render_template, request, url_for -from pony.orm import ObjectNotFound +from functools import wraps from ..db import Playlist @@ -19,42 +19,40 @@ def playlist_index(): return render_template( "playlists.html", - mine=Playlist.select(lambda p: p.user == request.user), - others=Playlist.select(lambda p: p.user != request.user and p.public), + mine=Playlist.select().where(Playlist.user == request.user), + others=Playlist.select().where(Playlist.user != request.user, Playlist.public), ) -@frontend.route("/playlist/") -def playlist_details(uid): - try: - uid = uuid.UUID(uid) - except ValueError: - flash("Invalid playlist id") - return redirect(url_for("frontend.playlist_index")) - - try: - playlist = Playlist[uid] - except ObjectNotFound: - flash("Unknown playlist") - return redirect(url_for("frontend.playlist_index")) +def resolve_and_inject_playlist(func): + @wraps(func) + def decorated(uid): + try: + uid = uuid.UUID(uid) + except ValueError: + flash("Invalid playlist id") + return redirect(url_for("frontend.playlist_index")) + + try: + playlist = Playlist[uid] + except Playlist.DoesNotExist: + flash("Unknown playlist") + return redirect(url_for("frontend.playlist_index")) + + return func(uid, playlist) + + return decorated + +@frontend.route("/playlist/") +@resolve_and_inject_playlist +def playlist_details(uid, playlist): return render_template("playlist.html", playlist=playlist) @frontend.route("/playlist//export") -def playlist_export(uid): - try: - uid = uuid.UUID(uid) - except ValueError: - flash("Invalid playlist id") - return redirect(url_for("frontend.playlist_index")) - - try: - playlist = Playlist[uid] - except ObjectNotFound: - flash("Unknown playlist") - return redirect(url_for("frontend.playlist_index")) - +@resolve_and_inject_playlist +def playlist_export(uid, playlist): return Response( render_template("playlist_export.m3u", playlist=playlist), mimetype="audio/mpegurl", @@ -65,20 +63,9 @@ def playlist_export(uid): @frontend.route("/playlist/", methods=["POST"]) -def playlist_update(uid): - try: - uid = uuid.UUID(uid) - except ValueError: - flash("Invalid playlist id") - return redirect(url_for("frontend.playlist_index")) - - try: - playlist = Playlist[uid] - except ObjectNotFound: - flash("Unknown playlist") - return redirect(url_for("frontend.playlist_index")) - - if playlist.user.id != request.user.id: +@resolve_and_inject_playlist +def playlist_update(uid, playlist): + if playlist.user_id != request.user.id: flash("You're not allowed to edit this playlist") elif not request.form.get("name"): flash("Missing playlist name") @@ -92,29 +79,19 @@ def playlist_update(uid): "on", "checked", ) + playlist.save() flash("Playlist updated.") return playlist_details(str(uid)) @frontend.route("/playlist/del/") -def playlist_delete(uid): - try: - uid = uuid.UUID(uid) - except ValueError: - flash("Invalid playlist id") - return redirect(url_for("frontend.playlist_index")) - - try: - playlist = Playlist[uid] - except ObjectNotFound: - flash("Unknown playlist") - return redirect(url_for("frontend.playlist_index")) - - if playlist.user.id != request.user.id: +@resolve_and_inject_playlist +def playlist_delete(uid, playlist): + if playlist.user_id != request.user.id: flash("You're not allowed to delete this playlist") else: - playlist.delete() + playlist.delete_instance() flash("Playlist deleted") return redirect(url_for("frontend.playlist_index")) diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index ce744085..c69fe58d 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -10,9 +10,8 @@ from flask import flash, redirect, render_template, request, session, url_for from flask import current_app from functools import wraps -from pony.orm import ObjectNotFound -from ..db import User +from ..db import ClientPrefs, User from ..lastfm import LastFm from ..managers.user import UserManager @@ -39,7 +38,7 @@ def decorated_func(*args, **kwargs): except ValueError as e: flash(str(e), "error") return redirect(url_for("frontend.index")) - except ObjectNotFound: + except User.DoesNotExist: flash("No such user", "error") return redirect(url_for("frontend.index")) @@ -91,7 +90,7 @@ def update_clients(uid, user): logger.debug(clients_opts) for client, opts in clients_opts.items(): - prefs = user.clients.select(lambda c: c.client_name == client).first() + prefs = user.clients.where(ClientPrefs.client_name == client).first() if prefs is None: continue @@ -102,13 +101,14 @@ def update_clients(uid, user): "selected", "1", ]: - prefs.delete() + prefs.delete_instance() continue prefs.format = opts["format"] if "format" in opts and opts["format"] else None prefs.bitrate = ( int(opts["bitrate"]) if "bitrate" in opts and opts["bitrate"] else None ) + prefs.save() flash("Clients preferences updated.") return user_profile(uid, user) @@ -122,7 +122,7 @@ def change_username_form(uid): except ValueError as e: flash(str(e), "error") return redirect(url_for("frontend.index")) - except ObjectNotFound: + except User.DoesNotExist: flash("No such user", "error") return redirect(url_for("frontend.index")) @@ -137,7 +137,7 @@ def change_username_post(uid): except ValueError as e: flash(str(e), "error") return redirect(url_for("frontend.index")) - except ObjectNotFound: + except User.DoesNotExist: flash("No such user", "error") return redirect(url_for("frontend.index")) @@ -145,9 +145,13 @@ def change_username_post(uid): if username in ("", None): flash("The username is required") return render_template("change_username.html", user=user) - if user.name != username and User.get(name=username) is not None: - flash("This name is already taken") - return render_template("change_username.html", user=user) + if user.name != username: + try: + User.get(name=username) + flash("This name is already taken") + return render_template("change_username.html", user=user) + except User.DoesNotExist: + pass if request.form.get("admin") is None: admin = False @@ -157,6 +161,7 @@ def change_username_post(uid): if user.name != username or user.admin != admin: user.name = username user.admin = admin + user.save() flash(f"User '{username}' updated.") else: flash(f"No changes for '{username}'.") @@ -262,7 +267,7 @@ def del_user(uid): flash("Deleted user") except ValueError as e: flash(str(e), "error") - except ObjectNotFound: + except User.DoesNotExist: flash("No such user", "error") return redirect(url_for("frontend.user_index")) diff --git a/tests/frontend/test_folder.py b/tests/frontend/test_folder.py index ce6a96a3..d3828853 100644 --- a/tests/frontend/test_folder.py +++ b/tests/frontend/test_folder.py @@ -1,14 +1,12 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2019 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import unittest -from pony.orm import db_session - from supysonic.db import Folder from .frontendtestbase import FrontendTestBase @@ -53,18 +51,15 @@ def test_add_post(self): follow_redirects=True, ) self.assertIn("created", rv.data) - with db_session: - self.assertEqual(Folder.select().count(), 1) + self.assertEqual(Folder.select().count(), 1) def test_delete(self): - with db_session: - folder = Folder(name="folder", path="tests/assets", root=True) + folder = Folder.create(name="folder", path="tests/assets", root=True) self._login("bob", "B0b") rv = self.client.get("/folder/del/" + str(folder.id), follow_redirects=True) self.assertIn("There's nothing much to see", rv.data) - with db_session: - self.assertEqual(Folder.select().count(), 1) + self.assertEqual(Folder.select().count(), 1) self._logout() self._login("alice", "Alic3") @@ -74,12 +69,10 @@ def test_delete(self): self.assertIn("No such folder", rv.data) rv = self.client.get("/folder/del/" + str(folder.id), follow_redirects=True) self.assertIn("Music folders", rv.data) - with db_session: - self.assertEqual(Folder.select().count(), 0) + self.assertEqual(Folder.select().count(), 0) def test_scan(self): - with db_session: - folder = Folder(name="folder", path="tests/assets/folder", root=True) + folder = Folder.create(name="folder", path="tests/assets/folder", root=True) self._login("alice", "Alic3") diff --git a/tests/frontend/test_login.py b/tests/frontend/test_login.py index 2a3853b4..b1705e55 100644 --- a/tests/frontend/test_login.py +++ b/tests/frontend/test_login.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -9,8 +9,6 @@ import unittest import uuid -from pony.orm import db_session - from supysonic.db import User from .frontendtestbase import FrontendTestBase @@ -50,9 +48,8 @@ def test_login_non_admin(self): def test_root_with_valid_session(self): # Root with valid session - with db_session: - with self.client.session_transaction() as sess: - sess["userid"] = User.get(name="alice").id + with self.client.session_transaction() as sess: + sess["userid"] = User.get(name="alice").id rv = self.client.get("/", follow_redirects=True) self.assertIn("alice", rv.data) self.assertIn("Log out", rv.data) diff --git a/tests/frontend/test_playlist.py b/tests/frontend/test_playlist.py index da3d96a2..409ea899 100644 --- a/tests/frontend/test_playlist.py +++ b/tests/frontend/test_playlist.py @@ -1,15 +1,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import unittest import uuid -from pony.orm import db_session - from supysonic.db import Folder, Artist, Album, Track, Playlist, User from .frontendtestbase import FrontendTestBase @@ -19,28 +17,28 @@ class PlaylistTestCase(FrontendTestBase): def setUp(self): super().setUp() - with db_session: - folder = Folder(name="Root", path="tests/assets", root=True) - artist = Artist(name="Artist!") - album = Album(name="Album!", artist=artist) - - track = Track( - path="tests/assets/23bytes", - title="23bytes", - artist=artist, - album=album, - folder=folder, - root_folder=folder, - duration=2, - disc=1, - number=1, - bitrate=320, - last_modification=0, - ) - - playlist = Playlist(name="Playlist!", user=User.get(name="alice")) - for _ in range(4): - playlist.add(track) + folder = Folder.create(name="Root", path="tests/assets", root=True) + artist = Artist.create(name="Artist!") + album = Album.create(name="Album!", artist=artist) + + track = Track.create( + path="tests/assets/23bytes", + title="23bytes", + artist=artist, + album=album, + folder=folder, + root_folder=folder, + duration=2, + disc=1, + number=1, + bitrate=320, + last_modification=0, + ) + + playlist = Playlist.create(name="Playlist!", user=User.get(name="alice")) + for _ in range(4): + playlist.add(track) + playlist.save() self.playlistid = playlist.id @@ -80,8 +78,7 @@ def test_update(self): ) self.assertNotIn("updated", rv.data) self.assertIn("Missing", rv.data) - with db_session: - self.assertEqual(Playlist[self.playlistid].name, "Playlist!") + self.assertEqual(Playlist[self.playlistid].name, "Playlist!") rv = self.client.post( "/playlist/" + str(self.playlistid), @@ -90,10 +87,9 @@ def test_update(self): ) self.assertIn("updated", rv.data) self.assertNotIn("not allowed", rv.data) - with db_session: - playlist = Playlist[self.playlistid] - self.assertEqual(playlist.name, "abc") - self.assertTrue(playlist.public) + playlist = Playlist[self.playlistid] + self.assertEqual(playlist.name, "abc") + self.assertTrue(playlist.public) def test_delete(self): self._login("bob", "B0b") @@ -107,8 +103,7 @@ def test_delete(self): "/playlist/del/" + str(self.playlistid), follow_redirects=True ) self.assertIn("not allowed", rv.data) - with db_session: - self.assertEqual(Playlist.select().count(), 1) + self.assertEqual(Playlist.select().count(), 1) self._logout() self._login("alice", "Alic3") @@ -116,8 +111,7 @@ def test_delete(self): "/playlist/del/" + str(self.playlistid), follow_redirects=True ) self.assertIn("deleted", rv.data) - with db_session: - self.assertEqual(Playlist.select().count(), 0) + self.assertEqual(Playlist.select().count(), 0) if __name__ == "__main__": diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index 9b759f8e..8009a0e2 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -9,7 +9,6 @@ import uuid from flask import escape -from pony.orm import db_session from supysonic.db import User, ClientPrefs @@ -20,8 +19,7 @@ class UserTestCase(FrontendTestBase): def setUp(self): super().setUp() - with db_session: - self.users = {u.name: u.id for u in User.select()} + self.users = {u.name: u.id for u in User.select()} def test_index(self): self._login("bob", "B0b") @@ -44,8 +42,7 @@ def test_details(self): self.assertIn("bob", rv.data) self._logout() - with db_session: - ClientPrefs(user=User[self.users["bob"]], client_name="tests") + ClientPrefs.create(user=User[self.users["bob"]], client_name="tests") self._login("bob", "B0b") rv = self.client.get("/user/" + str(self.users["alice"]), follow_redirects=True) @@ -66,21 +63,18 @@ def test_update_client_prefs(self): self.client.post("/user/me", data={"n_": "o"}) self.client.post("/user/me", data={"inexisting_client": "setting"}) - with db_session: - ClientPrefs(user=User[self.users["alice"]], client_name="tests") + ClientPrefs.create(user=User[self.users["alice"]], client_name="tests") rv = self.client.post( "/user/me", data={"tests_format": "mp3", "tests_bitrate": 128} ) self.assertIn("updated", rv.data) - with db_session: - prefs = ClientPrefs[User[self.users["alice"]], "tests"] - self.assertEqual(prefs.format, "mp3") - self.assertEqual(prefs.bitrate, 128) + prefs = ClientPrefs[User[self.users["alice"]], "tests"] + self.assertEqual(prefs.format, "mp3") + self.assertEqual(prefs.bitrate, 128) self.client.post("/user/me", data={"tests_delete": 1}) - with db_session: - self.assertEqual(ClientPrefs.select().count(), 0) + self.assertEqual(ClientPrefs.select().count(), 0) def test_change_username_get(self): self._login("bob", "B0b") @@ -116,13 +110,11 @@ def test_change_username_post(self): ) self.assertIn("updated", rv.data) self.assertIn("b0b", rv.data) - with db_session: - bob = User[self.users["bob"]] - self.assertEqual(bob.name, "b0b") - self.assertTrue(bob.admin) + bob = User[self.users["bob"]] + self.assertEqual(bob.name, "b0b") + self.assertTrue(bob.admin) rv = self.client.post(path, data={"user": "alice"}, follow_redirects=True) - with db_session: - self.assertEqual(User[self.users["bob"]].name, "b0b") + self.assertEqual(User[self.users["bob"]].name, "b0b") def test_change_mail_get(self): self._login("alice", "Alic3") @@ -208,8 +200,7 @@ def test_add_post(self): data={"user": "alice", "passwd": "passwd", "passwd_confirm": "passwd"}, ) self.assertIn(escape("User 'alice' exists"), rv.data) - with db_session: - self.assertEqual(User.select().count(), 2) + self.assertEqual(User.select().count(), 2) rv = self.client.post( "/user/add", @@ -222,8 +213,7 @@ def test_add_post(self): follow_redirects=True, ) self.assertIn("added", rv.data) - with db_session: - self.assertEqual(User.select().count(), 3) + self.assertEqual(User.select().count(), 3) self._logout() rv = self._login("user", "passwd") self.assertIn("Logged in", rv.data) @@ -234,8 +224,7 @@ def test_delete(self): self._login("bob", "B0b") rv = self.client.get(path, follow_redirects=True) self.assertIn("There's nothing much to see", rv.data) - with db_session: - self.assertEqual(User.select().count(), 2) + self.assertEqual(User.select().count(), 2) self._logout() self._login("alice", "Alic3") @@ -245,8 +234,7 @@ def test_delete(self): self.assertIn("No such user", rv.data) rv = self.client.get(path, follow_redirects=True) self.assertIn("Deleted", rv.data) - with db_session: - self.assertEqual(User.select().count(), 1) + self.assertEqual(User.select().count(), 1) self._logout() rv = self._login("bob", "B0b") self.assertIn("Wrong username or password", rv.data) From ee8165bb030a6634f2889645ff19d3be2d242b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 31 Dec 2022 17:01:20 +0100 Subject: [PATCH 134/237] Fix requesting lyrics --- supysonic/api/media.py | 8 +++-- tests/net/test_lyrics.py | 75 +++++++++++++++++++--------------------- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 5b6dfd0a..f43311dd 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -24,7 +24,7 @@ from zipstream import ZipStream from ..cache import CacheMiss -from ..db import Track, Album, Folder, now +from ..db import Track, Album, Artist, Folder, now from ..covers import EXTENSIONS from . import get_entity, get_entity_id, api_routing @@ -413,7 +413,11 @@ def lyrics(): artist = request.values["artist"] title = request.values["title"] - query = Track.select(lambda t: title in t.title and artist in t.artist.name) + query = ( + Track.select() + .join(Artist) + .where(Track.title.contains(title), Artist.name.contains(artist)) + ) for track in query: # Read from track metadata lyrics = mediafile.MediaFile(track.path).lyrics diff --git a/tests/net/test_lyrics.py b/tests/net/test_lyrics.py index 158fddfb..18057d9b 100644 --- a/tests/net/test_lyrics.py +++ b/tests/net/test_lyrics.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -10,8 +10,6 @@ import requests import unittest -from pony.orm import db_session - from supysonic.db import Folder, Artist, Album, Track from ..api.apitestbase import ApiTestBase @@ -21,42 +19,41 @@ class LyricsTestCase(ApiTestBase): def setUp(self): super().setUp() - with db_session: - folder = Folder( - name="Root", - path=os.path.abspath("tests/assets/lyrics"), - root=True, - ) - - artist = Artist(name="Artist") - album = Album(artist=artist, name="Album") - - Track( - title="Nope", - number=1, - disc=1, - artist=artist, - album=album, - path=os.path.abspath("tests/assets/lyrics/empty.mp3"), - root_folder=folder, - folder=folder, - duration=2, - bitrate=320, - last_modification=0, - ) - Track( - title="Yay", - number=1, - disc=1, - artist=artist, - album=album, - path=os.path.abspath("tests/assets/lyrics/withlyrics.mp3"), - root_folder=folder, - folder=folder, - duration=2, - bitrate=320, - last_modification=0, - ) + folder = Folder.create( + name="Root", + path=os.path.abspath("tests/assets/lyrics"), + root=True, + ) + + artist = Artist.create(name="Artist") + album = Album.create(artist=artist, name="Album") + + Track.create( + title="Nope", + number=1, + disc=1, + artist=artist, + album=album, + path=os.path.abspath("tests/assets/lyrics/empty.mp3"), + root_folder=folder, + folder=folder, + duration=2, + bitrate=320, + last_modification=0, + ) + Track.create( + title="Yay", + number=1, + disc=1, + artist=artist, + album=album, + path=os.path.abspath("tests/assets/lyrics/withlyrics.mp3"), + root_folder=folder, + folder=folder, + duration=2, + bitrate=320, + last_modification=0, + ) def test_get_lyrics(self): self._make_request("getLyrics", error=10) From e51abfe80ff42741e2626899380b7491eb28da83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 31 Dec 2022 17:17:49 +0100 Subject: [PATCH 135/237] Fix independent tests --- tests/issue101.py | 21 ++++++++------------ tests/issue129.py | 35 ++++++++++++++------------------- tests/issue133.py | 8 ++------ tests/issue139.py | 8 ++------ tests/issue148.py | 7 ++----- tests/issue221.py | 50 ++++++++++++++++++++++------------------------- tests/issue85.py | 14 +++++-------- 7 files changed, 57 insertions(+), 86 deletions(-) diff --git a/tests/issue101.py b/tests/issue101.py index 0801a989..6c41525c 100644 --- a/tests/issue101.py +++ b/tests/issue101.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2018 Alban 'spl0k' Féron +# Copyright (C) 2018-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -10,8 +10,6 @@ import tempfile import unittest -from pony.orm import db_session - from supysonic.db import init_database, release_database from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner @@ -21,8 +19,7 @@ class Issue101TestCase(unittest.TestCase): def setUp(self): self.__dir = tempfile.mkdtemp() init_database("sqlite:") - with db_session: - FolderManager.add("folder", self.__dir) + FolderManager.add("folder", self.__dir) def tearDown(self): release_database() @@ -37,17 +34,15 @@ def test_issue(self): "tests/assets/folder/silence.mp3", os.path.join(subdir, "silence.mp3") ) - with db_session: - scanner = Scanner() - scanner.queue_folder("folder") - scanner.run() + scanner = Scanner() + scanner.queue_folder("folder") + scanner.run() shutil.rmtree(firstsubdir) - with db_session: - scanner = Scanner() - scanner.queue_folder("folder") - scanner.run() + scanner = Scanner() + scanner.queue_folder("folder") + scanner.run() if __name__ == "__main__": diff --git a/tests/issue129.py b/tests/issue129.py index dbc6ace1..872c5847 100644 --- a/tests/issue129.py +++ b/tests/issue129.py @@ -1,15 +1,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2018 Alban 'spl0k' Féron +# Copyright (C) 2018-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import os.path import unittest -from pony.orm import db_session - from supysonic.db import User, Track, StarredTrack, RatingTrack from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner @@ -21,30 +19,27 @@ class Issue129TestCase(TestBase): def setUp(self): super().setUp() - with db_session: - FolderManager.add("folder", os.path.abspath("tests/assets/folder")) - scanner = Scanner() - scanner.queue_folder("folder") - scanner.run() + FolderManager.add("folder", os.path.abspath("tests/assets/folder")) + scanner = Scanner() + scanner.queue_folder("folder") + scanner.run() - self.trackid = Track.select().first().id - self.userid = User.get(name="alice").id + self.trackid = Track.select().first().id + self.userid = User.get(name="alice").id def test_last_play(self): - with db_session: - User[self.userid].last_play = Track[self.trackid] - with db_session: - FolderManager.delete_by_name("folder") + user = User[self.userid] + user.last_play = Track[self.trackid] + user.save() + FolderManager.delete_by_name("folder") def test_starred(self): - with db_session: - StarredTrack(user=self.userid, starred=self.trackid) - FolderManager.delete_by_name("folder") + StarredTrack.create(user=self.userid, starred=self.trackid) + FolderManager.delete_by_name("folder") def test_rating(self): - with db_session: - RatingTrack(user=self.userid, rated=self.trackid, rating=5) - FolderManager.delete_by_name("folder") + RatingTrack.create(user=self.userid, rated=self.trackid, rating=5) + FolderManager.delete_by_name("folder") if __name__ == "__main__": diff --git a/tests/issue133.py b/tests/issue133.py index 3e7257b5..f0d89f28 100644 --- a/tests/issue133.py +++ b/tests/issue133.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019 Alban 'spl0k' Féron +# Copyright (C) 2019-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -9,8 +9,6 @@ import tempfile import unittest -from pony.orm import db_session - from supysonic.db import init_database, release_database from supysonic.db import Track from supysonic.managers.folder import FolderManager @@ -22,14 +20,12 @@ def setUp(self): self.__dir = tempfile.mkdtemp() shutil.copy("tests/assets/issue133.flac", self.__dir) init_database("sqlite:") - with db_session: - FolderManager.add("folder", self.__dir) + FolderManager.add("folder", self.__dir) def tearDown(self): release_database() shutil.rmtree(self.__dir) - @db_session def test_issue133(self): scanner = Scanner() scanner.queue_folder("folder") diff --git a/tests/issue139.py b/tests/issue139.py index db135a0d..26e644b2 100644 --- a/tests/issue139.py +++ b/tests/issue139.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019 Alban 'spl0k' Féron +# Copyright (C) 2019-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -9,8 +9,6 @@ import tempfile import unittest -from pony.orm import db_session - from supysonic.db import init_database, release_database from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner @@ -20,14 +18,12 @@ class Issue139TestCase(unittest.TestCase): def setUp(self): self.__dir = tempfile.mkdtemp() init_database("sqlite:") - with db_session: - FolderManager.add("folder", self.__dir) + FolderManager.add("folder", self.__dir) def tearDown(self): release_database() shutil.rmtree(self.__dir) - @db_session def do_scan(self): scanner = Scanner() scanner.queue_folder("folder") diff --git a/tests/issue148.py b/tests/issue148.py index 595b5d87..6fddf1d8 100644 --- a/tests/issue148.py +++ b/tests/issue148.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019-2020 Alban 'spl0k' Féron +# Copyright (C) 2019-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -11,8 +11,6 @@ import tempfile import unittest -from pony.orm import db_session - from supysonic.db import init_database, release_database from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner @@ -23,8 +21,7 @@ class Issue148TestCase(unittest.TestCase): def setUp(self): self.__dir = tempfile.mkdtemp() init_database("sqlite:") - with db_session: - FolderManager.add("folder", self.__dir) + FolderManager.add("folder", self.__dir) def tearDown(self): release_database() diff --git a/tests/issue221.py b/tests/issue221.py index 2825d195..a895e031 100644 --- a/tests/issue221.py +++ b/tests/issue221.py @@ -1,48 +1,44 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2021 Alban 'spl0k' Féron +# Copyright (C) 2021-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import unittest -from pony.orm import db_session - from supysonic import db class Issue221TestCase(unittest.TestCase): def setUp(self): db.init_database("sqlite:") - with db_session: - root = db.Folder(root=True, name="Folder", path="tests") - artist = db.Artist(name="Artist") - album = db.Album(artist=artist, name="Album") - - for i in range(3): - db.Track( - title="Track {}".format(i), - album=album, - artist=artist, - disc=1, - number=i + 1, - duration=3, - has_art=False, - bitrate=64, - path="tests/track{}".format(i), - last_modification=2, - root_folder=root, - folder=root, - genre="Genre", - ) - - db.User(name="user", password="secret", salt="sugar") + root = db.Folder.create(root=True, name="Folder", path="tests") + artist = db.Artist.create(name="Artist") + album = db.Album.create(artist=artist, name="Album") + + for i in range(3): + db.Track.create( + title="Track {}".format(i), + album=album, + artist=artist, + disc=1, + number=i + 1, + duration=3, + has_art=False, + bitrate=64, + path="tests/track{}".format(i), + last_modification=2, + root_folder=root, + folder=root, + genre="Genre", + ) + + db.User.create(name="user", password="secret", salt="sugar") def tearDown(self): db.release_database() - @db_session def test_issue(self): data = db.Album.get().as_subsonic_album(db.User.get()) self.assertIn("genre", data) diff --git a/tests/issue85.py b/tests/issue85.py index 84d630c9..7f57550a 100644 --- a/tests/issue85.py +++ b/tests/issue85.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2020 Alban 'spl0k' Féron +# Copyright (C) 2020-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -12,8 +12,6 @@ import tempfile import unittest -from pony.orm import db_session - from supysonic.db import init_database, release_database from supysonic.managers.folder import FolderManager from supysonic.scanner import Scanner @@ -26,8 +24,7 @@ class Issue85TestCase(unittest.TestCase): def setUp(self): self.__dir = tempfile.mkdtemp() init_database("sqlite:") - with db_session: - FolderManager.add("folder", self.__dir) + FolderManager.add("folder", self.__dir) def tearDown(self): release_database() @@ -40,10 +37,9 @@ def test_issue(self): os.path.join(self.__dir.encode(), b"\xe6", b"silence.mp3"), ) - with db_session: - scanner = Scanner() - scanner.queue_folder("folder") - scanner.run() + scanner = Scanner() + scanner.queue_folder("folder") + scanner.run() if __name__ == "__main__": From f6b859d11fdf9162a4d9012d98bcb7eba51a9b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 31 Dec 2022 17:40:19 +0100 Subject: [PATCH 136/237] Fix average ratings for peewee pre-3.15.4 --- supysonic/db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index 841b6bd0..921fb73a 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -127,7 +127,7 @@ def as_subsonic_child(self, user): pass avgRating = ( - RatingFolder.select(fn.avg(RatingFolder.rating)) + RatingFolder.select(fn.avg(RatingFolder.rating, coerce=False)) .where(RatingFolder.rated == self) .scalar() ) @@ -343,7 +343,7 @@ def as_subsonic_child(self, user, prefs): pass avgRating = ( - RatingTrack.select(fn.avg(RatingTrack.rating)) + RatingTrack.select(fn.avg(RatingTrack.rating, coerce=False)) .where(RatingTrack.rated == self) .scalar() ) From b87091dd56f316826a3ef4817895a4fcadf153cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 31 Dec 2022 17:58:35 +0100 Subject: [PATCH 137/237] Force tests to run on Ubuntu 20.04 and test for py 3.11 and 3.12 Ubuntu 20.04 should allow Python 3.6 tests to run, even if this version is EOL --- .github/workflows/tests.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6f8fa600..a3944485 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,7 +21,7 @@ on: jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: matrix: python-version: @@ -30,6 +30,8 @@ jobs: - 3.8 - 3.9 - "3.10" + - 3.11 + - 3.12 fail-fast: false steps: - name: Checkout From 6ab0488620a930d7ac9ed20349500f5e3391678e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 31 Dec 2022 18:24:29 +0100 Subject: [PATCH 138/237] Add missing save calls in Last.fm handling --- supysonic/lastfm.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index d347a7e4..3a406455 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2018 Alban 'spl0k' Féron +# Copyright (C) 2013-2022 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -34,11 +34,13 @@ def link_account(self, token): else: self.__user.lastfm_session = res["session"]["key"] self.__user.lastfm_status = True + self.__user.save() return True, "OK" def unlink_account(self): self.__user.lastfm_session = None self.__user.lastfm_status = True + self.__user.save() def now_playing(self, track): if not self.__enabled: @@ -107,6 +109,7 @@ def __api_request(self, write, **kwargs): if "error" in json: if json["error"] in (9, "9"): self.__user.lastfm_status = False + self.__user.save() logger.warning("LastFM error %i: %s", json["error"], json["message"]) return json From 4d249adcf2bf3fc98fe4633b4b137b7932961c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 1 Jan 2023 13:38:26 +0100 Subject: [PATCH 139/237] Drop Python 3.6 --- .github/workflows/tests.yaml | 4 +--- setup.cfg | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a3944485..f03f07d7 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,17 +21,15 @@ on: jobs: build: name: Build - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: python-version: - - 3.6 - 3.7 - 3.8 - 3.9 - "3.10" - 3.11 - - 3.12 fail-fast: false steps: - name: Checkout diff --git a/setup.cfg b/setup.cfg index 849a9f2a..2a47c054 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,15 +41,15 @@ classifiers = Intended Audience :: System Administrators License :: OSI Approved :: GNU Affero General Public License v3 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Multimedia :: Sound/Audio [options] -python_requires = >=3.6,<3.11 +python_requires = >=3.7 install_requires = click flask >=0.11 From 09d48fdb2e9ac2c259ff26f0ded734f6ef2564fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 1 Jan 2023 17:21:35 +0100 Subject: [PATCH 140/237] Fix pony leftover in jukebox --- supysonic/api/jukebox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 70a53eb6..95c07c32 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -89,7 +89,7 @@ def jukebox_control(): for path in status.playlist: try: playlist.append(Track.get(path=path)) - except ObjectNotFound: + except Track.DoesNotExist: pass rv["entry"] = [ t.as_subsonic_child(request.user, request.client) for t in playlist From 30734fe9ab8de6b34595dcf79977ca960851fef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 2 Jan 2023 18:08:30 +0100 Subject: [PATCH 141/237] Fix getSongsByGenre + remove some useless DISTINCTs --- supysonic/api/albums_songs.py | 8 ++++---- tests/api/test_album_songs.py | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 434b8d98..6e72dbd8 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -84,7 +84,7 @@ def album_list(): }, ) elif ltype == "newest": - query = query.order_by(Folder.created.desc()).distinct() + query = query.order_by(Folder.created.desc()) elif ltype == "highest": query = query.join(RatingFolder, JOIN.LEFT_OUTER).order_by( fn.avg(RatingFolder.rating).desc() @@ -98,7 +98,7 @@ def album_list(): elif ltype == "starred": query = query.join(StarredFolder).where(StarredFolder.user == request.user) elif ltype == "alphabeticalByName": - query = query.order_by(Folder.name).distinct() + query = query.order_by(Folder.name) elif ltype == "alphabeticalByArtist": parent = Folder.alias() query = query.join(parent).order_by(parent.name, Folder.name) @@ -212,7 +212,7 @@ def songs_by_genre(): { "song": [ t.as_subsonic_child(request.user, request.client) - for t in query.limit(count, offset) + for t in query.limit(count).offset(offset) ] }, ) diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index 848f8a51..055983c7 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2022 Alban 'spl0k' Féron +# Copyright (C) 2017-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -267,6 +267,26 @@ def test_get_starred2(self): self._make_request("getStarred2", tag="starred2") self._make_request("getStarred2", {"musicFolderId": 1}, tag="starred2") + def test_get_songs_by_genre(self): + self._make_request("getSongsByGenre", error=10) + self._make_request( + "getSongsByGenre", {"genre": "genre", "musicFolderId": "idid"}, error=0 + ) + self._make_request( + "getSongsByGenre", {"genre": "genre", "musicFolderId": 1234567890}, error=70 + ) + self._make_request( + "getSongsByGenre", {"genre": "genre", "count": "three"}, error=0 + ) + self._make_request( + "getSongsByGenre", {"genre": "genre", "offset": "four"}, error=0 + ) + + rv, child = self._make_request( + "getSongsByGenre", {"genre": "Lampshade"}, tag="songsByGenre" + ) + self.assertEqual(len(child), 1) + if __name__ == "__main__": unittest.main() From 14dc63631f10eccbe6baf79a36b51bdeacd18d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Jan 2023 15:53:59 +0100 Subject: [PATCH 142/237] Fixed error on daemon quit --- supysonic/daemon/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/supysonic/daemon/__init__.py b/supysonic/daemon/__init__.py index d8a67e55..2cd48ac2 100644 --- a/supysonic/daemon/__init__.py +++ b/supysonic/daemon/__init__.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2014-2019 Alban 'spl0k' Féron +# Copyright (C) 2014-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -61,4 +61,3 @@ def main(): init_database(config.BASE["database_uri"]) daemon = Daemon(config) daemon.run() - release_database() From 82187fd4c4375e29b22a6b18922a2566f8303752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Jan 2023 16:16:28 +0100 Subject: [PATCH 143/237] pyupgrade --- supysonic/api/__init__.py | 2 +- supysonic/api/annotation.py | 4 ++-- supysonic/api/exceptions.py | 6 +++--- supysonic/api/formatters.py | 2 +- supysonic/api/media.py | 20 ++++++++++---------- supysonic/api/unsupported.py | 8 ++------ supysonic/cli.py | 20 ++++++++++---------- supysonic/daemon/client.py | 2 +- supysonic/db.py | 8 ++++---- supysonic/frontend/__init__.py | 2 +- supysonic/frontend/playlist.py | 4 +--- supysonic/managers/folder.py | 2 +- supysonic/managers/user.py | 2 +- supysonic/server/__init__.py | 6 ++---- supysonic/server/gunicorn.py | 4 ++-- tests/api/apitestbase.py | 12 +++++------- tests/api/test_api_setup.py | 2 +- tests/api/test_browse.py | 2 +- tests/api/test_media.py | 4 +--- tests/api/test_radio.py | 12 ++++++------ tests/api/test_search.py | 2 +- tests/api/test_transcoding.py | 6 +++--- tests/base/test_cli.py | 2 +- tests/frontend/test_user.py | 4 ++-- tests/issue221.py | 4 ++-- 25 files changed, 65 insertions(+), 77 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 0d612aa8..4965d4c9 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -24,7 +24,7 @@ def api_routing(endpoint): def decorator(func): - viewendpoint = "{}.view".format(endpoint) + viewendpoint = f"{endpoint}.view" api.add_url_rule(endpoint, view_func=func, methods=["GET", "POST"]) api.add_url_rule(viewendpoint, view_func=func, methods=["GET", "POST"]) return func diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index ae1fa4fd..b1cec691 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -29,11 +29,11 @@ def star_single(cls, starcls, eid): try: e = cls[eid] except cls.DoesNotExist: - raise NotFound("{} {}".format(cls.__name__, eid)) + raise NotFound(f"{cls.__name__} {eid}") try: starcls[request.user, eid] - raise GenericError("{} {} already starred".format(cls.__name__, eid)) + raise GenericError(f"{cls.__name__} {eid} already starred") except starcls.DoesNotExist: pass diff --git a/supysonic/api/exceptions.py b/supysonic/api/exceptions.py index 48e7a42b..dce00b91 100644 --- a/supysonic/api/exceptions.py +++ b/supysonic/api/exceptions.py @@ -21,7 +21,7 @@ def get_response(self, environ=None): def __str__(self): code = self.api_code if self.api_code is not None else "??" - return "{}: {}".format(code, self.message) + return f"{code}: {self.message}" class GenericError(SubsonicAPIException): @@ -38,7 +38,7 @@ class ServerError(GenericError): class UnsupportedParameter(GenericError): def __init__(self, parameter, *args, **kwargs): - message = "Unsupported parameter '{}'".format(parameter) + message = f"Unsupported parameter '{parameter}'" super().__init__(message, *args, **kwargs) @@ -89,7 +89,7 @@ class NotFound(SubsonicAPIException): def __init__(self, entity, *args, **kwargs): super().__init__(*args, **kwargs) - self.message = "{} not found".format(entity) + self.message = f"{entity} not found" class AggregateException(SubsonicAPIException): diff --git a/supysonic/api/formatters.py b/supysonic/api/formatters.py index c9f63752..1a15a2cc 100644 --- a/supysonic/api/formatters.py +++ b/supysonic/api/formatters.py @@ -82,7 +82,7 @@ def make_response(self, elem, data): ) rv = self._subsonicify(elem, data) - rv = "{}({})".format(self.__callback, json.dumps(rv)) + rv = f"{self.__callback}({json.dumps(rv)})" rv = make_response(rv) rv.mimetype = "application/javascript" return rv diff --git a/supysonic/api/media.py b/supysonic/api/media.py index f43311dd..a5d3f0fd 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -115,14 +115,14 @@ def stream_media(): if dst_suffix != src_suffix or dst_bitrate != res.bitrate: # Requires transcoding cache = current_app.transcode_cache - cache_key = "{}-{}.{}".format(res.id, dst_bitrate, dst_suffix) + cache_key = f"{res.id}-{dst_bitrate}.{dst_suffix}" try: response = send_file( cache.get(cache_key), mimetype=dst_mimetype, conditional=True ) except CacheMiss: - transcoder = config.get("transcoder_{}_{}".format(src_suffix, dst_suffix)) + transcoder = config.get(f"transcoder_{src_suffix}_{dst_suffix}") decoder = config.get("decoder_" + src_suffix) or config.get("decoder") encoder = config.get("encoder_" + dst_suffix) or config.get("encoder") if not transcoder and (not decoder or not encoder): @@ -134,10 +134,10 @@ def stream_media(): logger.info(message) raise GenericError(message) - transcoder, decoder, encoder = [ + transcoder, decoder, encoder = ( prepare_transcoding_cmdline(x, res, src_suffix, dst_suffix, dst_bitrate) for x in (transcoder, decoder, encoder) - ] + ) try: if transcoder: dec_proc = None @@ -280,7 +280,7 @@ def download_media(): raise GenericError("Nothing to download") resp = Response(z, mimetype="application/zip") - resp.headers["Content-Disposition"] = "attachment; filename={}.zip".format(rv.name) + resp.headers["Content-Disposition"] = f"attachment; filename={rv.name}.zip" resp.headers["Content-Length"] = len(z) return resp @@ -291,7 +291,7 @@ def _cover_from_track(obj): Returns None if no cover art is available. """ cache = current_app.cache - cache_key = "{}-cover".format(obj.id) + cache_key = f"{obj.id}-cover" try: return cache.get(cache_key) except CacheMiss: @@ -383,15 +383,15 @@ def cover_art(): mimetype = None if os.path.splitext(cover_path)[1].lower() not in EXTENSIONS: with Image.open(cover_path) as im: - mimetype = "image/{}".format(im.format.lower()) + mimetype = f"image/{im.format.lower()}" return send_file(cover_path, mimetype=mimetype) with Image.open(cover_path) as im: - mimetype = "image/{}".format(im.format.lower()) + mimetype = f"image/{im.format.lower()}" if size > im.width and size > im.height: return send_file(cover_path, mimetype=mimetype) - cache_key = "{}-cover-{}".format(eid, size) + cache_key = f"{eid}-cover-{size}" try: return send_file(cache.get(cache_key), mimetype=mimetype) except CacheMiss: @@ -447,7 +447,7 @@ def lyrics(): unique = hashlib.md5( json.dumps([x.lower() for x in (artist, title)]).encode("utf-8") ).hexdigest() - cache_key = "lyrics-{}".format(unique) + cache_key = f"lyrics-{unique}" lyrics = {} try: diff --git a/supysonic/api/unsupported.py b/supysonic/api/unsupported.py index 3a8e3108..a0607d8c 100644 --- a/supysonic/api/unsupported.py +++ b/supysonic/api/unsupported.py @@ -24,9 +24,5 @@ def unsupported(): for m in methods: - api.add_url_rule( - "/{}".format(m), "unsupported", unsupported, methods=["GET", "POST"] - ) - api.add_url_rule( - "/{}.view".format(m), "unsupported", unsupported, methods=["GET", "POST"] - ) + api.add_url_rule(f"/{m}", "unsupported", unsupported, methods=["GET", "POST"]) + api.add_url_rule(f"/{m}.view", "unsupported", unsupported, methods=["GET", "POST"]) diff --git a/supysonic/cli.py b/supysonic/cli.py index 2eacb5fa..a586057d 100644 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -28,7 +28,7 @@ def __init__(self, interval=5): def __call__(self, name, scanned): if time.time() - self.__last_display > self.__interval: - progress = "Scanning '{}': {} files scanned".format(name, scanned) + progress = f"Scanning '{name}': {scanned} files scanned" self.__stdout.write("\b" * self.__last_len) self.__stdout.write(progress) self.__stdout.flush() @@ -55,7 +55,7 @@ def folder_list(): click.echo("Name\t\tPath\n----\t\t----") for f in Folder.select().where(Folder.root): - click.echo("{: <16}{}".format(f.name, f.path)) + click.echo(f"{f.name: <16}{f.path}") @folder.command("add") @@ -76,7 +76,7 @@ def folder_add(name, path): try: FolderManager.add(name, path) - click.echo("Folder '{}' added".format(name)) + click.echo(f"Folder '{name}' added") except ValueError as e: raise ClickException(str(e)) from e @@ -91,9 +91,9 @@ def folder_delete(name): try: FolderManager.delete_by_name(name) - click.echo("Deleted folder '{}'".format(name)) + click.echo(f"Deleted folder '{name}'") except Folder.DoesNotExist as e: - raise ClickException("Folder '{}' does not exist.".format(name)) from e + raise ClickException(f"Folder '{name}' does not exist.") from e @folder.command("scan") @@ -272,9 +272,9 @@ def user_delete(name): try: UserManager.delete_by_name(name) - click.echo("Deleted user '{}'".format(name)) + click.echo(f"Deleted user '{name}'") except User.DoesNotExist as e: - raise ClickException("User '{}' does not exist.".format(name)) from e + raise ClickException(f"User '{name}' does not exist.") from e def _echo_role_change(username, name, value): @@ -325,9 +325,9 @@ def user_changepass(name, password): try: UserManager.change_password2(name, password) - click.echo("Successfully changed '{}' password".format(name)) + click.echo(f"Successfully changed '{name}' password") except User.DoesNotExist as e: - raise ClickException("User '{}' does not exist.".format(name)) from e + raise ClickException(f"User '{name}' does not exist.") from e @user.command("rename") @@ -358,7 +358,7 @@ def user_rename(name, newname): user.name = newname user.save() - click.echo("User '{}' renamed to '{}'".format(name, newname)) + click.echo(f"User '{name}' renamed to '{newname}'") def main(): diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index 666a187a..9ed5d45f 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -138,7 +138,7 @@ def __get_connection(self): return Client(address=self.__address, authkey=self.__key) except OSError: raise DaemonUnavailableError( - "Couldn't connect to daemon at {}".format(self.__address) + f"Couldn't connect to daemon at {self.__address}" ) def add_watched_folder(self, folder): diff --git a/supysonic/db.py b/supysonic/db.py index 921fb73a..29f71796 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -368,9 +368,9 @@ def mimetype(self): return mimetypes.guess_type(self.path, False)[0] or "application/octet-stream" def duration_str(self): - ret = "{:02}:{:02}".format((self.duration % 3600) / 60, self.duration % 60) + ret = f"{(self.duration % 3600) / 60:02}:{self.duration % 60:02}" if self.duration >= 3600: - ret = "{:02}:{}".format(self.duration / 3600, ret) + ret = f"{self.duration / 3600:02}:{ret}" return ret def suffix(self): @@ -492,7 +492,7 @@ def as_subsonic_playlist(self, user): "id": str(self.id), "name": self.name if self.user.id == user.id - else "[{}] {}".format(self.user.name, self.name), + else f"[{self.user.name}] {self.name}", "owner": self.user.name, "public": self.public, "songCount": len(tracks), @@ -536,7 +536,7 @@ def add(self, track): tid = UUID(track) if self.tracks and len(self.tracks) > 0: - self.tracks = "{},{}".format(self.tracks, tid) + self.tracks = f"{self.tracks},{tid}" else: self.tracks = str(tid) diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index 205aabb7..66c21a78 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -65,7 +65,7 @@ def scan_status(): current_app.config["DAEMON"]["socket"] ).get_scanning_progress() if scanned is not None: - flash("Scanning in progress, {} files scanned.".format(scanned)) + flash(f"Scanning in progress, {scanned} files scanned.") except DaemonUnavailableError: pass diff --git a/supysonic/frontend/playlist.py b/supysonic/frontend/playlist.py index 2c1f4f94..2dcedfdf 100644 --- a/supysonic/frontend/playlist.py +++ b/supysonic/frontend/playlist.py @@ -56,9 +56,7 @@ def playlist_export(uid, playlist): return Response( render_template("playlist_export.m3u", playlist=playlist), mimetype="audio/mpegurl", - headers={ - "Content-disposition": "attachment; filename={}.m3u".format(playlist.name) - }, + headers={"Content-disposition": f"attachment; filename={playlist.name}.m3u"}, ) diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index b4bc76a8..943149a2 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -26,7 +26,7 @@ def get(id): def add(name, path): try: Folder.get(name=name, root=True) - raise ValueError("Folder '{}' exists".format(name)) + raise ValueError(f"Folder '{name}' exists") except Folder.DoesNotExist: pass diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index 1a59e70c..fb16feec 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -29,7 +29,7 @@ def get(uid): @staticmethod def add(name, password, **kwargs): if User.select().where(User.name == name).exists(): - raise ValueError("User '{}' exists".format(name)) + raise ValueError(f"User '{name}' exists") crypt, salt = UserManager.__encrypt_password(password) return User.create(name=name, password=crypt, salt=salt, **kwargs) diff --git a/supysonic/server/__init__.py b/supysonic/server/__init__.py index 4acc6b43..4e156fba 100644 --- a/supysonic/server/__init__.py +++ b/supysonic/server/__init__.py @@ -109,15 +109,13 @@ def main(server, host, port, socket, processes, threads): server = find_first_available_server() if server is None: raise ClickException( - "Couldn't load any server, please install one of {}".format(_servers) + f"Couldn't load any server, please install one of {_servers}" ) else: try: server = get_server(server) except ImportError: - raise ClickException( - "Couldn't load {}, please install it first".format(server) - ) + raise ClickException(f"Couldn't load {server}, please install it first") if socket is not None: host = None diff --git a/supysonic/server/gunicorn.py b/supysonic/server/gunicorn.py index 3e6613d3..9e6dfd35 100644 --- a/supysonic/server/gunicorn.py +++ b/supysonic/server/gunicorn.py @@ -28,9 +28,9 @@ def load_config(self): threads = self.__config["threads"] if socket is not None: - self.cfg.set("bind", "unix:{}".format(socket)) + self.cfg.set("bind", f"unix:{socket}") else: - self.cfg.set("bind", "{}:{}".format(host, port)) + self.cfg.set("bind", f"{host}:{port}") if processes is not None: self.cfg.set("workers", processes) diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index 7cca930f..e662625c 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -23,9 +23,7 @@ class ApiTestBase(TestBase): def setUp(self, apiVersion="1.12.0"): super().setUp() self.apiVersion = apiVersion - xsd = etree.parse( - "tests/assets/subsonic-rest-api-{}.xsd".format(self.apiVersion) - ) + xsd = etree.parse(f"tests/assets/subsonic-rest-api-{self.apiVersion}.xsd") self.schema = etree.XMLSchema(xsd) def _find(self, xml, path): @@ -33,7 +31,7 @@ def _find(self, xml, path): Helper method that insert the namespace in ElementPath 'path' """ - path = path_replace_regexp.sub(r"/{{{}}}\1".format(NS), path) + path = path_replace_regexp.sub(rf"/{{{NS}}}\1", path) return xml.find(path) def _xpath(self, elem, path): @@ -70,7 +68,7 @@ def _make_request(self, endpoint, args={}, tag=None, error=None, skip_post=False if "u" not in args: args.update({"u": "alice", "p": "Alic3"}) - uri = "/rest/{}.view".format(endpoint) + uri = f"/rest/{endpoint}.view" rg = self.client.get(uri, query_string=args) if not skip_post: rp = self.client.post(uri, data=args) @@ -82,13 +80,13 @@ def _make_request(self, endpoint, args={}, tag=None, error=None, skip_post=False if xml.get("status") == "ok": self.assertIsNone(error) if tag: - self.assertEqual(xml[0].tag, "{{{}}}{}".format(NS, tag)) + self.assertEqual(xml[0].tag, f"{{{NS}}}{tag}") return rg, xml[0] else: self.assertEqual(len(xml), 0) return rg, None else: self.assertIsNone(tag) - self.assertEqual(xml[0].tag, "{{{}}}error".format(NS)) + self.assertEqual(xml[0].tag, f"{{{NS}}}error") self.assertEqual(xml[0].get("code"), str(error)) return rg diff --git a/tests/api/test_api_setup.py b/tests/api/test_api_setup.py index a7b8577e..f4bd3eed 100644 --- a/tests/api/test_api_setup.py +++ b/tests/api/test_api_setup.py @@ -24,7 +24,7 @@ def setUp(self): self._patch_client() def __basic_auth_get(self, username, password): - hashed = base64.b64encode("{}:{}".format(username, password).encode("utf-8")) + hashed = base64.b64encode(f"{username}:{password}".encode("utf-8")) headers = {"Authorization": "Basic " + hashed.decode("utf-8")} return self.client.get( "/rest/ping.view", headers=headers, query_string={"c": "tests"} diff --git a/tests/api/test_browse.py b/tests/api/test_browse.py index 73579e3d..0de3dfb7 100644 --- a/tests/api/test_browse.py +++ b/tests/api/test_browse.py @@ -24,7 +24,7 @@ def setUp(self): for letter in "ABC": folder = Folder.create( name=letter + "rtist", - path="tests/assets/{}rtist".format(letter), + path=f"tests/assets/{letter}rtist", root=False, parent=self.root, ) diff --git a/tests/api/test_media.py b/tests/api/test_media.py index 4272e5b0..ee28e828 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -57,9 +57,7 @@ def setUp(self): disc=1, artist=artist, album=album, - path=os.path.abspath( - "tests/assets/formats/silence.{}".format(self.formats[i]) - ), + path=os.path.abspath(f"tests/assets/formats/silence.{self.formats[i]}"), root_folder=folder, folder=folder, duration=2, diff --git a/tests/api/test_radio.py b/tests/api/test_radio.py index 277da550..741baece 100644 --- a/tests/api/test_radio.py +++ b/tests/api/test_radio.py @@ -178,9 +178,9 @@ def test_get_radio_stations(self): test_range = 3 for x in range(test_range): RadioStation.create( - stream_url="http://example.com/radio-{}".format(x), - name="Radio {}".format(x), - homepage_url="http://example.com/update-{}".format(x), + stream_url=f"http://example.com/radio-{x}", + name=f"Radio {x}", + homepage_url=f"http://example.com/update-{x}", ) # verify happy path is clean @@ -193,9 +193,9 @@ def test_get_radio_stations(self): # Test data is sequential by design. for x in range(test_range): station = child[x] - self.assertTrue(station.get("streamUrl").endswith("radio-{}".format(x))) - self.assertTrue(station.get("name").endswith("Radio {}".format(x))) - self.assertTrue(station.get("homePageUrl").endswith("update-{}".format(x))) + self.assertTrue(station.get("streamUrl").endswith(f"radio-{x}")) + self.assertTrue(station.get("name").endswith(f"Radio {x}")) + self.assertTrue(station.get("homePageUrl").endswith(f"update-{x}")) # test for non-admin access rv, child = self._make_request( diff --git a/tests/api/test_search.py b/tests/api/test_search.py index 3aa71e84..6c9167ab 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -23,7 +23,7 @@ def setUp(self): for letter in "ABC": folder = Folder.create( name=letter + "rtist", - path="tests/assets/{}rtist".format(letter), + path=f"tests/assets/{letter}rtist", root=False, parent=root, ) diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index 7df37244..79e75b02 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -81,7 +81,7 @@ def test_mostly_transcoded_cached(self): rv.response.close() rv.close() - key = "{}-96.rnd".format(self.trackid) + key = f"{self.trackid}-96.rnd" with self.app_context(): self.assertTrue(current_app.transcode_cache.has(key)) self.assertEqual(current_app.transcode_cache.size, 52000) @@ -98,7 +98,7 @@ def test_partly_transcoded_cached(self): rv.response.close() rv.close() - key = "{}-96.rnd".format(self.trackid) + key = f"{self.trackid}-96.rnd" with self.app_context(): self.assertFalse(current_app.transcode_cache.has(key)) self.assertEqual(current_app.transcode_cache.size, 0) @@ -118,7 +118,7 @@ def test_last_chunk_close_transcoded_cached(self): rv.response.close() rv.close() - key = "{}-96.rnd".format(self.trackid) + key = f"{self.trackid}-96.rnd" with self.app_context(): self.assertTrue(current_app.transcode_cache.has(key)) self.assertEqual(current_app.transcode_cache.size, 52000) diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index 27dfbffb..652117ec 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -41,7 +41,7 @@ def __invoke(self, cmd, expect_fail=False): return rv def __add_folder(self, name, path, expect_fail=False): - self.__invoke("folder add {} {}".format(name, shlex.quote(path)), expect_fail) + self.__invoke(f"folder add {name} {shlex.quote(path)}", expect_fail) def test_folder_add(self): with tempfile.TemporaryDirectory() as d: diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index 8009a0e2..c1723cde 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -86,7 +86,7 @@ def test_change_username_get(self): rv = self.client.get("/user/whatever/changeusername", follow_redirects=True) self.assertIn("badly formed", rv.data) rv = self.client.get( - "/user/{}/changeusername".format(uuid.uuid4()), follow_redirects=True + f"/user/{uuid.uuid4()}/changeusername", follow_redirects=True ) self.assertIn("No such user", rv.data) self.client.get("/user/{}/changeusername".format(self.users["bob"])) @@ -96,7 +96,7 @@ def test_change_username_post(self): rv = self.client.post("/user/whatever/changeusername", follow_redirects=True) self.assertIn("badly formed", rv.data) rv = self.client.post( - "/user/{}/changeusername".format(uuid.uuid4()), follow_redirects=True + f"/user/{uuid.uuid4()}/changeusername", follow_redirects=True ) self.assertIn("No such user", rv.data) diff --git a/tests/issue221.py b/tests/issue221.py index a895e031..570eaa64 100644 --- a/tests/issue221.py +++ b/tests/issue221.py @@ -19,7 +19,7 @@ def setUp(self): for i in range(3): db.Track.create( - title="Track {}".format(i), + title=f"Track {i}", album=album, artist=artist, disc=1, @@ -27,7 +27,7 @@ def setUp(self): duration=3, has_art=False, bitrate=64, - path="tests/track{}".format(i), + path=f"tests/track{i}", last_modification=2, root_folder=root, folder=root, From 399686f17ba75db6233ac5d87289ca0db40f4d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Jan 2023 16:20:23 +0100 Subject: [PATCH 144/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index bcbc9c11..1040b9a9 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.2" +VERSION = "0.7.3" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From ba1fbf4b73ba8d7afe89e52904d2186e187b9678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Jan 2023 16:40:51 +0100 Subject: [PATCH 145/237] Forgot to update badge and docs about Python version --- README.md | 2 +- docs/setup/install.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f7f2e49d..387d832a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Supysonic is a Python implementation of the [Subsonic][] server API. ![Build Status](https://github.com/spl0k/supysonic/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) -![Python](https://img.shields.io/badge/python-3.6--3.10-blue.svg) +![Python](https://img.shields.io/badge/python-3.7+-blue.svg) Current supported features are: * browsing (by folders or tags) diff --git a/docs/setup/install.rst b/docs/setup/install.rst index 089bae1d..061ed147 100644 --- a/docs/setup/install.rst +++ b/docs/setup/install.rst @@ -1,7 +1,7 @@ Installing Supysonic ==================== -Supysonic is written in Python and supports Python 3.6 through 3.10. +Supysonic is written in Python and supports Python 3.7 and later. Linux ----- @@ -46,7 +46,7 @@ Once the command prompt is open, type :command:`python --version` and press Enter. If Python is installed, you will see the version of Python printed to the screen. If you do not have Python installed, head over to the `Python website`__ and install one of the `compatible Python versions`__. You need at -least Python 3.6, but you can go up to the latest 3.10. +least Python 3.7. Once Python is installed, you can install Supysonic using :command:`pip`. Refer to the `installation instructions `_ below for more information. From 4bbd7e94b0ab92d6605bc7af643fa0f5be4287db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Wed, 11 Jan 2023 23:12:13 +0100 Subject: [PATCH 146/237] Use hex-encoded string for id instead of bytes on DBMSs that don't provide native UUID support Closes #241 --- supysonic/db.py | 6 +- supysonic/schema/migration/mysql/20230111.py | 75 +++++++++++++++++++ supysonic/schema/migration/sqlite/20230111.py | 50 +++++++++++++ supysonic/schema/mysql.sql | 48 ++++++------ 4 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 supysonic/schema/migration/mysql/20230111.py create mode 100644 supysonic/schema/migration/sqlite/20230111.py diff --git a/supysonic/db.py b/supysonic/db.py index 29f71796..70868343 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -15,7 +15,6 @@ from hashlib import sha1 from peewee import ( AutoField, - BinaryUUIDField, BlobField, BooleanField, CharField, @@ -24,6 +23,7 @@ ForeignKeyField, IntegerField, TextField, + UUIDField, ) from peewee import CompositeKey, DatabaseProxy, Model, MySQLDatabase from peewee import fn @@ -31,7 +31,7 @@ from urllib.parse import urlparse from uuid import UUID, uuid4 -SCHEMA_VERSION = "20200607" +SCHEMA_VERSION = "20230111" def now(): @@ -45,7 +45,7 @@ def random(): def PrimaryKeyField(**kwargs): - return BinaryUUIDField(primary_key=True, default=uuid4, **kwargs) + return UUIDField(primary_key=True, default=uuid4, **kwargs) db = DatabaseProxy() diff --git a/supysonic/schema/migration/mysql/20230111.py b/supysonic/schema/migration/mysql/20230111.py new file mode 100644 index 00000000..d659cee1 --- /dev/null +++ b/supysonic/schema/migration/mysql/20230111.py @@ -0,0 +1,75 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2023 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +# Converts ids from binary data to hex-encoded strings + +try: + import MySQLdb as provider +except ImportError: + import pymysql as provider + +from uuid import UUID +from warnings import filterwarnings + + +def process_table(connection, table, fields, nullable_fields=()): + to_update = {field: set() for field in fields + nullable_fields} + + c = connection.cursor() + c.execute("SELECT {1} FROM {0}".format(table, ",".join(fields + nullable_fields))) + for row in c: + for field, value in zip(fields + nullable_fields, row): + if value is None or not isinstance(value, bytes): + continue + to_update[field].add(value) + + for field in fields: + sql = "ALTER TABLE {} MODIFY {} BINARY(32) NOT NULL".format(table, field) + c.execute(sql) + for field in nullable_fields: + sql = "ALTER TABLE {} MODIFY {} BINARY(32)".format(table, field) + c.execute(sql) + for field, values in to_update.items(): + if not values: + continue + sql = "UPDATE {0} SET {1}=%s WHERE {1}=%s".format(table, field) + c.executemany( + sql, map(lambda v: (UUID(bytes=v).hex, v + (b"\x00" * 16)), values) + ) + for field in fields: + sql = "ALTER TABLE {} MODIFY {} CHAR(32) NOT NULL".format(table, field) + c.execute(sql) + for field in nullable_fields: + sql = "ALTER TABLE {} MODIFY {} CHAR(32)".format(table, field) + c.execute(sql) + + connection.commit() + + +def apply(args): + filterwarnings("ignore", category=provider.Warning) + + conn = provider.connect(**args) + conn.cursor().execute("SET FOREIGN_KEY_CHECKS = 0") + + process_table(conn, "artist", ("id",)) + process_table(conn, "album", ("id", "artist_id")) + process_table(conn, "track", ("id", "album_id", "artist_id")) + process_table(conn, "user", ("id",), ("last_play_id",)) + process_table(conn, "client_prefs", ("user_id",)) + process_table(conn, "starred_folder", ("user_id",)) + process_table(conn, "starred_artist", ("user_id", "starred_id")) + process_table(conn, "starred_album", ("user_id", "starred_id")) + process_table(conn, "starred_track", ("user_id", "starred_id")) + process_table(conn, "rating_folder", ("user_id",)) + process_table(conn, "rating_track", ("user_id", "rated_id")) + process_table(conn, "chat_message", ("id", "user_id")) + process_table(conn, "playlist", ("id", "user_id")) + process_table(conn, "radio_station", ("id",)) + + conn.cursor().execute("SET FOREIGN_KEY_CHECKS = 1") + conn.close() diff --git a/supysonic/schema/migration/sqlite/20230111.py b/supysonic/schema/migration/sqlite/20230111.py new file mode 100644 index 00000000..9c81edb4 --- /dev/null +++ b/supysonic/schema/migration/sqlite/20230111.py @@ -0,0 +1,50 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2023 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +# Converts ids from binary data to hex-encoded strings + +import sqlite3 + +from uuid import UUID + + +def process_table(connection, table, fields): + to_update = {field: set() for field in fields} + + c = connection.cursor() + for row in c.execute("SELECT {1} FROM {0}".format(table, ",".join(fields))): + for field, value in zip(fields, row): + if value is None or not isinstance(value, bytes): + continue + to_update[field].add(value) + + for field, values in to_update.items(): + sql = "UPDATE {0} SET {1}=? WHERE {1}=?".format(table, field) + c.executemany(sql, map(lambda v: (UUID(bytes=v).hex, v), values)) + + connection.commit() + + +def apply(args): + file = args.pop("database") + with sqlite3.connect(file, **args) as conn: + conn.cursor().execute("PRAGMA foreign_keys = OFF") + + process_table(conn, "artist", ("id",)) + process_table(conn, "album", ("id", "artist_id")) + process_table(conn, "track", ("id", "album_id", "artist_id")) + process_table(conn, "user", ("id", "last_play_id")) + process_table(conn, "client_prefs", ("user_id",)) + process_table(conn, "starred_folder", ("user_id",)) + process_table(conn, "starred_artist", ("user_id", "starred_id")) + process_table(conn, "starred_album", ("user_id", "starred_id")) + process_table(conn, "starred_track", ("user_id", "starred_id")) + process_table(conn, "rating_folder", ("user_id",)) + process_table(conn, "rating_track", ("user_id", "rated_id")) + process_table(conn, "chat_message", ("id", "user_id")) + process_table(conn, "playlist", ("id", "user_id")) + process_table(conn, "radio_station", ("id",)) diff --git a/supysonic/schema/mysql.sql b/supysonic/schema/mysql.sql index a4c1c961..dd14798f 100644 --- a/supysonic/schema/mysql.sql +++ b/supysonic/schema/mysql.sql @@ -12,19 +12,19 @@ CREATE TABLE IF NOT EXISTS folder ( CREATE INDEX index_folder_parent_id_fk ON folder(parent_id); CREATE TABLE IF NOT EXISTS artist ( - id BINARY(16) PRIMARY KEY, + id CHAR(32) PRIMARY KEY, name VARCHAR(256) NOT NULL ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS album ( - id BINARY(16) PRIMARY KEY, + id CHAR(32) PRIMARY KEY, name VARCHAR(256) NOT NULL, - artist_id BINARY(16) NOT NULL REFERENCES artist(id) + artist_id CHAR(32) NOT NULL REFERENCES artist(id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE INDEX index_album_artist_id_fk ON album(artist_id); CREATE TABLE IF NOT EXISTS track ( - id BINARY(16) PRIMARY KEY, + id CHAR(32) PRIMARY KEY, disc INTEGER NOT NULL, number INTEGER NOT NULL, title VARCHAR(256) NOT NULL, @@ -32,8 +32,8 @@ CREATE TABLE IF NOT EXISTS track ( genre VARCHAR(256), duration INTEGER NOT NULL, has_art BOOLEAN NOT NULL DEFAULT false, - album_id BINARY(16) NOT NULL REFERENCES album(id), - artist_id BINARY(16) NOT NULL REFERENCES artist(id), + album_id CHAR(32) NOT NULL REFERENCES album(id), + artist_id CHAR(32) NOT NULL REFERENCES artist(id), bitrate INTEGER NOT NULL, path VARCHAR(4096) NOT NULL, path_hash BINARY(20) UNIQUE NOT NULL, @@ -50,7 +50,7 @@ CREATE INDEX index_track_folder_id_fk ON track(folder_id); CREATE INDEX index_track_root_folder_id_fk ON track(root_folder_id); CREATE TABLE IF NOT EXISTS user ( - id BINARY(16) PRIMARY KEY, + id CHAR(32) PRIMARY KEY, name VARCHAR(64) NOT NULL, mail VARCHAR(256), password CHAR(40) NOT NULL, @@ -59,13 +59,13 @@ CREATE TABLE IF NOT EXISTS user ( jukebox BOOLEAN NOT NULL, lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, - last_play_id BINARY(16) REFERENCES track(id), + last_play_id CHAR(32) REFERENCES track(id), last_play_date DATETIME ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE INDEX index_user_last_play_id_fk ON user(last_play_id); CREATE TABLE IF NOT EXISTS client_prefs ( - user_id BINARY(16) NOT NULL, + user_id CHAR(32) NOT NULL, client_name VARCHAR(32) NOT NULL, format VARCHAR(8), bitrate INTEGER, @@ -73,7 +73,7 @@ CREATE TABLE IF NOT EXISTS client_prefs ( ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS starred_folder ( - user_id BINARY(16) NOT NULL REFERENCES user(id), + user_id CHAR(32) NOT NULL REFERENCES user(id), starred_id INTEGER NOT NULL REFERENCES folder(id), date DATETIME NOT NULL, PRIMARY KEY (user_id, starred_id) @@ -82,8 +82,8 @@ CREATE INDEX index_starred_folder_user_id_fk ON starred_folder(user_id); CREATE INDEX index_starred_folder_starred_id_fk ON starred_folder(starred_id); CREATE TABLE IF NOT EXISTS starred_artist ( - user_id BINARY(16) NOT NULL REFERENCES user(id), - starred_id BINARY(16) NOT NULL REFERENCES artist(id), + user_id CHAR(32) NOT NULL REFERENCES user(id), + starred_id CHAR(32) NOT NULL REFERENCES artist(id), date DATETIME NOT NULL, PRIMARY KEY (user_id, starred_id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -91,8 +91,8 @@ CREATE INDEX index_starred_artist_user_id_fk ON starred_artist(user_id); CREATE INDEX index_starred_artist_starred_id_fk ON starred_artist(starred_id); CREATE TABLE IF NOT EXISTS starred_album ( - user_id BINARY(16) NOT NULL REFERENCES user(id), - starred_id BINARY(16) NOT NULL REFERENCES album(id), + user_id CHAR(32) NOT NULL REFERENCES user(id), + starred_id CHAR(32) NOT NULL REFERENCES album(id), date DATETIME NOT NULL, PRIMARY KEY (user_id, starred_id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -100,8 +100,8 @@ CREATE INDEX index_starred_album_user_id_fk ON starred_album(user_id); CREATE INDEX index_starred_album_starred_id_fk ON starred_album(starred_id); CREATE TABLE IF NOT EXISTS starred_track ( - user_id BINARY(16) NOT NULL REFERENCES user(id), - starred_id BINARY(16) NOT NULL REFERENCES track(id), + user_id CHAR(32) NOT NULL REFERENCES user(id), + starred_id CHAR(32) NOT NULL REFERENCES track(id), date DATETIME NOT NULL, PRIMARY KEY (user_id, starred_id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -109,7 +109,7 @@ CREATE INDEX index_starred_track_user_id_fk ON starred_track(user_id); CREATE INDEX index_starred_track_starred_id_fk ON starred_track(starred_id); CREATE TABLE IF NOT EXISTS rating_folder ( - user_id BINARY(16) NOT NULL REFERENCES user(id), + user_id CHAR(32) NOT NULL REFERENCES user(id), rated_id INTEGER NOT NULL REFERENCES folder(id), rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), PRIMARY KEY (user_id, rated_id) @@ -118,8 +118,8 @@ CREATE INDEX index_rating_folder_user_id_fk ON rating_folder(user_id); CREATE INDEX index_rating_folder_rated_id_fk ON rating_folder(rated_id); CREATE TABLE IF NOT EXISTS rating_track ( - user_id BINARY(16) NOT NULL REFERENCES user(id), - rated_id BINARY(16) NOT NULL REFERENCES track(id), + user_id CHAR(32) NOT NULL REFERENCES user(id), + rated_id CHAR(32) NOT NULL REFERENCES track(id), rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), PRIMARY KEY (user_id, rated_id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -127,16 +127,16 @@ CREATE INDEX index_rating_track_user_id_fk ON rating_track(user_id); CREATE INDEX index_rating_track_rated_id_fk ON rating_track(rated_id); CREATE TABLE IF NOT EXISTS chat_message ( - id BINARY(16) PRIMARY KEY, - user_id BINARY(16) NOT NULL REFERENCES user(id), + id CHAR(32) PRIMARY KEY, + user_id CHAR(32) NOT NULL REFERENCES user(id), time INTEGER NOT NULL, message VARCHAR(512) NOT NULL ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE INDEX index_chat_message_user_id_fk ON chat_message(user_id); CREATE TABLE IF NOT EXISTS playlist ( - id BINARY(16) PRIMARY KEY, - user_id BINARY(16) NOT NULL REFERENCES user(id), + id CHAR(32) PRIMARY KEY, + user_id CHAR(32) NOT NULL REFERENCES user(id), name VARCHAR(256) NOT NULL, comment VARCHAR(256), public BOOLEAN NOT NULL, @@ -151,7 +151,7 @@ CREATE TABLE meta ( ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS radio_station ( - id BINARY(16) PRIMARY KEY, + id CHAR(32) PRIMARY KEY, stream_url VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL, homepage_url VARCHAR(256), From b57b086e040c33e4643fd107ac20b398e053c151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 14 Jan 2023 16:45:44 +0100 Subject: [PATCH 147/237] Enforce foreign keys on SQLite --- supysonic/db.py | 1 + tests/api/test_album_songs.py | 1 + tests/base/test_db.py | 12 ++++++------ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index 70868343..53ac7dc5 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -588,6 +588,7 @@ def init_database(database_uri): provider = "postgres" elif uri.scheme.startswith("sqlite"): provider = "sqlite" + args["pragmas"] = {"foreign_keys": 1} else: raise RuntimeError(f"Unsupported database: {uri.scheme}") diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index 055983c7..17578f80 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -137,6 +137,7 @@ def test_get_album_list(self): ) self.assertEqual(len(child), 0) + Track.delete().execute() Folder[1].delete_instance() rv, child = self._make_request( "getAlbumList", {"type": "random"}, tag="albumList" diff --git a/tests/base/test_db.py b/tests/base/test_db.py index 7acbc345..bde95d07 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2022 Alban 'spl0k' Féron +# Copyright (C) 2017-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -10,6 +10,7 @@ import uuid from collections import namedtuple +from peewee import IntegrityError from supysonic import db @@ -20,11 +21,6 @@ class DbTestCase(unittest.TestCase): def setUp(self): db.init_database("sqlite:") - try: - self.assertRegex - except AttributeError: - self.assertRegex = self.assertRegexpMatches - def tearDown(self): db.release_database() @@ -114,6 +110,10 @@ def create_playlist(self): return playlist + def test_ensure_sqlite_foreign_keys(self): + root, _, _ = self.create_some_folders() + self.assertRaises(IntegrityError, root.delete_instance) + def test_folder_base(self): root_folder, child_folder, child_noart = self.create_some_folders() track_embededart = self.create_track_in(child_noart, root_folder) From 09a5fb12ed54735dd72375120daf6998422b11da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 14 Jan 2023 16:47:57 +0100 Subject: [PATCH 148/237] Fix integrity errors when deleting a root folder --- supysonic/db.py | 16 ++++++-- supysonic/managers/folder.py | 42 ++++++++++++++------ tests/managers/test_manager_folder.py | 55 ++++++++++++++++++++++----- 3 files changed, 90 insertions(+), 23 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index 53ac7dc5..931b7b19 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -202,11 +202,19 @@ def as_subsonic_artist(self, user): @classmethod def prune(cls): + album_artists = Album.select(Album.artist) + track_artists = Track.select(Track.artist) + + StarredArtist.delete().where( + StarredArtist.starred.not_in(album_artists), + StarredArtist.starred.not_in(track_artists), + ).execute() + return ( cls.delete() .where( - cls.id.not_in(Album.select(Album.artist)), - cls.id.not_in(Track.select(Track.artist)), + cls.id.not_in(album_artists), + cls.id.not_in(track_artists), ) .execute() ) @@ -269,7 +277,9 @@ def sort_key(self): @classmethod def prune(cls): - return cls.delete().where(cls.id.not_in(Track.select(Track.album))).execute() + albums = Track.select(Track.album) + StarredAlbum.delete().where(StarredAlbum.starred.not_in(albums)).execute() + return cls.delete().where(cls.id.not_in(albums)).execute() class Track(PathMixin, _Model): diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index 943149a2..27cd9e79 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -7,9 +7,21 @@ import os.path +from peewee import IntegrityError + from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError -from ..db import Folder, Track, Artist, Album, User, RatingTrack, StarredTrack +from ..db import ( + Folder, + Track, + Artist, + Album, + User, + RatingFolder, + RatingTrack, + StarredFolder, + StarredTrack, +) class FolderManager: @@ -67,21 +79,29 @@ def delete(id): except DaemonUnavailableError: pass - users = User.select(User.id).join(Track).where(Track.root_folder == folder) + root_cond = Track.root_folder == folder + users = User.select(User.id).join(Track).where(root_cond) User.update(last_play=None).where(User.id.in_(users)).execute() - deleted_tracks_query = Track.select(Track.id).where(Track.root_folder == folder) - RatingTrack.delete().where( - RatingTrack.rated.in_(deleted_tracks_query) - ).execute() - StarredTrack.delete().where( - StarredTrack.starred.in_(deleted_tracks_query) - ).execute() + tracks = Track.select(Track.id).where(root_cond) + RatingTrack.delete().where(RatingTrack.rated.in_(tracks)).execute() + StarredTrack.delete().where(StarredTrack.starred.in_(tracks)).execute() + + path_cond = Folder.path.startswith(folder.path) + folders = Folder.select(Folder.id).where(path_cond) + RatingFolder.delete().where(RatingFolder.rated.in_(folders)).execute() + StarredFolder.delete().where(StarredFolder.starred.in_(folders)).execute() - Track.delete().where(Track.root_folder == folder).execute() + Track.delete().where(root_cond).execute() Album.prune() Artist.prune() - Folder.delete().where(Folder.path.startswith(folder.path)).execute() + query = Folder.delete().where(path_cond) + try: + query.execute() + except IntegrityError: + # Integrity error most likely due to MySQL poor handling of delete order + query = query.order_by(Folder.path.desc()) + query.execute() @staticmethod def delete_by_name(name): diff --git a/tests/managers/test_manager_folder.py b/tests/managers/test_manager_folder.py index cccb47b7..221404e9 100644 --- a/tests/managers/test_manager_folder.py +++ b/tests/managers/test_manager_folder.py @@ -1,12 +1,26 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2022 Alban 'spl0k' Féron +# Copyright (C) 2017-2023 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. -from supysonic.db import Folder, Album, Artist, Track, init_database, release_database +from supysonic.db import ( + Folder, + Album, + Artist, + RatingFolder, + RatingTrack, + StarredAlbum, + StarredArtist, + StarredFolder, + StarredTrack, + Track, + User, + init_database, + release_database, +) from supysonic.managers.folder import FolderManager import os @@ -31,31 +45,48 @@ def tearDown(self): def create_folders(self): # Add test folders - self.assertIsNotNone(FolderManager.add("media", self.media_dir)) - self.assertIsNotNone(FolderManager.add("music", self.music_dir)) + media = FolderManager.add("media", self.media_dir) + music = FolderManager.add("music", self.music_dir) + self.assertIsNotNone(media) + self.assertIsNotNone(music) Folder.create( - root=False, name="non-root", path=os.path.join(self.music_dir, "subfolder") + root=False, + parent=music, + name="non-root", + path=os.path.join(self.music_dir, "subfolder"), ) artist = Artist.create(name="Artist") album = Album.create(name="Album", artist=artist) - root = Folder.get(name="media") - Track( + Track.create( title="Track", artist=artist, album=album, disc=1, number=1, path=os.path.join(self.media_dir, "somefile"), - folder=root, - root_folder=root, + folder=media, + root_folder=media, duration=2, bitrate=320, last_modification=0, ) + def create_annotations(self): + track = Track.select().first() + user = User.create(name="user", password="secret", salt="ABC+", last_play=track) + folder = Folder.get(name="media") + + RatingFolder.create(user=user, rated=folder, rating=3) + RatingTrack.create(user=user, rated=track, rating=3) + + StarredFolder.create(user=user, starred=folder) + StarredArtist.create(user=user, starred=track.artist_id) + StarredAlbum.create(user=user, starred=track.album_id) + StarredTrack.create(user=user, starred=track) + def test_get_folder(self): self.create_folders() @@ -116,6 +147,9 @@ def test_delete_folder(self): self.assertRaises(Folder.DoesNotExist, FolderManager.delete, folder.id) self.assertEqual(Folder.select().count(), 3) + # Create some annotation to ensure foreign keys are properly handled + self.create_annotations() + # Delete existing folders for name in ["media", "music"]: folder = Folder.get(name=name, root=True) @@ -132,6 +166,9 @@ def test_delete_by_name(self): self.assertRaises(Folder.DoesNotExist, FolderManager.delete_by_name, "null") self.assertEqual(Folder.select().count(), 3) + # Create some annotation to ensure foreign keys are properly handled + self.create_annotations() + # Delete existing folders for name in ["media", "music"]: FolderManager.delete_by_name(name) From 724f04726e4cf33d875c0261d28efe541de6246a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 15 Jan 2023 15:51:27 +0100 Subject: [PATCH 149/237] Adding missing foreign key on client_prefs --- supysonic/db.py | 16 +++++++++++----- supysonic/schema/migration/mysql/20230115.sql | 2 ++ supysonic/schema/migration/postgres/20230115.sql | 2 ++ supysonic/schema/migration/sqlite/20230115.sql | 16 ++++++++++++++++ supysonic/schema/mysql.sql | 3 ++- supysonic/schema/postgres.sql | 3 ++- supysonic/schema/sqlite.sql | 3 ++- 7 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 supysonic/schema/migration/mysql/20230115.sql create mode 100644 supysonic/schema/migration/postgres/20230115.sql create mode 100644 supysonic/schema/migration/sqlite/20230115.sql diff --git a/supysonic/db.py b/supysonic/db.py index 931b7b19..ee10fa65 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -31,7 +31,7 @@ from urllib.parse import urlparse from uuid import UUID, uuid4 -SCHEMA_VERSION = "20230111" +SCHEMA_VERSION = "20230115" def now(): @@ -608,8 +608,9 @@ def init_database(database_uri): # Check if we should create the tables if not db.table_exists("meta"): - execute_sql_resource_script(f"schema/{provider}.sql") - Meta.create(key="schema_version", value=SCHEMA_VERSION) + with db.atomic(): + execute_sql_resource_script(f"schema/{provider}.sql") + Meta.create(key="schema_version", value=SCHEMA_VERSION) # Check for schema changes version = Meta["schema_version"] @@ -618,11 +619,16 @@ def init_database(database_uri): pkg_resources.resource_listdir(__package__, f"schema/migration/{provider}") ) for migration in migrations: + if migration[0] in ("_", "."): + continue + date, ext = os.path.splitext(migration) if date <= version.value: continue + if ext == ".sql": - execute_sql_resource_script(f"schema/migration/{provider}/{migration}") + with db.atomic(): + execute_sql_resource_script(f"schema/migration/{provider}/{migration}") elif ext == ".py": m = importlib.import_module( f".schema.migration.{provider}.{date}", __package__ diff --git a/supysonic/schema/migration/mysql/20230115.sql b/supysonic/schema/migration/mysql/20230115.sql new file mode 100644 index 00000000..475de5a6 --- /dev/null +++ b/supysonic/schema/migration/mysql/20230115.sql @@ -0,0 +1,2 @@ +ALTER TABLE client_prefs ADD FOREIGN KEY (user_id) REFERENCES user(id); +CREATE INDEX IF NOT EXISTS index_client_prefs_user_id_fk ON client_prefs(user_id); diff --git a/supysonic/schema/migration/postgres/20230115.sql b/supysonic/schema/migration/postgres/20230115.sql new file mode 100644 index 00000000..f10ad713 --- /dev/null +++ b/supysonic/schema/migration/postgres/20230115.sql @@ -0,0 +1,2 @@ +ALTER TABLE client_prefs ADD FOREIGN KEY (user_id) REFERENCES "user"; +CREATE INDEX IF NOT EXISTS index_client_prefs_user_id_fk ON client_prefs(user_id); diff --git a/supysonic/schema/migration/sqlite/20230115.sql b/supysonic/schema/migration/sqlite/20230115.sql new file mode 100644 index 00000000..22484e8f --- /dev/null +++ b/supysonic/schema/migration/sqlite/20230115.sql @@ -0,0 +1,16 @@ +CREATE TABLE client_prefs_new ( + user_id CHAR(36) NOT NULL REFERENCES user, + client_name VARCHAR(32) NOT NULL, + format VARCHAR(8), + bitrate INTEGER, + PRIMARY KEY (user_id, client_name) +); + +INSERT INTO client_prefs_new(user_id, client_name, format, bitrate) +SELECT user_id, client_name, format, bitrate +FROM client_prefs; + +DROP TABLE client_prefs; +ALTER TABLE client_prefs_new RENAME TO client_prefs; + +CREATE INDEX IF NOT EXISTS index_client_prefs_user_id_fk ON client_prefs(user_id); diff --git a/supysonic/schema/mysql.sql b/supysonic/schema/mysql.sql index dd14798f..4c85bbac 100644 --- a/supysonic/schema/mysql.sql +++ b/supysonic/schema/mysql.sql @@ -65,12 +65,13 @@ CREATE TABLE IF NOT EXISTS user ( CREATE INDEX index_user_last_play_id_fk ON user(last_play_id); CREATE TABLE IF NOT EXISTS client_prefs ( - user_id CHAR(32) NOT NULL, + user_id CHAR(32) NOT NULL REFERENCES user(id), client_name VARCHAR(32) NOT NULL, format VARCHAR(8), bitrate INTEGER, PRIMARY KEY (user_id, client_name) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE INDEX index_client_prefs_user_id_fk ON client_prefs(user_id); CREATE TABLE IF NOT EXISTS starred_folder ( user_id CHAR(32) NOT NULL REFERENCES user(id), diff --git a/supysonic/schema/postgres.sql b/supysonic/schema/postgres.sql index 47b301cd..1984266e 100644 --- a/supysonic/schema/postgres.sql +++ b/supysonic/schema/postgres.sql @@ -65,12 +65,13 @@ CREATE TABLE IF NOT EXISTS "user" ( CREATE INDEX IF NOT EXISTS index_user_last_play_id_fk ON "user"(last_play_id); CREATE TABLE IF NOT EXISTS client_prefs ( - user_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES "user", client_name VARCHAR(32) NOT NULL, format VARCHAR(8), bitrate INTEGER, PRIMARY KEY (user_id, client_name) ); +CREATE INDEX IF NOT EXISTS index_client_prefs_user_id_fk ON client_prefs(user_id); CREATE TABLE IF NOT EXISTS starred_folder ( user_id UUID NOT NULL REFERENCES "user", diff --git a/supysonic/schema/sqlite.sql b/supysonic/schema/sqlite.sql index d2b905c9..ae39a87b 100644 --- a/supysonic/schema/sqlite.sql +++ b/supysonic/schema/sqlite.sql @@ -67,12 +67,13 @@ CREATE TABLE IF NOT EXISTS user ( CREATE INDEX IF NOT EXISTS index_user_last_play_id_fk ON user(last_play_id); CREATE TABLE IF NOT EXISTS client_prefs ( - user_id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL REFERENCES user, client_name VARCHAR(32) NOT NULL, format VARCHAR(8), bitrate INTEGER, PRIMARY KEY (user_id, client_name) ); +CREATE INDEX IF NOT EXISTS index_client_prefs_user_id_fk ON client_prefs(user_id); CREATE TABLE IF NOT EXISTS starred_folder ( user_id CHAR(36) NOT NULL REFERENCES user, From ad688a23c32d8afbee9e73432feaaff009916bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 15 Jan 2023 16:04:37 +0100 Subject: [PATCH 150/237] Fix FK errors when deleting a user --- supysonic/managers/user.py | 4 ++-- tests/managers/test_manager_user.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index fb16feec..23215c0b 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -37,12 +37,12 @@ def add(name, password, **kwargs): @staticmethod def delete(uid): user = UserManager.get(uid) - user.delete_instance() + user.delete_instance(recursive=True) @staticmethod def delete_by_name(name): user = User.get(name=name) - user.delete_instance() + user.delete_instance(recursive=True) @staticmethod def try_auth(name, password): diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index 7c873eb9..a1ea4caa 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -104,6 +104,7 @@ def test_delete_user(self): # Delete existing users for name in ["alice", "bob", "charlie"]: user = db.User.get(name=name) + db.ClientPrefs.create(user=user, client_name="tests") # test for FK handling UserManager.delete(user.id) self.assertRaises(db.User.DoesNotExist, db.User.__getitem__, user.id) self.assertEqual(db.User.select().count(), 0) @@ -113,6 +114,8 @@ def test_delete_by_name(self): # Delete existing users for name in ["alice", "bob", "charlie"]: + user = db.User.get(name=name) + db.ClientPrefs.create(user=user, client_name="tests") # test for FK handling UserManager.delete_by_name(name) self.assertFalse(db.User.select().where(db.User.name == name).exists()) From d5a4f1856c068d81bf3be177a67fbd659becd371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 15 Jan 2023 16:26:59 +0100 Subject: [PATCH 151/237] Genre related fixes Fix /getGenres for DBMSs other tha SQLite Don't list an empty genre Ref #241 --- supysonic/api/browse.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index 2bd13a01..05994c55 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -139,8 +139,9 @@ def list_genres(): "genre": [ {"value": genre, "songCount": sc, "albumCount": ac} for genre, sc, ac in Track.select( - Track.genre, fn.count(), fn.count(Track.album.distinct()) + Track.genre, fn.count("*"), fn.count(Track.album.distinct()) ) + .where(Track.genre.is_null(False)) .group_by(Track.genre) .tuples() ] From be6b617e60c6926eef90ca927980c92a5f378560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 15 Jan 2023 16:28:27 +0100 Subject: [PATCH 152/237] black --- supysonic/db.py | 4 +++- tests/managers/test_manager_user.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index ee10fa65..bc5650de 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -628,7 +628,9 @@ def init_database(database_uri): if ext == ".sql": with db.atomic(): - execute_sql_resource_script(f"schema/migration/{provider}/{migration}") + execute_sql_resource_script( + f"schema/migration/{provider}/{migration}" + ) elif ext == ".py": m = importlib.import_module( f".schema.migration.{provider}.{date}", __package__ diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index a1ea4caa..6fc9d7cd 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -104,7 +104,9 @@ def test_delete_user(self): # Delete existing users for name in ["alice", "bob", "charlie"]: user = db.User.get(name=name) - db.ClientPrefs.create(user=user, client_name="tests") # test for FK handling + db.ClientPrefs.create( + user=user, client_name="tests" + ) # test for FK handling UserManager.delete(user.id) self.assertRaises(db.User.DoesNotExist, db.User.__getitem__, user.id) self.assertEqual(db.User.select().count(), 0) @@ -115,7 +117,9 @@ def test_delete_by_name(self): # Delete existing users for name in ["alice", "bob", "charlie"]: user = db.User.get(name=name) - db.ClientPrefs.create(user=user, client_name="tests") # test for FK handling + db.ClientPrefs.create( + user=user, client_name="tests" + ) # test for FK handling UserManager.delete_by_name(name) self.assertFalse(db.User.select().where(db.User.name == name).exists()) From 36efefcda6b944adc69034b6b8e25a6e22a10c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 16 Jan 2023 22:10:39 +0100 Subject: [PATCH 153/237] Fix supysonic-server with gunicorn creating the application too early Was causing SQL connection issues when using forked workers Closes #241 --- supysonic/server/__init__.py | 6 ++---- supysonic/server/_base.py | 11 +++++++---- supysonic/server/gevent.py | 4 ++-- supysonic/server/gunicorn.py | 15 ++++++--------- supysonic/server/waitress.py | 4 ++-- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/supysonic/server/__init__.py b/supysonic/server/__init__.py index 4e156fba..c215de2c 100644 --- a/supysonic/server/__init__.py +++ b/supysonic/server/__init__.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2021 Alban 'spl0k' Féron +# Copyright (C) 2021-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -13,7 +13,6 @@ from click.exceptions import UsageError, ClickException from click.types import Choice -from ..web import create_application _servers = [ e.name[:-3] @@ -121,7 +120,6 @@ def main(server, host, port, socket, processes, threads): host = None port = None - app = create_application() server( - app, host=host, port=port, socket=socket, processes=processes, threads=threads + host=host, port=port, socket=socket, processes=processes, threads=threads ).run() diff --git a/supysonic/server/_base.py b/supysonic/server/_base.py index a47791ce..5fc26ab4 100644 --- a/supysonic/server/_base.py +++ b/supysonic/server/_base.py @@ -1,19 +1,19 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2021 Alban 'spl0k' Féron +# Copyright (C) 2021-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. from abc import ABCMeta, abstractmethod +from ..web import create_application + class BaseServer(metaclass=ABCMeta): def __init__( - self, app, *, host=None, port=None, socket=None, processes=None, threads=None + self, *, host=None, port=None, socket=None, processes=None, threads=None ): - self._app = app - self._host = host self._port = port self._socket = socket @@ -28,5 +28,8 @@ def _build_kwargs(self): def _run(self, **kwargs): ... + def _load_app(self): + return create_application() + def run(self): self._run(**self._build_kwargs()) diff --git a/supysonic/server/gevent.py b/supysonic/server/gevent.py index ba94f721..7f3904a4 100644 --- a/supysonic/server/gevent.py +++ b/supysonic/server/gevent.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2021 Alban 'spl0k' Féron +# Copyright (C) 2021-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -16,7 +16,7 @@ class GeventServer(BaseServer): def _build_kwargs(self): - rv = {"application": self._app} + rv = {"application": self._load_app()} if self._socket is not None: if os.path.exists(self._socket): diff --git a/supysonic/server/gunicorn.py b/supysonic/server/gunicorn.py index 9e6dfd35..7ae9a69f 100644 --- a/supysonic/server/gunicorn.py +++ b/supysonic/server/gunicorn.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2021 Alban 'spl0k' Féron +# Copyright (C) 2021-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -11,15 +11,11 @@ class GunicornApp(BaseApplication): - def __init__(self, app, **config): - self.__app = app + def __init__(self, **config): self.__config = config super().__init__() - def load(self): - return self.__app - def load_config(self): socket = self.__config["socket"] host = self.__config["host"] @@ -39,9 +35,10 @@ def load_config(self): class GunicornServer(BaseServer): - def __init__(self, app, **kwargs): - super().__init__(app, **kwargs) - self.__server = GunicornApp(app, **kwargs) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.__server = GunicornApp(**kwargs) + self.__server.load = self._load_app def _build_kwargs(self): return {} diff --git a/supysonic/server/waitress.py b/supysonic/server/waitress.py index 99ded0ce..e2d3552f 100644 --- a/supysonic/server/waitress.py +++ b/supysonic/server/waitress.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2021 Alban 'spl0k' Féron +# Copyright (C) 2021-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -12,7 +12,7 @@ class WaitressServer(BaseServer): def _build_kwargs(self): - rv = {"app": self._app} + rv = {"app": self._load_app()} if self._host is not None: rv["host"] = self._host From 0957fef1487bbac3f9597c5648f04c6758ba7d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 17 Jan 2023 22:59:43 +0100 Subject: [PATCH 154/237] Fix failing deletions from the scanner --- supysonic/db.py | 46 ++++++++++++++++++++---- supysonic/managers/folder.py | 38 ++------------------ supysonic/scanner.py | 26 ++++++++------ tests/base/test_scanner.py | 68 +++++++++++++++++++++++++++++++++++- tests/issue101.py | 49 -------------------------- 5 files changed, 126 insertions(+), 101 deletions(-) delete mode 100644 tests/issue101.py diff --git a/supysonic/db.py b/supysonic/db.py index bc5650de..7b3da497 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -167,18 +167,52 @@ def as_subsonic_directory(self, user, client): # "Directory" type in XSD @classmethod def prune(cls): - query = cls.delete().where( + alias = cls.alias() + query = cls.select(cls.id).where( ~cls.root, - cls.id.not_in(Track.select(Track.folder)), - cls.id.not_in(cls.select(cls.parent)), + Track.select(fn.count("*")).where(Track.folder == cls.id) == 0, + alias.select(fn.count("*")).where(alias.parent == cls.id) == 0, ) total = 0 while True: - count = query.execute() - total += count - if not count: + clone = query.clone() # peewee caches the results, clone to force a refetch + for f in clone: + f.delete_instance(recursive=True) + total += 1 + if not len(clone): return total + def delete_hierarchy(self): + if self.root: + cond = Track.root_folder == self + else: + cond = Track.path.startswith(self.path) + + return self.__delete_hierarchy(cond) + + def __delete_hierarchy(self, cond): + users = User.select(User.id).join(Track).where(cond) + User.update(last_play=None).where(User.id.in_(users)).execute() + + tracks = Track.select(Track.id).where(cond) + RatingTrack.delete().where(RatingTrack.rated.in_(tracks)).execute() + StarredTrack.delete().where(StarredTrack.starred.in_(tracks)).execute() + + path_cond = Folder.path.startswith(self.path) + folders = Folder.select(Folder.id).where(path_cond) + RatingFolder.delete().where(RatingFolder.rated.in_(folders)).execute() + StarredFolder.delete().where(StarredFolder.starred.in_(folders)).execute() + + deleted_tracks = Track.delete().where(cond).execute() + + query = Folder.delete().where(path_cond) + if isinstance(db.obj, MySQLDatabase): + # MySQL can't propery resolve deletion order when it has several to handle + query = query.order_by(Folder.path.desc()) + query.execute() + + return deleted_tracks + class Artist(_Model): id = PrimaryKeyField() diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index 27cd9e79..29f9476a 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -1,27 +1,15 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import os.path -from peewee import IntegrityError - from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError -from ..db import ( - Folder, - Track, - Artist, - Album, - User, - RatingFolder, - RatingTrack, - StarredFolder, - StarredTrack, -) +from ..db import Folder, Artist, Album class FolderManager: @@ -79,29 +67,9 @@ def delete(id): except DaemonUnavailableError: pass - root_cond = Track.root_folder == folder - users = User.select(User.id).join(Track).where(root_cond) - User.update(last_play=None).where(User.id.in_(users)).execute() - - tracks = Track.select(Track.id).where(root_cond) - RatingTrack.delete().where(RatingTrack.rated.in_(tracks)).execute() - StarredTrack.delete().where(StarredTrack.starred.in_(tracks)).execute() - - path_cond = Folder.path.startswith(folder.path) - folders = Folder.select(Folder.id).where(path_cond) - RatingFolder.delete().where(RatingFolder.rated.in_(folders)).execute() - StarredFolder.delete().where(StarredFolder.starred.in_(folders)).execute() - - Track.delete().where(root_cond).execute() + folder.delete_hierarchy() Album.prune() Artist.prune() - query = Folder.delete().where(path_cond) - try: - query.execute() - except IntegrityError: - # Integrity error most likely due to MySQL poor handling of delete order - query = query.order_by(Folder.path.desc()) - query.execute() @staticmethod def delete_by_name(name): diff --git a/supysonic/scanner.py b/supysonic/scanner.py index a6bbd485..70b582bc 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -141,8 +141,19 @@ def __scan_folder(self, folder): self.__report_progress(folder.name, scanned) + # Remove deleted/moved folders + folders = [folder] + while not self.__stopped.is_set() and folders: + f = folders.pop() + + if not f.root and not os.path.isdir(f.path): + self.__stats.deleted.tracks += f.delete_hierarchy() + continue + + folders += f.children[:] + # Remove files that have been deleted - # Could be more efficient if done above + # Could be more efficient if done when walking on the files if not self.__stopped.is_set(): for track in Track.select().where(Track.root_folder == folder): if not os.path.exists(track.path) or not self.__check_extension( @@ -150,15 +161,10 @@ def __scan_folder(self, folder): ): self.remove_file(track.path) - # Remove deleted/moved folders and update cover art info + # Update cover art info folders = [folder] while not self.__stopped.is_set() and folders: f = folders.pop() - - if not f.root and not os.path.isdir(f.path): - f.delete_instance(recursive=True) - continue - self.find_cover(f.path) folders += f.children[:] @@ -173,8 +179,8 @@ def prune(self): if self.__stopped.is_set(): return - self.__stats.deleted.albums = Album.prune() - self.__stats.deleted.artists = Artist.prune() + self.__stats.deleted.albums += Album.prune() + self.__stats.deleted.artists += Artist.prune() Folder.prune() def __check_extension(self, path): @@ -272,7 +278,7 @@ def remove_file(self, path): raise TypeError("Expecting string, got " + str(type(path))) try: - Track.get(path=path).delete_instance() + Track.get(path=path).delete_instance(recursive=True) self.__stats.deleted.tracks += 1 except Track.DoesNotExist: pass diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index b5fb212e..4f837f39 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -1,13 +1,14 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2022 Alban 'spl0k' Féron +# Copyright (C) 2017-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import mutagen import os import os.path +import shutil import tempfile import unittest @@ -164,5 +165,70 @@ def test_stats(self): self.assertEqual(stats.deleted.tracks, 0) +class ScannerDeletionsTestCase(unittest.TestCase): + def setUp(self): + self.__dir = tempfile.mkdtemp() + db.init_database("sqlite:") + FolderManager.add("folder", self.__dir) + + # Create folder hierarchy + self._firstsubdir = tempfile.mkdtemp(dir=self.__dir) + subdir = self._firstsubdir + for _ in range(4): + subdir = tempfile.mkdtemp(dir=subdir) + + # Put a file in the deepest folder + self._trackpath = os.path.join(subdir, "silence.mp3") + shutil.copyfile("tests/assets/folder/silence.mp3", self._trackpath) + + self._scan() + + # Create annotation data + track = db.Track.get() + firstdir = db.Folder.get(path=self._firstsubdir) + user = db.User.create( + name="user", password="password", salt="salt", last_play=track + ) + db.StarredFolder.create(user=user, starred=track.folder_id) + db.StarredFolder.create(user=user, starred=firstdir) + db.StarredArtist.create(user=user, starred=track.artist_id) + db.StarredAlbum.create(user=user, starred=track.album_id) + db.StarredTrack.create(user=user, starred=track) + db.RatingFolder.create(user=user, rated=track.folder_id, rating=2) + db.RatingFolder.create(user=user, rated=firstdir, rating=2) + db.RatingTrack.create(user=user, rated=track, rating=2) + + def tearDown(self): + db.release_database() + shutil.rmtree(self.__dir) + + def _scan(self): + scanner = Scanner() + scanner.queue_folder("folder") + scanner.run() + + return scanner.stats() + + def _check_assertions(self, stats): + self.assertEqual(stats.deleted.artists, 1) + self.assertEqual(stats.deleted.albums, 1) + self.assertEqual(stats.deleted.tracks, 1) + self.assertEqual(db.Track.select().count(), 0) + self.assertEqual(db.Album.select().count(), 0) + self.assertEqual(db.Artist.select().count(), 0) + self.assertEqual(db.User.select().count(), 1) + self.assertEqual(db.Folder.select().count(), 1) + + def test_parent_folder(self): + shutil.rmtree(self._firstsubdir) + stats = self._scan() + self._check_assertions(stats) + + def test_track(self): + os.remove(self._trackpath) + stats = self._scan() + self._check_assertions(stats) + + if __name__ == "__main__": unittest.main() diff --git a/tests/issue101.py b/tests/issue101.py deleted file mode 100644 index 6c41525c..00000000 --- a/tests/issue101.py +++ /dev/null @@ -1,49 +0,0 @@ -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2018-2022 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -import os.path -import shutil -import tempfile -import unittest - -from supysonic.db import init_database, release_database -from supysonic.managers.folder import FolderManager -from supysonic.scanner import Scanner - - -class Issue101TestCase(unittest.TestCase): - def setUp(self): - self.__dir = tempfile.mkdtemp() - init_database("sqlite:") - FolderManager.add("folder", self.__dir) - - def tearDown(self): - release_database() - shutil.rmtree(self.__dir) - - def test_issue(self): - firstsubdir = tempfile.mkdtemp(dir=self.__dir) - subdir = firstsubdir - for _ in range(4): - subdir = tempfile.mkdtemp(dir=subdir) - shutil.copyfile( - "tests/assets/folder/silence.mp3", os.path.join(subdir, "silence.mp3") - ) - - scanner = Scanner() - scanner.queue_folder("folder") - scanner.run() - - shutil.rmtree(firstsubdir) - - scanner = Scanner() - scanner.queue_folder("folder") - scanner.run() - - -if __name__ == "__main__": - unittest.main() From 536c4e9fb059976e78917106cae85e78ea1ee9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 20 Jan 2023 19:32:11 +0100 Subject: [PATCH 155/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 1040b9a9..350995d4 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.3" +VERSION = "0.7.4" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From f696a2dc0d664540a543c79747090eb0b2c60d33 Mon Sep 17 00:00:00 2001 From: lolspark <124263515+lolspark@users.noreply.github.com> Date: Thu, 2 Feb 2023 16:23:19 +0400 Subject: [PATCH 156/237] Update configuration.rst Added example for windows in daemon section. --- docs/setup/configuration.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index 821c82ce..9b1572ac 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -222,6 +222,8 @@ Sample configuration:: ; Socket file the daemon will listen on for incoming management commands ; Default: /tmp/supysonic/supysonic.sock socket = /var/run/supysonic.sock + ; Syntax for windows named pipe: + ;socket = \\.\pipe\supysonic.sock ; Defines if the file watcher should be started. Default: yes run_watcher = yes From 56162adbefb7334c49a636278162dac326b43641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 11 Feb 2023 14:47:54 +0100 Subject: [PATCH 157/237] Drop the "pragmas" kw for Python SQLite migrations Fixes #244 --- supysonic/db.py | 1 + 1 file changed, 1 insertion(+) diff --git a/supysonic/db.py b/supysonic/db.py index 7b3da497..0c745cdc 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -649,6 +649,7 @@ def init_database(database_uri): # Check for schema changes version = Meta["schema_version"] if version.value < SCHEMA_VERSION: + args.pop("pragmas", ()) migrations = sorted( pkg_resources.resource_listdir(__package__, f"schema/migration/{provider}") ) From 8e2adf8fc8f6faaef1d90f32d3a661c190cd87e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 25 Feb 2023 16:30:05 +0100 Subject: [PATCH 158/237] Fix getting starred stuff Closes #246 --- supysonic/api/albums_songs.py | 16 ++++++++++------ tests/api/test_album_songs.py | 23 ++++++++++++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 6e72dbd8..9544b3d0 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -270,9 +270,11 @@ def get_starred(): return request.formatter( "starred", { - "artist": [sf.as_subsonic_artist(request.user) for sf in arq], - "album": [sf.as_subsonic_child(request.user) for sf in alq], - "song": [st.as_subsonic_child(request.user, request.client) for st in trq], + "artist": [sf.starred.as_subsonic_artist(request.user) for sf in arq], + "album": [sf.starred.as_subsonic_child(request.user) for sf in alq], + "song": [ + st.starred.as_subsonic_child(request.user, request.client) for st in trq + ], }, ) @@ -306,8 +308,10 @@ def get_starred_id3(): return request.formatter( "starred2", { - "artist": [sa.as_subsonic_artist(request.user) for sa in arq], - "album": [sa.as_subsonic_album(request.user) for sa in alq], - "song": [st.as_subsonic_child(request.user, request.client) for st in trq], + "artist": [sa.starred.as_subsonic_artist(request.user) for sa in arq], + "album": [sa.starred.as_subsonic_album(request.user) for sa in alq], + "song": [ + st.starred.as_subsonic_child(request.user, request.client) for st in trq + ], }, ) diff --git a/tests/api/test_album_songs.py b/tests/api/test_album_songs.py index 17578f80..833aa39b 100644 --- a/tests/api/test_album_songs.py +++ b/tests/api/test_album_songs.py @@ -7,7 +7,17 @@ import unittest -from supysonic.db import Folder, Artist, Album, Track +from supysonic.db import ( + Folder, + Artist, + Album, + Track, + StarredArtist, + StarredAlbum, + StarredFolder, + StarredTrack, + User, +) from .apitestbase import ApiTestBase @@ -260,11 +270,22 @@ def test_get_random_songs(self): def test_now_playing(self): self._make_request("getNowPlaying", tag="nowPlaying") + def _create_starred_info(self): + user = User.select().first() + StarredArtist.create(user=user, starred=Artist.select().first()) + StarredAlbum.create(user=user, starred=Album.select().first()) + StarredTrack.create(user=user, starred=Track.select().first()) + StarredFolder.create(user=user, starred=Folder.select().first()) + def test_get_starred(self): + self._create_starred_info() + self._make_request("getStarred", tag="starred") self._make_request("getStarred", {"musicFolderId": 1}, tag="starred") def test_get_starred2(self): + self._create_starred_info() + self._make_request("getStarred2", tag="starred2") self._make_request("getStarred2", {"musicFolderId": 1}, tag="starred2") From eac6bdf1a36136a13890b5d8038d8d022ac7b96d Mon Sep 17 00:00:00 2001 From: vithyze <127023076+vithyze@users.noreply.github.com> Date: Sun, 5 Mar 2023 12:07:19 +0000 Subject: [PATCH 159/237] Add a setting to disable log rotation --- config.sample | 4 ++++ supysonic/config.py | 2 ++ supysonic/daemon/__init__.py | 6 +++--- supysonic/web.py | 8 +++++--- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/config.sample b/config.sample index 897cdd77..b6f57ef4 100644 --- a/config.sample +++ b/config.sample @@ -29,6 +29,9 @@ log_file = /var/supysonic/supysonic.log ; Default: WARNING log_level = WARNING +; Enable log rotation. Default: yes +log_rotate = yes + ; Enable the Subsonic REST API. You'll most likely want to keep this on, here ; for testing purposes. Default: on ;mount_api = on @@ -60,6 +63,7 @@ jukebox_command = mplayer -ss %offset %path ; Optional rotating log file for the scanner daemon. Logs to stderr if empty log_file = /var/supysonic/supysonic-daemon.log log_level = INFO +log_rotate = yes [lastfm] ; API and secret key to enable scrobbling. http://www.last.fm/api/accounts diff --git a/supysonic/config.py b/supysonic/config.py index 3d913b24..f9e126cd 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -34,6 +34,7 @@ class DefaultConfig: "transcode_cache_size": 512, "log_file": None, "log_level": "WARNING", + "log_rotate": True, "mount_webui": True, "mount_api": True, "index_ignored_prefixes": "El La Le Las Les Los The", @@ -47,6 +48,7 @@ class DefaultConfig: "jukebox_command": None, "log_file": None, "log_level": "WARNING", + "log_rotate": True, } LASTFM = {"api_key": None, "secret": None} TRANSCODING = {} diff --git a/supysonic/daemon/__init__.py b/supysonic/daemon/__init__.py index 2cd48ac2..49c1e9b4 100644 --- a/supysonic/daemon/__init__.py +++ b/supysonic/daemon/__init__.py @@ -25,10 +25,10 @@ def setup_logging(config): if config["log_file"]: - if config["log_file"] == "/dev/null": - log_handler = logging.NullHandler() - else: + if config["log_rotate"]: log_handler = TimedRotatingFileHandler(config["log_file"], when="midnight") + else: + log_handler = logging.FileHandler(config["log_file"]) log_handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") ) diff --git a/supysonic/web.py b/supysonic/web.py index eab41aed..6d203d98 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -11,6 +11,7 @@ import mimetypes from flask import Flask +from logging.handlers import TimedRotatingFileHandler from os import makedirs, path from .config import IniConfig @@ -35,9 +36,10 @@ def create_application(config=None): # Set loglevel logfile = app.config["WEBAPP"]["log_file"] if logfile: # pragma: nocover - from logging.handlers import TimedRotatingFileHandler - - handler = TimedRotatingFileHandler(logfile, when="midnight") + if app.config["WEBAPP"]["log_rotate"]: + handler = TimedRotatingFileHandler(logfile, when="midnight") + else: + handler = logging.FileHandler(logfile) handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") ) From 03ac57ca26f8aab58b75e057944c7a8bb5b91cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 18 Mar 2023 12:54:11 +0100 Subject: [PATCH 160/237] log rotate documentation --- docs/setup/configuration.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index 9b1572ac..e4c22c05 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -121,6 +121,11 @@ Configuration relative to the HTTP server. Defaults to ``WARNING``. +``log_rotate`` + Enable automatic log rotation (when logs are enabled) every day at midnight. + Set it to ``no`` if you don't want to rotate the logs or if you use external + utilities such as :command:`logrotate`. Defaults to ``yes``. + ``mount_api`` (``on`` or ``off``) Enable or disable the Subsonic REST API. Should be kept on or Supysonic would be quite useless. Exists mostly for testing purposes. @@ -161,6 +166,9 @@ Sample configuration:: ; Default: WARNING log_level = WARNING + ; Enable log rotation. Default: yes + log_rotate = yes + ; Enable the Subsonic REST API. You'll most likely want to keep this on. ; Here for testing purposes. Default: on ;mount_api = on @@ -216,6 +224,11 @@ library folders and providing the jukebox feature. Defaults to ``WARNING``. +``log_rotate`` + Enable automatic log rotation (when logs are enabled) every day at midnight. + Set it to ``no`` if you don't want to rotate the logs or if you use external + utilities such as :command:`logrotate`. Defaults to ``yes``. + Sample configuration:: [daemon] @@ -241,6 +254,9 @@ Sample configuration:: log_file = /var/supysonic/supysonic-daemon.log log_level = INFO + ; Enable log rotation. Default: yes + log_rotate = yes + .. _conf-lastfm: ``[lastfm]`` section From 27df94e578640d31768dd356c8ff046e89d10297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 18 Mar 2023 16:04:00 +0100 Subject: [PATCH 161/237] Setting to enable or disable ChartLyrics Will be disabled by default (cherry picked from commit b662162ca76874131e2ada469534eb69a834e4f6) --- config.sample | 3 +++ docs/setup/configuration.rst | 8 ++++++++ supysonic/api/media.py | 3 +++ supysonic/config.py | 1 + tests/net/test_lyrics.py | 2 ++ 5 files changed, 17 insertions(+) diff --git a/config.sample b/config.sample index b6f57ef4..3daeddff 100644 --- a/config.sample +++ b/config.sample @@ -43,6 +43,9 @@ log_rotate = yes ; Default: El La Le Las Les Los The index_ignored_prefixes = El La Le Las Les Los The +; Enable the ChartLyrics API. Default: off +online_lyrics = off + [daemon] ; Socket file the daemon will listen on for incoming management commands ; Default: /tmp/supysonic/supysonic.sock diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index e4c22c05..01463bf7 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -147,6 +147,11 @@ Configuration relative to the HTTP server. case insensitive. Defaults to ``El La Le Las Les Los The``. +``online_lyrics`` + If enabled, will fetch the lyrics (when requested) from ChartLyrics if they + aren't available locally (either from metadata or from text files). + Defaults to ``no``. + Sample configuration:: [webapp] @@ -180,6 +185,9 @@ Sample configuration:: ; Default: El La Le Las Les Los The index_ignored_prefixes = El La Le Las Les Los The + ; Enable the ChartLyrics API. Default: off + online_lyrics = off + .. _conf-daemon: ``[daemon]`` section diff --git a/supysonic/api/media.py b/supysonic/api/media.py index a5d3f0fd..6864880b 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -443,6 +443,9 @@ def lyrics(): return lyrics_response_for_track(track, lyrics) + if not current_app.config["WEBAPP"]["online_lyrics"]: + return request.formatter("lyrics", {}) + # Create a stable, unique, filesystem-compatible identifier for the artist+title unique = hashlib.md5( json.dumps([x.lower() for x in (artist, title)]).encode("utf-8") diff --git a/supysonic/config.py b/supysonic/config.py index f9e126cd..bff29c81 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -38,6 +38,7 @@ class DefaultConfig: "mount_webui": True, "mount_api": True, "index_ignored_prefixes": "El La Le Las Les Los The", + "online_lyrics": False, } DAEMON = { "socket": r"\\.\pipe\supysonic" diff --git a/tests/net/test_lyrics.py b/tests/net/test_lyrics.py index 18057d9b..81c787d3 100644 --- a/tests/net/test_lyrics.py +++ b/tests/net/test_lyrics.py @@ -19,6 +19,8 @@ class LyricsTestCase(ApiTestBase): def setUp(self): super().setUp() + self.config.WEBAPP["online_lyrics"] = True + folder = Folder.create( name="Root", path=os.path.abspath("tests/assets/lyrics"), From 034a47d1d77c871906c9712b00d4f2add37f05b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 18 Mar 2023 17:29:30 +0100 Subject: [PATCH 162/237] Fix getAlbumList for PostgreSQL Ref #245 --- supysonic/api/albums_songs.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 9544b3d0..148a1246 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -69,7 +69,7 @@ def album_list(): offset = int(offset) if offset else 0 root = get_root_folder(mfid) - query = Track.select(Track.folder).join(Folder).group_by(Track.folder) + query = Folder.select().join(Track, on=Track.folder).switch().group_by(Folder.id) if root is not None: query = query.where(Track.root_folder == root) @@ -78,8 +78,8 @@ def album_list(): "albumList", { "album": [ - t.folder.as_subsonic_child(request.user) - for t in query.order_by(random()).limit(size) + f.as_subsonic_child(request.user) + for f in query.order_by(random()).limit(size) ] }, ) @@ -101,7 +101,11 @@ def album_list(): query = query.order_by(Folder.name) elif ltype == "alphabeticalByArtist": parent = Folder.alias() - query = query.join(parent).order_by(parent.name, Folder.name) + query = ( + query.join(parent) + .group_by_extend(parent.id) + .order_by(parent.name, Folder.name) + ) elif ltype == "byYear": startyear = int(request.values["fromYear"]) endyear = int(request.values["toYear"]) @@ -122,8 +126,8 @@ def album_list(): "albumList", { "album": [ - t.folder.as_subsonic_child(request.user) - for t in query.limit(size).offset(offset) + f.as_subsonic_child(request.user) + for f in query.limit(size).offset(offset) ] }, ) From cf73d5a26d10b4faa46265f622a50db3a2e7e811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 18 Mar 2023 17:39:07 +0100 Subject: [PATCH 163/237] Fix getAlbumList2 for PostgreSQL Closes #245 (for real) --- supysonic/api/albums_songs.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 148a1246..e2880989 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -142,7 +142,7 @@ def album_list_id3(): offset = int(offset) if offset else 0 root = get_root_folder(mfid) - query = Album.select().join(Track).group_by(Album) + query = Album.select().join(Track).group_by(Album.id) if root is not None: query = query.where(Track.root_folder == root) @@ -171,7 +171,12 @@ def album_list_id3(): elif ltype == "alphabeticalByName": query = query.order_by(Album.name) elif ltype == "alphabeticalByArtist": - query = query.switch().join(Artist).order_by(Artist.name, Album.name) + query = ( + query.switch() + .join(Artist) + .group_by_extend(Artist.id) + .order_by(Artist.name, Album.name) + ) elif ltype == "byYear": startyear = int(request.values["fromYear"]) endyear = int(request.values["toYear"]) From dc93b43c41f65224d08bc916142a871da85cc284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 25 Mar 2023 15:59:52 +0100 Subject: [PATCH 164/237] Docs: add a note about supysonic-server tuning --- docs/conf.py | 2 +- docs/setup/deploying/index.rst | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2bb023c2..c14db223 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,7 +13,7 @@ project = supysonic.NAME author = supysonic.AUTHOR -copyright = "2013-2021, " + author +copyright = "2013-2023, " + author version = supysonic.VERSION release = supysonic.VERSION diff --git a/docs/setup/deploying/index.rst b/docs/setup/deploying/index.rst index 0d96eceb..82355184 100644 --- a/docs/setup/deploying/index.rst +++ b/docs/setup/deploying/index.rst @@ -19,7 +19,9 @@ the following command:: And it will start to listen on all IPv4 interfaces on port 5722. This command allows some options, more details are given on its manpage: -:doc:`/man/supysonic-server`. +:doc:`/man/supysonic-server`. It is intentionally kept simple, as such it +doesn't provide much in terms of tuning. If you want more control over the +server's behavior you might as well try one of the options presentend below. __ https://www.gevent.org __ https://gunicorn.org/ @@ -28,7 +30,7 @@ __ https://docs.pylonsproject.org/projects/waitress/en/stable/index.html Other options ^^^^^^^^^^^^^ -You'll find some other common (and less common) deployment option below: +You'll find some other common (and less common) deployment options below: .. toctree:: :maxdepth: 2 From 109f81e713cbdea75ed6c24e5a53000f8f5f5153 Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Fri, 31 Mar 2023 20:23:11 -0400 Subject: [PATCH 165/237] Fix bitrate units when scanning In `0183bcb6` the scanner switched from using Mutagen to Mediafile for scanning files. Prior to this commit, the bitrate from Mutagen was divided by 1000 to convert it from bps to kbps. After switching to Mediafile, the conversion was dropped even though Mediafile also reports bitrate in bps. This commit adds back the conversion to kbps and adds a test that checks that the bitrate and some other metadata is correct. This commit will fix transcoding being applied in some cases where it isn't needed. This was happening because the bitrate in the DB was always larger than the requested bitrate due to its units. --- supysonic/scanner.py | 2 +- tests/base/test_scanner.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 70b582bc..c2f32ddf 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -239,7 +239,7 @@ def scan_file(self, path_or_direntry): trdict["duration"] = int(tag.length) trdict["has_art"] = bool(tag.images) - trdict["bitrate"] = tag.bitrate + trdict["bitrate"] = tag.bitrate // 1000 trdict["last_modification"] = mtime tralbum = self.__find_album(albumartist, album) diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index 4f837f39..9f04f810 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -70,6 +70,22 @@ def test_scan_file(self): self.scanner.scan_file("/some/inexistent/path") self.assertEqual(db.Track.select().count(), 1) + def test_scanned_metadata(self): + self.assertEqual(db.Track.select().count(), 1) + + track = db.Track.select().first() + artist = db.Artist.select().where(db.Artist.id == track.artist).first() + album = db.Album.select().where(db.Album.id == track.album).first() + + self.assertEqual(track.bitrate, 128) + self.assertEqual(track.disc, 1) + self.assertEqual(track.number, 1) + self.assertEqual(track.duration, 4) + self.assertEqual(track.has_art, True) + self.assertEqual(track.title, "[silence]") + self.assertEqual(artist.name, "Some artist") + self.assertEqual(album.name, "Awesome album") + def test_remove_file(self): track = db.Track.select().first() self.assertRaises(TypeError, self.scanner.remove_file, None) From 639c68291a7a719dc54cafbfc118bf497ce6c5e6 Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Fri, 31 Mar 2023 20:40:28 -0400 Subject: [PATCH 166/237] Add database migrations to fix up the bitrate units The migration assumes that no audio files will be <16kbps and only wav files will be more than 16,000kbps. --- supysonic/db.py | 2 +- supysonic/schema/migration/mysql/20230331.sql | 5 +++++ supysonic/schema/migration/postgres/20230331.sql | 5 +++++ supysonic/schema/migration/sqlite/20230331.sql | 8 ++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 supysonic/schema/migration/mysql/20230331.sql create mode 100644 supysonic/schema/migration/postgres/20230331.sql create mode 100644 supysonic/schema/migration/sqlite/20230331.sql diff --git a/supysonic/db.py b/supysonic/db.py index 0c745cdc..4ab3a455 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -31,7 +31,7 @@ from urllib.parse import urlparse from uuid import UUID, uuid4 -SCHEMA_VERSION = "20230115" +SCHEMA_VERSION = "20230331" def now(): diff --git a/supysonic/schema/migration/mysql/20230331.sql b/supysonic/schema/migration/mysql/20230331.sql new file mode 100644 index 00000000..030a7bf8 --- /dev/null +++ b/supysonic/schema/migration/mysql/20230331.sql @@ -0,0 +1,5 @@ +START TRANSACTION; + +UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%.wav'; + +COMMIT; diff --git a/supysonic/schema/migration/postgres/20230331.sql b/supysonic/schema/migration/postgres/20230331.sql new file mode 100644 index 00000000..030a7bf8 --- /dev/null +++ b/supysonic/schema/migration/postgres/20230331.sql @@ -0,0 +1,5 @@ +START TRANSACTION; + +UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%.wav'; + +COMMIT; diff --git a/supysonic/schema/migration/sqlite/20230331.sql b/supysonic/schema/migration/sqlite/20230331.sql new file mode 100644 index 00000000..45e7b22d --- /dev/null +++ b/supysonic/schema/migration/sqlite/20230331.sql @@ -0,0 +1,8 @@ +COMMIT; +PRAGMA foreign_keys = OFF; +BEGIN TRANSACTION; + +UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%.wav'; + +COMMIT; +BEGIN TRANSACTION; From 62f921d05f8c7995f9affc15d345d71ffc419664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 2 Apr 2023 16:00:56 +0200 Subject: [PATCH 167/237] Fix #252 migrations --- supysonic/schema/migration/mysql/20230331.sql | 6 +----- supysonic/schema/migration/postgres/20230331.sql | 6 +----- supysonic/schema/migration/sqlite/20230331.sql | 7 ------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/supysonic/schema/migration/mysql/20230331.sql b/supysonic/schema/migration/mysql/20230331.sql index 030a7bf8..0c92526c 100644 --- a/supysonic/schema/migration/mysql/20230331.sql +++ b/supysonic/schema/migration/mysql/20230331.sql @@ -1,5 +1 @@ -START TRANSACTION; - -UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%.wav'; - -COMMIT; +UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%%.wav'; diff --git a/supysonic/schema/migration/postgres/20230331.sql b/supysonic/schema/migration/postgres/20230331.sql index 030a7bf8..0c92526c 100644 --- a/supysonic/schema/migration/postgres/20230331.sql +++ b/supysonic/schema/migration/postgres/20230331.sql @@ -1,5 +1 @@ -START TRANSACTION; - -UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%.wav'; - -COMMIT; +UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%%.wav'; diff --git a/supysonic/schema/migration/sqlite/20230331.sql b/supysonic/schema/migration/sqlite/20230331.sql index 45e7b22d..fec93d46 100644 --- a/supysonic/schema/migration/sqlite/20230331.sql +++ b/supysonic/schema/migration/sqlite/20230331.sql @@ -1,8 +1 @@ -COMMIT; -PRAGMA foreign_keys = OFF; -BEGIN TRANSACTION; - UPDATE track SET bitrate=bitrate/1000 WHERE bitrate > 16000 AND path NOT LIKE '%.wav'; - -COMMIT; -BEGIN TRANSACTION; From f3e743dece4057084327f1c59735db54ebe371e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 2 Apr 2023 16:13:54 +0200 Subject: [PATCH 168/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 350995d4..10cb204a 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.4" +VERSION = "0.7.5" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From 893a007f29c03de055fa71485c7c5c3980e12e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Thu, 20 Apr 2023 16:40:51 +0200 Subject: [PATCH 169/237] Properly close database connections when they're not in use CLI and web only Ref #253 --- supysonic/cli.py | 8 +++++--- supysonic/db.py | 8 ++++++++ supysonic/web.py | 10 ++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/supysonic/cli.py b/supysonic/cli.py index a586057d..c2cf58dd 100644 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -364,8 +364,10 @@ def user_rename(name, newname): def main(): config = IniConfig.from_common_locations() init_database(config.BASE["database_uri"]) - cli.main(obj=config) - release_database() + try: + cli.main(obj=config) + finally: + release_database() if __name__ == "__main__": diff --git a/supysonic/db.py b/supysonic/db.py index 4ab3a455..babe976f 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -679,3 +679,11 @@ def init_database(database_uri): def release_database(): db.close() db.initialize(None) + + +def open_connection(): + db.connect() + + +def close_connection(): + db.close() diff --git a/supysonic/web.py b/supysonic/web.py index 6d203d98..c617ad1f 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # 2018-2019 Carey 'pR0Ps' Metcalfe # 2017 Óscar García Amor # @@ -16,7 +16,7 @@ from .config import IniConfig from .cache import Cache -from .db import init_database +from .db import init_database, open_connection, close_connection from .utils import get_secret_key logger = logging.getLogger(__package__) @@ -50,6 +50,9 @@ def create_application(config=None): # Initialize database init_database(app.config["BASE"]["database_uri"]) + if not app.testing: + app.before_request(open_connection) + app.teardown_request(lambda exc: close_connection()) # Insert unknown mimetypes for k, v in app.config["MIMETYPES"].items(): @@ -83,4 +86,7 @@ def create_application(config=None): app.register_blueprint(api, url_prefix="/rest") + if not app.testing: + close_connection() + return app From 32a74706c24b75a96688037adf2966aee5b6f805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Thu, 20 Apr 2023 18:17:10 +0200 Subject: [PATCH 170/237] Properly close database connections when they're not in use Daemon startup, background scans Ref #253 --- supysonic/daemon/server.py | 8 ++++++-- supysonic/db.py | 4 ++-- supysonic/scanner.py | 9 +++++++-- supysonic/web.py | 6 +++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index d8d6fb6b..8f53b22d 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2019-2022 Alban 'spl0k' Féron +# Copyright (C) 2019-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -12,7 +12,7 @@ from threading import Thread, Event from .client import DaemonCommand -from ..db import Folder +from ..db import Folder, open_connection, close_connection from ..jukebox import Jukebox from ..scanner import Scanner from ..utils import get_secret_key @@ -59,6 +59,8 @@ def run(self): if self.__config.DAEMON["jukebox_command"]: self.__jukebox = Jukebox(self.__config.DAEMON["jukebox_command"]) + close_connection() + Thread(target=self.__listen).start() while not self.__stopped.is_set(): time.sleep(1) @@ -72,9 +74,11 @@ def __listen(self): def start_scan(self, folders=[], force=False): if not folders: + open_connection() folders = [ t[0] for t in Folder.select(Folder.name).where(Folder.root).tuples() ] + close_connection() if self.__scanner is not None and self.__scanner.is_alive(): for f in folders: diff --git a/supysonic/db.py b/supysonic/db.py index babe976f..0ad49082 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -681,8 +681,8 @@ def release_database(): db.initialize(None) -def open_connection(): - db.connect() +def open_connection(reuse=False): + return db.connect(reuse) def close_connection(): diff --git a/supysonic/scanner.py b/supysonic/scanner.py index c2f32ddf..d29399e8 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -16,7 +16,7 @@ from threading import Thread, Event from .covers import find_cover_in_folder, CoverFile -from .db import Folder, Artist, Album, Track +from .db import Folder, Artist, Album, Track, open_connection, close_connection logger = logging.getLogger(__name__) @@ -95,6 +95,8 @@ def queue_folder(self, folder_name): self.__queue.put(folder_name) def run(self): + opened = open_connection(True) + while not self.__stopped.is_set(): try: folder_name = self.__queue.get(False) @@ -113,6 +115,9 @@ def run(self): if self.__on_done is not None: self.__on_done() + if opened: + close_connection() + def stop(self): self.__stopped.set() diff --git a/supysonic/web.py b/supysonic/web.py index c617ad1f..d4d676ab 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -51,7 +51,11 @@ def create_application(config=None): # Initialize database init_database(app.config["BASE"]["database_uri"]) if not app.testing: - app.before_request(open_connection) + + def open_conn(): # Just to discard the return value + open_connection() + + app.before_request(open_conn) app.teardown_request(lambda exc: close_connection()) # Insert unknown mimetypes From abe0b799688ee9181b7d04f5e67f6fd800e2ab36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 21 Apr 2023 17:04:44 +0200 Subject: [PATCH 171/237] Properly close database connections when they're not in use Watcher Closes #253 --- supysonic/watcher.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 9307843f..76fa6d53 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -14,7 +14,7 @@ from watchdog.events import PatternMatchingEventHandler from . import covers -from .db import Folder +from .db import Folder, open_connection, close_connection from .scanner import Scanner OP_SCAN = 1 @@ -45,16 +45,9 @@ def on_created(self, event): logger.debug("File created: '%s'", event.src_path) op = OP_SCAN | FLAG_CREATE - if not covers.is_valid_cover(event.src_path): - self.queue.put(event.src_path, op) - - dirname = os.path.dirname(event.src_path) - try: - Folder.get(path=dirname) - except Folder.DoesNotExist: - self.queue.put(dirname, op | FLAG_COVER) - else: - self.queue.put(event.src_path, op | FLAG_COVER) + if covers.is_valid_cover(event.src_path): + op |= FLAG_COVER + self.queue.put(event.src_path, op) def on_deleted(self, event): logger.debug("File deleted: '%s'", event.src_path) @@ -154,6 +147,7 @@ def __run(self): continue logger.debug("Instantiating scanner") + open_connection() scanner = Scanner() item = self.__next_item() @@ -166,6 +160,7 @@ def __run(self): item = self.__next_item() scanner.prune() + close_connection() logger.debug("Freeing scanner") def __process_regular_item(self, scanner, item): From 4bc80bfce52b3d4e74fda04d41d91665937f9ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis-Philippe=20V=C3=A9ronneau?= Date: Tue, 13 Jun 2023 15:44:38 -0400 Subject: [PATCH 172/237] Replace distutils.dir_util.remove_tree() by shutil.rmtree(). In Python 3.10 and 3.11, distutils has been formally marked as deprecated. Code that imports distutils will no longer work from Python 3.12. I'm pretty sure distutils.dir_util.remove_tree() and shutil.rmtree() do the same exact same thing and this should fix the issue :) --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 190e0ba7..d86991bb 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ import os.path -from distutils import dir_util +from shutil import rmtree from setuptools import setup from setuptools.command.sdist import sdist as _sdist @@ -19,7 +19,7 @@ def make_release_tree(self, base_dir, files): man_dir = os.path.join(base_dir, "man") doctrees_dir = os.path.join(man_dir, ".doctrees") self.spawn(["sphinx-build", "-q", "-b", "man", "docs", man_dir]) - dir_util.remove_tree(doctrees_dir) + rmtree(doctrees_dir) if __name__ == "__main__": From a14a7da11d7a7c83766533b634703573bfd21cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 14 Jul 2023 12:17:06 +0200 Subject: [PATCH 173/237] Log failed login attempts Closes #257 --- supysonic/api/__init__.py | 16 ++++++++++++---- supysonic/frontend/user.py | 6 +++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 4965d4c9..fdeebf14 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -1,13 +1,14 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. API_VERSION = "1.12.0" import binascii +import logging import uuid from flask import request from flask import Blueprint @@ -20,6 +21,7 @@ from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter api = Blueprint("api", __name__) +logger = logging.getLogger(__name__) def api_routing(endpoint): @@ -57,12 +59,15 @@ def decode_password(password): @api.before_request def authorize(): if request.authorization: - user = UserManager.try_auth( - request.authorization.username, request.authorization.password - ) + username = request.authorization.username + user = UserManager.try_auth(username, request.authorization.password) if user is not None: request.user = user return + + logger.error( + "Failed login attempt for user %s (IP: %s)", username, request.remote_addr + ) raise Unauthorized() username = request.values["u"] @@ -71,6 +76,9 @@ def authorize(): user = UserManager.try_auth(username, password) if user is None: + logger.error( + "Failed login attempt for user %s (IP: %s)", username, request.remote_addr + ) raise Unauthorized() request.user = user diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index c69fe58d..23db33f0 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2013-2023 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -319,10 +319,14 @@ def login(): if not error: user = UserManager.try_auth(name, password) if user: + logger.info("Logged user %s (IP: %s)", name, request.remote_addr) session["userid"] = str(user.id) flash("Logged in!") return redirect(return_url) else: + logger.error( + "Failed login attempt for user %s (IP: %s)", name, request.remote_addr + ) flash("Wrong username or password") return render_template("login.html") From 0fa0d55290c34eba978c131401ac027d5d5be850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Fri, 14 Jul 2023 15:24:17 +0200 Subject: [PATCH 174/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 10cb204a..18ab9901 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.5" +VERSION = "0.7.6" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From c2a9d42772b17702a29d521d0795d9efc9ae5789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 2 Jan 2024 13:22:43 +0100 Subject: [PATCH 175/237] Fix failing test with Flask >=3.0.0 Fixes #260 --- tests/frontend/test_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index c1723cde..297eaaf0 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -8,7 +8,7 @@ import unittest import uuid -from flask import escape +from markupsafe import escape from supysonic.db import User, ClientPrefs From 2209b65d5d9c1d7b0b587d65734fb5de795024ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 2 Jan 2024 14:34:33 +0100 Subject: [PATCH 176/237] Add ReadTheDocs configuration file --- .readthedocs.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..1f0cf258 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,11 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + +formats: all From 1feaae76377f40a509a5633de71fb35786d0dd5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Tue, 2 Jan 2024 15:13:43 +0100 Subject: [PATCH 177/237] Silence auth errors from tests --- tests/api/apitestbase.py | 4 +++- tests/frontend/frontendtestbase.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index e662625c..3531efe8 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -1,11 +1,12 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2020 Alban 'spl0k' Féron +# Copyright (C) 2017-2024 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import re +import logging from lxml import etree @@ -22,6 +23,7 @@ class ApiTestBase(TestBase): def setUp(self, apiVersion="1.12.0"): super().setUp() + logging.getLogger("supysonic.api").addHandler(logging.NullHandler()) self.apiVersion = apiVersion xsd = etree.parse(f"tests/assets/subsonic-rest-api-{self.apiVersion}.xsd") self.schema = etree.XMLSchema(xsd) diff --git a/tests/frontend/frontendtestbase.py b/tests/frontend/frontendtestbase.py index 8d23eafa..7a885ba8 100644 --- a/tests/frontend/frontendtestbase.py +++ b/tests/frontend/frontendtestbase.py @@ -1,10 +1,12 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2024 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. +import logging + from ..testbase import TestBase @@ -13,6 +15,7 @@ class FrontendTestBase(TestBase): def setUp(self): super().setUp() + logging.getLogger("supysonic.frontend.user").addHandler(logging.NullHandler()) self._patch_client() def _login(self, username, password): From a39f2fb16aa8d86e7dd175b71c8482c62b880a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20=C3=81valos?= Date: Mon, 18 Mar 2024 03:30:56 -0600 Subject: [PATCH 178/237] Initial ListenBrainz support --- supysonic/api/annotation.py | 4 + supysonic/config.py | 1 + supysonic/db.py | 7 +- supysonic/frontend/user.py | 25 ++++ supysonic/listenbrainz.py | 127 ++++++++++++++++++ supysonic/schema/migration/mysql/20240318.sql | 2 + .../schema/migration/postgres/20240318.sql | 2 + .../schema/migration/sqlite/20240318.sql | 2 + supysonic/schema/mysql.sql | 2 + supysonic/schema/postgres.sql | 2 + supysonic/schema/sqlite.sql | 2 + supysonic/templates/profile.html | 22 +++ 12 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 supysonic/listenbrainz.py create mode 100644 supysonic/schema/migration/mysql/20240318.sql create mode 100644 supysonic/schema/migration/postgres/20240318.sql create mode 100644 supysonic/schema/migration/sqlite/20240318.sql diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index b1cec691..0a99e3f3 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -13,6 +13,7 @@ from ..db import StarredTrack, StarredAlbum, StarredArtist, StarredFolder from ..db import RatingTrack, RatingFolder from ..lastfm import LastFm +from ..listenbrainz import ListenBrainz from . import get_entity, get_entity_id, api_routing from .exceptions import AggregateException, GenericError, MissingParameter, NotFound @@ -175,10 +176,13 @@ def scrobble(): t = int(t) / 1000 if t else int(time.time()) lfm = LastFm(current_app.config["LASTFM"], request.user) + lbz = ListenBrainz(current_app.config["LISTENBRAINZ"], request.user) if submission in (None, "", True, "true", "True", 1, "1"): lfm.scrobble(res, t) + lbz.scrobble(res, t) else: lfm.now_playing(res) + lbz.now_playing(res) return request.formatter.empty diff --git a/supysonic/config.py b/supysonic/config.py index bff29c81..b969c19f 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -52,6 +52,7 @@ class DefaultConfig: "log_rotate": True, } LASTFM = {"api_key": None, "secret": None} + LISTENBRAINZ = {"api_url": "https://api.listenbrainz.org"} TRANSCODING = {} MIMETYPES = {} diff --git a/supysonic/db.py b/supysonic/db.py index 0ad49082..9ed4ccf2 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -31,7 +31,7 @@ from urllib.parse import urlparse from uuid import UUID, uuid4 -SCHEMA_VERSION = "20230331" +SCHEMA_VERSION = "20240318" def now(): @@ -439,6 +439,11 @@ class User(_Model): default=True ) # True: ok/unlinked, False: invalid session + listenbrainz_session = FixedCharField(36, null=True) + listenbrainz_status = BooleanField( + default=True + ) # True: ok/unlinked, False: invalid token + last_play = ForeignKeyField(Track, null=True, backref="+") last_play_date = DateTimeField(null=True) diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index 23db33f0..1e329b39 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -13,6 +13,7 @@ from ..db import ClientPrefs, User from ..lastfm import LastFm +from ..listenbrainz import ListenBrainz from ..managers.user import UserManager from . import admin_only, frontend @@ -297,6 +298,30 @@ def lastfm_unreg(uid, user): return redirect(url_for("frontend.user_profile", uid=uid)) +@frontend.route("/user//listenbrainz/link") +@me_or_uuid +def listenbrainz_reg(uid, user): + token = request.args.get("token") + if not token: + flash("Missing ListenBrainz auth token") + return redirect(url_for("frontend.user_profile", uid=uid)) + + lbz = ListenBrainz(current_app.config["LISTENBRAINZ"], user) + status, error = lbz.link_account(token) + flash(error if not status else "Successfully linked ListenBrainz account") + + return redirect(url_for("frontend.user_profile", uid=uid)) + + +@frontend.route("/user//listenbrainz/unlink") +@me_or_uuid +def listenbrainz_unreg(uid, user): + lbz = ListenBrainz(current_app.config["LISTENBRAINZ"], user) + lbz.unlink_account() + flash("Unlinked ListenBrainz account") + return redirect(url_for("frontend.user_profile", uid=uid)) + + @frontend.route("/user/login", methods=["GET", "POST"]) def login(): return_url = request.args.get("returnUrl") or url_for("frontend.index") diff --git a/supysonic/listenbrainz.py b/supysonic/listenbrainz.py new file mode 100644 index 00000000..186ce6d6 --- /dev/null +++ b/supysonic/listenbrainz.py @@ -0,0 +1,127 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2013-2022 Alban 'spl0k' Féron +# Copyright (C) 2024 Iván Ávalos +# +# Distributed under terms of the GNU AGPLv3 license. + +import hashlib +import logging +import requests +import json +from urllib.parse import urljoin + +logger = logging.getLogger(__name__) + +class ListenBrainz: + def __init__(self, config, user): + if config["api_url"] is not None: + self.__api_url = config["api_url"] + self.__enabled = True + else: + self.__enabled = False + self.__user = user + + def link_account(self, token): + if not self.__enabled: + return False, "No ListenBrainz URL set" + + res = self.__api_request(False, "/1/validate-token", token) + if not res: + return False, "Error connecting to ListenBrainz" + else: + if "valid" in res and res["valid"]: + self.__user.listenbrainz_session = token + self.__user.listenbrainz_status = True + self.__user.save() + return True, "OK" + else: + return False, f"Error: {res['message']}" + + + def unlink_account(self): + self.__user.listenbrainz_session = None + self.__user.listenbrainz_status = True + self.__user.save() + + def now_playing(self, track): + if not self.__enabled: + return + + self.__api_request( + True, + "/1/submit-listens", + self.__user.listenbrainz_session, + listen_type="playing_now", + payload=[{ + "track_metadata": { + "artist_name": track.album.artist.name, + "track_name": track.title, + "release_name": track.album.name, + "additional_info": { + "media_player": "Supysonic", + "duration_ms": track.duration, + }, + }, + }] + ) + + def scrobble(self, track, ts): + if not self.__enabled: + return + + self.__api_request( + True, + "/1/submit-listens", + self.__user.listenbrainz_session, + listen_type="single", + payload=[{ + "listened_at": ts, + "track_metadata": { + "artist_name": track.album.artist.name, + "track_name": track.title, + "release_name": track.album.name, + "additional_info": { + "media_player": "Supysonic", + "duration_ms": track.duration, + }, + }, + }] + ) + + def __api_request(self, write, route, token, **kwargs): + if not self.__enabled or not token: + return + + headers = {"Content-Type": "application/json"} + headers["Authorization"] = "Token {0}".format(token) + + try: + if write: + r = requests.post( + urljoin(self.__api_url, route), + headers=headers, + data=json.dumps(kwargs), + timeout=5) + else: + r = requests.get( + urljoin(self.__api_url, route), + headers=headers, + data=json.dumps(kwargs), + timeout=5) + + r.raise_for_status() + except requests.HTTPError as e: + status_code = e.response.status_code + if status_code == 401: # Unauthorized + self.__user.listenbrainz_status = False + self.__user.save() + message = e.response.json().get("error", "") + logger.warning("ListenBrainz error %i: %s", status_code, message) + return None + except requests.exceptions.RequestException as e: + logger.warning("Error while connecting to ListenBrainz: " + str(e)) + return None + + return r.json() diff --git a/supysonic/schema/migration/mysql/20240318.sql b/supysonic/schema/migration/mysql/20240318.sql new file mode 100644 index 00000000..b7457d04 --- /dev/null +++ b/supysonic/schema/migration/mysql/20240318.sql @@ -0,0 +1,2 @@ +ALTER TABLE user ADD listenbrainz_session CHAR(36); +ALTER TABLE user ADD listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/supysonic/schema/migration/postgres/20240318.sql b/supysonic/schema/migration/postgres/20240318.sql new file mode 100644 index 00000000..bced44fb --- /dev/null +++ b/supysonic/schema/migration/postgres/20240318.sql @@ -0,0 +1,2 @@ +ALTER TABLE user ADD COLUMN listenbrainz_session CHAR(36); +ALTER TABLE user ADD COLUMN listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/supysonic/schema/migration/sqlite/20240318.sql b/supysonic/schema/migration/sqlite/20240318.sql new file mode 100644 index 00000000..b7457d04 --- /dev/null +++ b/supysonic/schema/migration/sqlite/20240318.sql @@ -0,0 +1,2 @@ +ALTER TABLE user ADD listenbrainz_session CHAR(36); +ALTER TABLE user ADD listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/supysonic/schema/mysql.sql b/supysonic/schema/mysql.sql index 4c85bbac..ae7bb692 100644 --- a/supysonic/schema/mysql.sql +++ b/supysonic/schema/mysql.sql @@ -57,6 +57,8 @@ CREATE TABLE IF NOT EXISTS user ( salt CHAR(6) NOT NULL, admin BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL, + listenbrainz_session CHAR(36), + listenbrainz_status BOOLEAN NOT NULL, lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id CHAR(32) REFERENCES track(id), diff --git a/supysonic/schema/postgres.sql b/supysonic/schema/postgres.sql index 1984266e..b870492f 100644 --- a/supysonic/schema/postgres.sql +++ b/supysonic/schema/postgres.sql @@ -57,6 +57,8 @@ CREATE TABLE IF NOT EXISTS "user" ( salt CHAR(6) NOT NULL, admin BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL, + listenbrainz_session CHAR(36), + listenbrainz_status BOOLEAN NOT NULL, lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id UUID REFERENCES track, diff --git a/supysonic/schema/sqlite.sql b/supysonic/schema/sqlite.sql index ae39a87b..996d5662 100644 --- a/supysonic/schema/sqlite.sql +++ b/supysonic/schema/sqlite.sql @@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS user ( salt CHAR(6) NOT NULL, admin BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL, + listenbrainz_session CHAR(36), + listenbrainz_status BOOLEAN NOT NULL, lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id CHAR(36) REFERENCES track, diff --git a/supysonic/templates/profile.html b/supysonic/templates/profile.html index 56c29afa..ecc0f642 100644 --- a/supysonic/templates/profile.html +++ b/supysonic/templates/profile.html @@ -81,6 +81,28 @@

{{ user.name }}{% if user.admin %} + +
+ +
+
ListenBrainz status
+ {% if user.listenbrainz_session %} + +
+ Unlink +
+ {% else %} + +
+ +
+ {% endif %} +
+
+ +
+ {% if request.user.id == user.id %} Change password From 0f49dfb3ac8a90f977759019ba1c59df32dd760f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20=C3=81valos?= Date: Mon, 18 Mar 2024 22:58:03 -0600 Subject: [PATCH 179/237] Add ListenBrainz documentation and tests --- README.md | 4 +++- config.sample | 5 +++++ docs/index.rst | 2 ++ docs/setup/configuration.rst | 29 +++++++++++++++++++++++++++++ tests/frontend/test_user.py | 16 ++++++++++++++++ tests/net/test_listenbrainz.py | 26 ++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/net/test_listenbrainz.py diff --git a/README.md b/README.md index 387d832a..8ebf6fab 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Current supported features are: * cover art * starred tracks/albums and ratings * [Last.fm][lastfm] scrobbling +* [ListenBrainz][listenbrainz] scrobbling * Jukebox mode Supysonic currently targets the version 1.12.0 of the Subsonic API. For more @@ -21,6 +22,7 @@ details, go check the [API implementation status][docs-api]. [subsonic]: http://www.subsonic.org/ [lastfm]: https://www.last.fm/ +[listenbrainz]: https://listenbrainz.org/ [docs-api]: https://supysonic.readthedocs.io/en/latest/api.html ## Documentation @@ -72,6 +74,6 @@ And there's also the tests (which require `lxml` to run): $ python -m unittest tests.net.suite The last command runs a few tests that make HTTP requests to remote third-party -services (namely Last.fm and ChartLyrics). +services (namely Last.fm, ListenBrainz and ChartLyrics). [flask]: https://flask.palletsprojects.com/ diff --git a/config.sample b/config.sample index 3daeddff..e0c2b63f 100644 --- a/config.sample +++ b/config.sample @@ -74,6 +74,11 @@ log_rotate = yes ;api_key = ;secret = +[listenbrainz] +; root URL of the ListenBrainz API. +; Defaults: https://api.listenbrainz.org/ +;api_url = + [transcoding] ; Programs used to convert from one format/bitrate to another. Defaults: none transcoder_mp3_mp3 = lame --quiet --mp3input -b %outrate %srcpath - diff --git a/docs/index.rst b/docs/index.rst index 0411c00d..f2b0a644 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,10 +12,12 @@ Current supported features are: * cover art * starred tracks/albums and ratings * `Last.FM`__ scrobbling +* `ListenBrainz`__ scrobbling * Jukebox mode __ http://www.subsonic.org/ __ https://www.last.fm/ +__ https://listenbrainz.org/ .. rubric:: User's guide diff --git a/docs/setup/configuration.rst b/docs/setup/configuration.rst index 01463bf7..2dd75728 100644 --- a/docs/setup/configuration.rst +++ b/docs/setup/configuration.rst @@ -293,6 +293,35 @@ Sample configuration:: ;api_key = ;secret = +.. _conf-listenbrainz: + +``[listenbrainz]`` section +-------------------------- + +This section allows a custom ListenBrainz instance to be configured +for scrobbling. ListenBrainz is a music scrobbling service with social +features, similar to LastFM, but it is open source and +self-hostable. Supysonic can configured with any ListenBrainz +instance, but it connects to the official instance by default. + +In order to connect to ListenBrainz, each user requires an user token +that can be obtained from their ListenBrainz profile (more information +in the API docs). This token has to be configured per profile using +the web UI. + +The ListenBrainz API documentation can be found here: +https://listenbrainz.readthedocs.io/en/latest/users/api/index.html + +``api_url`` + root URL of the ListenBrainz API for the instance + +Sample configuration:: + + [listenbrainz] + ; root URL of the ListenBrainz API. + ; Defaults: https://api.listenbrainz.org/ + ;api_url = + .. _conf-transcoding: ``[transcoding]`` section diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index 297eaaf0..dff2f5fe 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -255,6 +255,22 @@ def test_lastfm_unlink(self): rv = self.client.get("/user/me/lastfm/unlink", follow_redirects=True) self.assertIn("Unlinked", rv.data) + def test_listenbrainz_link(self): + self._login("alice", "Alic3") + rv = self.client.get("/user/me/listenbrainz/link", follow_redirects=True) + self.assertIn("Missing ListenBrainz auth token", rv.data) + # # Testing this requires an HTTP request! + # rv = self.client.get( + # "/user/me/listenbrainz/link", + # query_string={"token": "abcdef"}, + # follow_redirects=True, + # ) + # self.assertIn("Error: ", rv.data) + + def test_listenbrainz_unlink(self): + self._login("alice", "Alic3") + rv = self.client.get("/user/me/listenbrainz/unlink", follow_redirects=True) + self.assertIn("Unlinked", rv.data) if __name__ == "__main__": unittest.main() diff --git a/tests/net/test_listenbrainz.py b/tests/net/test_listenbrainz.py new file mode 100644 index 00000000..abd20314 --- /dev/null +++ b/tests/net/test_listenbrainz.py @@ -0,0 +1,26 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2024 Iván Ávalos +# +# Distributed under terms of the GNU AGPLv3 license. + +import logging +import unittest + +from supysonic.listenbrainz import ListenBrainz + +class ListenBrainzTestCase(unittest.TestCase): + """Basic test of unauthenticated ListenBrainz API method""" + + def test_request(self): + logging.getLogger("supysonic.listenbrainz").addHandler(logging.NullHandler()) + listenbrainz = ListenBrainz({"api_url": "https://api.listenbrainz.org/"}, None) + + user = "aavalos" + rv = listenbrainz._ListenBrainz__api_request(False, "/1/search/users/?search_term={0}".format(user), token="123") + self.assertIsInstance(rv, dict) + +if __name__ == "__main__": + unittest.main() From 8d3a31d0124eb851b6cb6ff65b9c14fbfca17ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20=C3=81valos?= Date: Sun, 31 Mar 2024 20:17:26 -0600 Subject: [PATCH 180/237] Allow non-admin users to link ListenBrainz account --- supysonic/templates/profile.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/supysonic/templates/profile.html b/supysonic/templates/profile.html index ecc0f642..6be53798 100644 --- a/supysonic/templates/profile.html +++ b/supysonic/templates/profile.html @@ -90,12 +90,20 @@

{{ user.name }}{% if user.admin %}
+ {% if request.user.id == user.id %} + Unlink + {% else %} Unlink + {% endif %}
{% else %}
+ {% if request.user.id == user.id %} + + {% else %} + {% endif %}
{% endif %} From 1cfca9410938e5f4aa518deb13f47493cf9185c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 1 Apr 2024 12:33:57 +0200 Subject: [PATCH 181/237] Move a ListenBrainz test to the net suite Allow to uncomment it and slightly improves coverage --- tests/frontend/test_user.py | 12 ------------ tests/net/test_listenbrainz.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index dff2f5fe..d65b1dc7 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -255,18 +255,6 @@ def test_lastfm_unlink(self): rv = self.client.get("/user/me/lastfm/unlink", follow_redirects=True) self.assertIn("Unlinked", rv.data) - def test_listenbrainz_link(self): - self._login("alice", "Alic3") - rv = self.client.get("/user/me/listenbrainz/link", follow_redirects=True) - self.assertIn("Missing ListenBrainz auth token", rv.data) - # # Testing this requires an HTTP request! - # rv = self.client.get( - # "/user/me/listenbrainz/link", - # query_string={"token": "abcdef"}, - # follow_redirects=True, - # ) - # self.assertIn("Error: ", rv.data) - def test_listenbrainz_unlink(self): self._login("alice", "Alic3") rv = self.client.get("/user/me/listenbrainz/unlink", follow_redirects=True) diff --git a/tests/net/test_listenbrainz.py b/tests/net/test_listenbrainz.py index abd20314..6486ad1e 100644 --- a/tests/net/test_listenbrainz.py +++ b/tests/net/test_listenbrainz.py @@ -11,6 +11,8 @@ from supysonic.listenbrainz import ListenBrainz +from ..frontend.frontendtestbase import FrontendTestBase + class ListenBrainzTestCase(unittest.TestCase): """Basic test of unauthenticated ListenBrainz API method""" @@ -22,5 +24,18 @@ def test_request(self): rv = listenbrainz._ListenBrainz__api_request(False, "/1/search/users/?search_term={0}".format(user), token="123") self.assertIsInstance(rv, dict) +class FrontendListenBrainzCase(FrontendTestBase): + def test_listenbrainz_link(self): + self._login("alice", "Alic3") + rv = self.client.get("/user/me/listenbrainz/link", follow_redirects=True) + self.assertIn("Missing ListenBrainz auth token", rv.data) + rv = self.client.get( + "/user/me/listenbrainz/link", + query_string={"token": "abcdef"}, + follow_redirects=True, + ) + self.assertIn("Error: ", rv.data) + + if __name__ == "__main__": unittest.main() From 4a0437b5e20cad126ac6d344cad1f6bf1ca094b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 14 Apr 2024 15:59:51 +0200 Subject: [PATCH 182/237] Fix(?) watcher queue sometimes not stopping when asked Ref #263 --- supysonic/watcher.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 76fa6d53..a99ed9fb 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -141,7 +141,10 @@ def __run(self): time.sleep(0.1) with self.__cond: - self.__cond.wait() + # Flag might have flipped during sleep. Check it again before waiting + # See issue #263 + if self.__running: + self.__cond.wait() if not self.__queue: continue @@ -195,8 +198,8 @@ def __process_cover_item(self, scanner, item): scanner.add_cover(item.path) def stop(self): - self.__running = False with self.__cond: + self.__running = False self.__cond.notify() def put(self, path, operation, **kwargs): From f12e403c67c5158b368ce777f36702181a5c0e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 19 May 2024 15:59:40 +0200 Subject: [PATCH 183/237] Add Python 3.12 support --- .github/workflows/tests.yaml | 3 ++- setup.cfg | 1 + supysonic/db.py | 38 ++++++++++++++++++++++++++++++------ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f03f07d7..3a68b88a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -29,7 +29,8 @@ jobs: - 3.8 - 3.9 - "3.10" - - 3.11 + - "3.11" + - "3.12" fail-fast: false steps: - name: Checkout diff --git a/setup.cfg b/setup.cfg index 2a47c054..d951e089 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,6 +46,7 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Topic :: Multimedia :: Sound/Audio [options] diff --git a/supysonic/db.py b/supysonic/db.py index 9ed4ccf2..306290b3 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -1,14 +1,14 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2023 Alban 'spl0k' Féron +# Copyright (C) 2013-2024 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import importlib import mimetypes import os.path -import pkg_resources +import sys import time from datetime import datetime @@ -616,8 +616,36 @@ def as_subsonic_station(self): return info +if sys.version_info < (3, 9): + import pkg_resources + + def get_resource_text(respath): + return pkg_resources.resource_string(__package__, respath).decode("utf-8") + + def list_migrations(provider): + return pkg_resources.resource_listdir( + __package__, f"schema/migration/{provider}" + ) + +else: + import importlib.resources + + def get_resource_text(respath): + return ( + importlib.resources.files(__package__).joinpath(respath).read_text("utf-8") + ) + + def list_migrations(provider): + return ( + e.name + for e in importlib.resources.files(__package__) + .joinpath(f"schema/migration/{provider}") + .iterdir() + ) + + def execute_sql_resource_script(respath): - sql = pkg_resources.resource_string(__package__, respath).decode("utf-8") + sql = get_resource_text(respath) for statement in sql.split(";"): statement = statement.strip() if statement and not statement.startswith("--"): @@ -655,9 +683,7 @@ def init_database(database_uri): version = Meta["schema_version"] if version.value < SCHEMA_VERSION: args.pop("pragmas", ()) - migrations = sorted( - pkg_resources.resource_listdir(__package__, f"schema/migration/{provider}") - ) + migrations = sorted(list_migrations(provider)) for migration in migrations: if migration[0] in ("_", "."): continue From df51cabb4193bdf02d8e1981d5270b87474553a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 19 May 2024 16:02:45 +0200 Subject: [PATCH 184/237] Run the project through an up-to-date black --- supysonic/api/formatters.py | 8 +++-- supysonic/api/search.py | 16 ++++++---- supysonic/config.py | 8 +++-- supysonic/db.py | 8 +++-- supysonic/listenbrainz.py | 56 +++++++++++++++++++--------------- supysonic/server/__init__.py | 8 ++--- supysonic/server/_base.py | 6 ++-- tests/frontend/test_user.py | 1 + tests/net/test_listenbrainz.py | 6 +++- 9 files changed, 68 insertions(+), 49 deletions(-) diff --git a/supysonic/api/formatters.py b/supysonic/api/formatters.py index 1a15a2cc..a30de9cf 100644 --- a/supysonic/api/formatters.py +++ b/supysonic/api/formatters.py @@ -42,9 +42,11 @@ def __remove_empty_lists(self, d): keys_to_remove.append(key) else: d[key] = [ - self.__remove_empty_lists(item) - if isinstance(item, dict) - else item + ( + self.__remove_empty_lists(item) + if isinstance(item, dict) + else item + ) for item in value ] diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 90acc860..cf2ff5bd 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -67,9 +67,11 @@ def old_search(): "totalHits": folders.count() + tracks.count(), "offset": offset, "match": [ - r.as_subsonic_child(request.user) - if isinstance(r, Folder) - else r.as_subsonic_child(request.user, request.client) + ( + r.as_subsonic_child(request.user) + if isinstance(r, Folder) + else r.as_subsonic_child(request.user, request.client) + ) for r in res ], }, @@ -83,9 +85,11 @@ def old_search(): "totalHits": query.count(), "offset": offset, "match": [ - r.as_subsonic_child(request.user) - if isinstance(r, Folder) - else r.as_subsonic_child(request.user, request.client) + ( + r.as_subsonic_child(request.user) + if isinstance(r, Folder) + else r.as_subsonic_child(request.user, request.client) + ) for r in query[offset : offset + count] ], }, diff --git a/supysonic/config.py b/supysonic/config.py index b969c19f..039ae4a5 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -41,9 +41,11 @@ class DefaultConfig: "online_lyrics": False, } DAEMON = { - "socket": r"\\.\pipe\supysonic" - if sys.platform == "win32" - else os.path.join(tempdir, "supysonic.sock"), + "socket": ( + r"\\.\pipe\supysonic" + if sys.platform == "win32" + else os.path.join(tempdir, "supysonic.sock") + ), "run_watcher": True, "wait_delay": 5, "jukebox_command": None, diff --git a/supysonic/db.py b/supysonic/db.py index 306290b3..e7bfa58d 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -539,9 +539,11 @@ def as_subsonic_playlist(self, user): tracks = self.get_tracks() info = { "id": str(self.id), - "name": self.name - if self.user.id == user.id - else f"[{self.user.name}] {self.name}", + "name": ( + self.name + if self.user.id == user.id + else f"[{self.user.name}] {self.name}" + ), "owner": self.user.name, "public": self.public, "songCount": len(tracks), diff --git a/supysonic/listenbrainz.py b/supysonic/listenbrainz.py index 186ce6d6..8e943b94 100644 --- a/supysonic/listenbrainz.py +++ b/supysonic/listenbrainz.py @@ -14,6 +14,7 @@ logger = logging.getLogger(__name__) + class ListenBrainz: def __init__(self, config, user): if config["api_url"] is not None: @@ -39,7 +40,6 @@ def link_account(self, token): else: return False, f"Error: {res['message']}" - def unlink_account(self): self.__user.listenbrainz_session = None self.__user.listenbrainz_status = True @@ -54,17 +54,19 @@ def now_playing(self, track): "/1/submit-listens", self.__user.listenbrainz_session, listen_type="playing_now", - payload=[{ - "track_metadata": { - "artist_name": track.album.artist.name, - "track_name": track.title, - "release_name": track.album.name, - "additional_info": { - "media_player": "Supysonic", - "duration_ms": track.duration, + payload=[ + { + "track_metadata": { + "artist_name": track.album.artist.name, + "track_name": track.title, + "release_name": track.album.name, + "additional_info": { + "media_player": "Supysonic", + "duration_ms": track.duration, + }, }, - }, - }] + } + ], ) def scrobble(self, track, ts): @@ -76,18 +78,20 @@ def scrobble(self, track, ts): "/1/submit-listens", self.__user.listenbrainz_session, listen_type="single", - payload=[{ - "listened_at": ts, - "track_metadata": { - "artist_name": track.album.artist.name, - "track_name": track.title, - "release_name": track.album.name, - "additional_info": { - "media_player": "Supysonic", - "duration_ms": track.duration, + payload=[ + { + "listened_at": ts, + "track_metadata": { + "artist_name": track.album.artist.name, + "track_name": track.title, + "release_name": track.album.name, + "additional_info": { + "media_player": "Supysonic", + "duration_ms": track.duration, + }, }, - }, - }] + } + ], ) def __api_request(self, write, route, token, **kwargs): @@ -103,18 +107,20 @@ def __api_request(self, write, route, token, **kwargs): urljoin(self.__api_url, route), headers=headers, data=json.dumps(kwargs), - timeout=5) + timeout=5, + ) else: r = requests.get( urljoin(self.__api_url, route), headers=headers, data=json.dumps(kwargs), - timeout=5) + timeout=5, + ) r.raise_for_status() except requests.HTTPError as e: status_code = e.response.status_code - if status_code == 401: # Unauthorized + if status_code == 401: # Unauthorized self.__user.listenbrainz_status = False self.__user.save() message = e.response.json().get("error", "") diff --git a/supysonic/server/__init__.py b/supysonic/server/__init__.py index c215de2c..86f1dae4 100644 --- a/supysonic/server/__init__.py +++ b/supysonic/server/__init__.py @@ -27,10 +27,10 @@ def __init__(self, *args, **kwargs): help = kwargs.get("help", "") if self.mutually_exclusive: ex_str = ", ".join(self.mutually_exclusive) - kwargs[ - "help" - ] = "{} NOTE: This argument is mutually exclusive with arguments: [{}].".format( - help, ex_str + kwargs["help"] = ( + "{} NOTE: This argument is mutually exclusive with arguments: [{}].".format( + help, ex_str + ) ) super().__init__(*args, **kwargs) diff --git a/supysonic/server/_base.py b/supysonic/server/_base.py index 5fc26ab4..1c075fb3 100644 --- a/supysonic/server/_base.py +++ b/supysonic/server/_base.py @@ -21,12 +21,10 @@ def __init__( self._threads = threads @abstractmethod - def _build_kwargs(self): - ... + def _build_kwargs(self): ... @abstractmethod - def _run(self, **kwargs): - ... + def _run(self, **kwargs): ... def _load_app(self): return create_application() diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index d65b1dc7..26922a57 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -260,5 +260,6 @@ def test_listenbrainz_unlink(self): rv = self.client.get("/user/me/listenbrainz/unlink", follow_redirects=True) self.assertIn("Unlinked", rv.data) + if __name__ == "__main__": unittest.main() diff --git a/tests/net/test_listenbrainz.py b/tests/net/test_listenbrainz.py index 6486ad1e..e17ca7bc 100644 --- a/tests/net/test_listenbrainz.py +++ b/tests/net/test_listenbrainz.py @@ -13,6 +13,7 @@ from ..frontend.frontendtestbase import FrontendTestBase + class ListenBrainzTestCase(unittest.TestCase): """Basic test of unauthenticated ListenBrainz API method""" @@ -21,9 +22,12 @@ def test_request(self): listenbrainz = ListenBrainz({"api_url": "https://api.listenbrainz.org/"}, None) user = "aavalos" - rv = listenbrainz._ListenBrainz__api_request(False, "/1/search/users/?search_term={0}".format(user), token="123") + rv = listenbrainz._ListenBrainz__api_request( + False, "/1/search/users/?search_term={0}".format(user), token="123" + ) self.assertIsInstance(rv, dict) + class FrontendListenBrainzCase(FrontendTestBase): def test_listenbrainz_link(self): self._login("alice", "Alic3") From 002f223eb122a9ad0ba276d2f2cee2a9e5560c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 19 May 2024 17:07:09 +0200 Subject: [PATCH 185/237] Update GitHub actions --- .github/workflows/tests.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 3a68b88a..78d80992 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -34,9 +34,9 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -48,5 +48,7 @@ jobs: coverage run -m unittest coverage run -a -m unittest tests.net.suite - name: Upload coverage - uses: codecov/codecov-action@v1.0.15 + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} if: ${{ !cancelled() }} From 813f5a3a8fc3fde05cfe5c6e57690491919f5e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 19 May 2024 17:14:13 +0200 Subject: [PATCH 186/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 18ab9901..3185171d 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.6" +VERSION = "0.7.7" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From a1231e05ad49dfe408d3a500f662a363e8b35fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 20 May 2024 15:17:50 +0200 Subject: [PATCH 187/237] Fix latest postgresql migration Closes #264 --- supysonic/schema/migration/postgres/20240318.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supysonic/schema/migration/postgres/20240318.sql b/supysonic/schema/migration/postgres/20240318.sql index bced44fb..078f9771 100644 --- a/supysonic/schema/migration/postgres/20240318.sql +++ b/supysonic/schema/migration/postgres/20240318.sql @@ -1,2 +1,2 @@ -ALTER TABLE user ADD COLUMN listenbrainz_session CHAR(36); -ALTER TABLE user ADD COLUMN listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE "user" ADD COLUMN listenbrainz_session CHAR(36); +ALTER TABLE "user" ADD COLUMN listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE; From 03b3b652e5205b4d9e49f2dabe789162c9b212fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Mon, 20 May 2024 15:24:54 +0200 Subject: [PATCH 188/237] Version bump --- supysonic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index 3185171d..8dead248 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -7,7 +7,7 @@ # Distributed under terms of the GNU AGPLv3 license. NAME = "Supysonic" -VERSION = "0.7.7" +VERSION = "0.7.8" DESCRIPTION = "Python implementation of the Subsonic server API" AUTHOR = "Alban Féron" AUTHOR_EMAIL = "alban.feron@gmail.com" From bd53ac05f63653fdebb25e5fdc92d747afb44b62 Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Sun, 8 Sep 2024 14:38:35 -0400 Subject: [PATCH 189/237] Fix track duration display --- supysonic/db.py | 8 +++++--- tests/base/test_db.py | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/supysonic/db.py b/supysonic/db.py index e7bfa58d..d273b951 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -412,9 +412,11 @@ def mimetype(self): return mimetypes.guess_type(self.path, False)[0] or "application/octet-stream" def duration_str(self): - ret = f"{(self.duration % 3600) / 60:02}:{self.duration % 60:02}" - if self.duration >= 3600: - ret = f"{self.duration / 3600:02}:{ret}" + m, s = divmod(self.duration, 60) + h, m = divmod(m, 60) + ret = f"{m:02}:{s:02}" + if h: + ret = f"{h:02}:{ret}" return ret def suffix(self): diff --git a/tests/base/test_db.py b/tests/base/test_db.py index bde95d07..58890d6b 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -59,7 +59,7 @@ def create_some_tracks(self, artist=None, album=None): artist=artist, disc=1, number=1, - duration=3, + duration=3599, has_art=True, bitrate=320, path="tests/assets/formats/silence.ogg", @@ -74,7 +74,7 @@ def create_some_tracks(self, artist=None, album=None): artist=artist, disc=1, number=2, - duration=5, + duration=3600, bitrate=96, path="tests/assets/23bytes", last_modification=1234, @@ -223,6 +223,9 @@ def test_album(self): def test_track(self): track1, track2 = self.create_some_tracks() + assert track1.duration_str() == "59:59" + assert track2.duration_str() == "01:00:00" + # Assuming SQLite doesn't enforce foreign key constraints MockUser = namedtuple("User", ["id"]) user = MockUser(uuid.uuid4()) From bad81b7fe5f071182c0d51eb63e8f18589ab76ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Sun, 1 Dec 2024 21:06:37 +0100 Subject: [PATCH 190/237] Migrates to Bootstrap 5, closes #266 --- supysonic/static/css/bootstrap-theme.min.css | 6 - .../static/css/bootstrap-theme.min.css.map | 1 - supysonic/static/css/bootstrap.min.css | 10 +- supysonic/static/css/bootstrap.min.css.map | 2 +- supysonic/static/css/supysonic.css | 30 +- .../fonts/glyphicons-halflings-regular.eot | Bin 20127 -> 0 bytes .../fonts/glyphicons-halflings-regular.svg | 288 ------------------ .../fonts/glyphicons-halflings-regular.ttf | Bin 45404 -> 0 bytes .../fonts/glyphicons-halflings-regular.woff | Bin 23424 -> 0 bytes .../fonts/glyphicons-halflings-regular.woff2 | Bin 18028 -> 0 bytes supysonic/static/img/vinyl.svg | 1 + supysonic/static/js/bootstrap.bundle.min.js | 7 + supysonic/static/js/bootstrap.min.js | 7 - supysonic/static/js/supysonic.js | 21 +- supysonic/templates/addfolder.html | 38 +-- supysonic/templates/adduser.html | 73 ++--- supysonic/templates/change_mail.html | 27 +- supysonic/templates/change_pass.html | 52 ++-- supysonic/templates/change_username.html | 30 +- supysonic/templates/folders.html | 48 +-- supysonic/templates/home.html | 108 +++---- supysonic/templates/layout.html | 115 +++---- supysonic/templates/login.html | 52 ++-- supysonic/templates/playlist.html | 15 +- supysonic/templates/playlist_export.m3u | 4 +- supysonic/templates/playlists.html | 86 +++--- supysonic/templates/profile.html | 125 +++----- supysonic/templates/users.html | 38 ++- 28 files changed, 422 insertions(+), 762 deletions(-) delete mode 100644 supysonic/static/css/bootstrap-theme.min.css delete mode 100644 supysonic/static/css/bootstrap-theme.min.css.map delete mode 100644 supysonic/static/fonts/glyphicons-halflings-regular.eot delete mode 100644 supysonic/static/fonts/glyphicons-halflings-regular.svg delete mode 100644 supysonic/static/fonts/glyphicons-halflings-regular.ttf delete mode 100644 supysonic/static/fonts/glyphicons-halflings-regular.woff delete mode 100644 supysonic/static/fonts/glyphicons-halflings-regular.woff2 create mode 100644 supysonic/static/img/vinyl.svg create mode 100644 supysonic/static/js/bootstrap.bundle.min.js delete mode 100644 supysonic/static/js/bootstrap.min.js diff --git a/supysonic/static/css/bootstrap-theme.min.css b/supysonic/static/css/bootstrap-theme.min.css deleted file mode 100644 index 5e394019..00000000 --- a/supysonic/static/css/bootstrap-theme.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Bootstrap v3.3.7 (http://getbootstrap.com) - * Copyright 2011-2016 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.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:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} -/*# sourceMappingURL=bootstrap-theme.min.css.map */ \ No newline at end of file diff --git a/supysonic/static/css/bootstrap-theme.min.css.map b/supysonic/static/css/bootstrap-theme.min.css.map deleted file mode 100644 index 94813e90..00000000 --- a/supysonic/static/css/bootstrap-theme.min.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":"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","sourcesContent":["/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n .box-shadow(none);\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &,\n &:hover,\n &:focus,\n &.focus,\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n border-radius: @navbar-border-radius;\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} \ No newline at end of file diff --git a/supysonic/static/css/bootstrap.min.css b/supysonic/static/css/bootstrap.min.css index ed3905e0..39934146 100644 --- a/supysonic/static/css/bootstrap.min.css +++ b/supysonic/static/css/bootstrap.min.css @@ -1,6 +1,6 @@ -/*! - * Bootstrap v3.3.7 (http://getbootstrap.com) - * Copyright 2011-2016 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} /*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/supysonic/static/css/bootstrap.min.css.map b/supysonic/static/css/bootstrap.min.css.map index 6c7fa40b..90ce7987 100644 --- a/supysonic/static/css/bootstrap.min.css.map +++ b/supysonic/static/css/bootstrap.min.css.map @@ -1 +1 @@ -{"version":3,"sources":["less/normalize.less","less/print.less","bootstrap.css","dist/css/bootstrap.css","less/glyphicons.less","less/scaffolding.less","less/mixins/vendor-prefixes.less","less/mixins/tab-focus.less","less/mixins/image.less","less/type.less","less/mixins/text-emphasis.less","less/mixins/background-variant.less","less/mixins/text-overflow.less","less/code.less","less/grid.less","less/mixins/grid.less","less/mixins/grid-framework.less","less/tables.less","less/mixins/table-row.less","less/forms.less","less/mixins/forms.less","less/buttons.less","less/mixins/buttons.less","less/mixins/opacity.less","less/component-animations.less","less/dropdowns.less","less/mixins/nav-divider.less","less/mixins/reset-filter.less","less/button-groups.less","less/mixins/border-radius.less","less/input-groups.less","less/navs.less","less/navbar.less","less/mixins/nav-vertical-align.less","less/utilities.less","less/breadcrumbs.less","less/pagination.less","less/mixins/pagination.less","less/pager.less","less/labels.less","less/mixins/labels.less","less/badges.less","less/jumbotron.less","less/thumbnails.less","less/alerts.less","less/mixins/alerts.less","less/progress-bars.less","less/mixins/gradients.less","less/mixins/progress-bar.less","less/media.less","less/list-group.less","less/mixins/list-group.less","less/panels.less","less/mixins/panels.less","less/responsive-embed.less","less/wells.less","less/close.less","less/modals.less","less/tooltip.less","less/mixins/reset-text.less","less/popovers.less","less/carousel.less","less/mixins/clearfix.less","less/mixins/center-block.less","less/mixins/hide-text.less","less/responsive-utilities.less","less/mixins/responsive-visibility.less"],"names":[],"mappings":"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kDG,edokDH,MAAA,acpkDG,edukDH,MAAA,IcvkDG,ed0kDH,MAAA,ac1kDG,ed6kDH,MAAA,ac7kDG,edglDH,MAAA,IchlDG,edmlDH,MAAA,ac9kDG,edilDH,MAAA,YchmDG,edmmDH,MAAA,KcnmDG,gBdsmDH,KAAA,KctmDG,gBdymDH,KAAA,aczmDG,gBd4mDH,KAAA,ac5mDG,ed+mDH,KAAA,Ic/mDG,edknDH,KAAA,aclnDG,edqnDH,KAAA,acrnDG,edwnDH,KAAA,IcxnDG,ed2nDH,KAAA,ac3nDG,ed8nDH,KAAA,ac9nDG,edioDH,KAAA,IcjoDG,edooDH,KAAA,ac/nDG,edkoDH,KAAA,YcnnDG,edsnDH,KAAA,KctnDG,kBdynDH,YAAA,KcznDG,kBd4nDH,YAAA,ac5nDG,kBd+nDH,YAAA,ac/nDG,iBdkoDH,YAAA,IcloDG,iBdqoDH,YAAA,acroDG,iBdwoDH,YAAA,acxoDG,iBd2oDH,YAAA,Ic3oDG,iBd8oDH,YAAA,ac9oDG,iBdipDH,YAAA,acjpDG,iBdopDH,YAAA,IcppDG,iBdupDH,YAAA,acvpDG,iBd0pDH,YAAA,Yc5rDG,iBACE,YAAA,EAOJ,yBACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Ud0rDD,MAAA,Kc1rDC,Wd6rDD,MAAA,Kc7rDC,WdgsDD,MAAA,achsDC,WdmsDD,MAAA,acnsDC,UdssDD,MAAA,IctsDC,UdysDD,MAAA,aczsDC,Ud4sDD,MAAA,ac5sDC,Ud+sDD,MAAA,Ic/sDC,UdktDD,MAAA,acltDC,UdqtDD,MAAA,acrtDC,UdwtDD,MAAA,IcxtDC,Ud2tDD,MAAA,ac5sDC,Ud+sDD,MAAA,Yc/sDC,gBdktDD,MAAA,KcltDC,gBdqtDD,MAAA,acrtDC,gBdwtDD,MAAA,acxtDC,ed2tDD,MAAA,Ic3tDC,ed8tDD,MAAA,ac9tDC,ediuDD,MAAA,acjuDC,edouDD,MAAA,IcpuDC,eduuDD,MAAA,acvuDC,ed0uDD,MAAA,ac1uDC,ed6uDD,MAAA,Ic7uDC,edgvDD,MAAA,ac3uDC,ed8uDD,MAAA,Yc7vDC,edgwDD,MAAA,KchwDC,gBdmwDD,KAAA,KcnwDC,gBdswDD,KAAA,actwDC,gBdywDD,KAAA,aczwDC,ed4wDD,KAAA,Ic5wDC,ed+wDD,KAAA,ac/wDC,edkxDD,KAAA,aclxDC,edqxDD,KAAA,IcrxDC,edwxDD,KAAA,acxxDC,ed2xDD,KAAA,ac3xDC,ed8xDD,KAAA,Ic9xDC,ediyDD,KAAA,ac5xDC,ed+xDD,KAAA,YchxDC,edmxDD,KAAA,KcnxDC,kBdsxDD,YAAA,KctxDC,kBdyxDD,YAAA,aczxDC,kBd4xDD,YAAA,ac5xDC,iBd+xDD,YAAA,Ic/xDC,iBdkyDD,YAAA,aclyDC,iBdqyDD,YAAA,acryDC,iBdwyDD,YAAA,IcxyDC,iBd2yDD,YAAA,ac3yDC,iBd8yDD,YAAA,ac9yDC,iBdizDD,YAAA,IcjzDC,iBdozDD,YAAA,acpzDC,iBduzDD,YAAA,YY9yDD,iBE3CE,YAAA,GAQF,yBACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Udw1DD,MAAA,Kcx1DC,Wd21DD,MAAA,Kc31DC,Wd81DD,MAAA,ac91DC,Wdi2DD,MAAA,acj2DC,Udo2DD,MAAA,Icp2DC,Udu2DD,MAAA,acv2DC,Ud02DD,MAAA,ac12DC,Ud62DD,MAAA,Ic72DC,Udg3DD,MAAA,ach3DC,Udm3DD,MAAA,acn3DC,Uds3DD,MAAA,Ict3DC,Udy3DD,MAAA,ac12DC,Ud62DD,MAAA,Yc72DC,gBdg3DD,MAAA,Kch3DC,gBdm3DD,MAAA,acn3DC,gBds3DD,MAAA,act3DC,edy3DD,MAAA,Icz3DC,ed43DD,MAAA,ac53DC,ed+3DD,MAAA,ac/3DC,edk4DD,MAAA,Icl4DC,edq4DD,MAAA,acr4DC,edw4DD,MAAA,acx4DC,ed24DD,MAAA,Ic34DC,ed84DD,MAAA,acz4DC,ed44DD,MAAA,Yc35DC,ed85DD,MAAA,Kc95DC,gBdi6DD,KAAA,Kcj6DC,gBdo6DD,KAAA,acp6DC,gBdu6DD,KAAA,acv6DC,ed06DD,KAAA,Ic16DC,ed66DD,KAAA,ac76DC,edg7DD,KAAA,ach7DC,edm7DD,KAAA,Icn7DC,eds7DD,KAAA,act7DC,edy7DD,KAAA,acz7DC,ed47DD,KAAA,Ic57DC,ed+7DD,KAAA,ac17DC,ed67DD,KAAA,Yc96DC,edi7DD,KAAA,Kcj7DC,kBdo7DD,YAAA,Kcp7DC,kBdu7DD,YAAA,acv7DC,kBd07DD,YAAA,ac17DC,iBd67DD,YAAA,Ic77DC,iBdg8DD,YAAA,ach8DC,iBdm8DD,YAAA,acn8DC,iBds8DD,YAAA,Ict8DC,iBdy8DD,YAAA,acz8DC,iBd48DD,YAAA,ac58DC,iBd+8DD,YAAA,Ic/8DC,iBdk9DD,YAAA,acl9DC,iBdq9DD,YAAA,YYz8DD,iBE9CE,YAAA,GAQF,0BACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Uds/DD,MAAA,Kct/DC,Wdy/DD,MAAA,Kcz/DC,Wd4/DD,MAAA,ac5/DC,Wd+/DD,MAAA,ac//DC,UdkgED,MAAA,IclgEC,UdqgED,MAAA,acrgEC,UdwgED,MAAA,acxgEC,Ud2gED,MAAA,Ic3gEC,Ud8gED,MAAA,ac9gEC,UdihED,MAAA,acjhEC,UdohED,MAAA,IcphEC,UduhED,MAAA,acxgEC,Ud2gED,MAAA,Yc3gEC,gBd8gED,MAAA,Kc9gEC,gBdihED,MAAA,acjhEC,gBdohED,MAAA,acphEC,eduhED,MAAA,IcvhEC,ed0hED,MAAA,ac1hEC,ed6hED,MAAA,ac7hEC,edgiED,MAAA,IchiEC,edmiED,MAAA,acniEC,edsiED,MAAA,actiEC,edyiED,MAAA,IcziEC,ed4iED,MAAA,acviEC,ed0iED,MAAA,YczjEC,ed4jED,MAAA,Kc5jEC,gBd+jED,KAAA,Kc/jEC,gBdkkED,KAAA,aclkEC,gBdqkED,KAAA,acrkEC,edwkED,KAAA,IcxkEC,ed2kED,KAAA,ac3kEC,ed8kED,KAAA,ac9kEC,edilED,KAAA,IcjlEC,edolED,KAAA,acplEC,edulED,KAAA,acvlEC,ed0lED,KAAA,Ic1lEC,ed6lED,KAAA,acxlEC,ed2lED,KAAA,Yc5kEC,ed+kED,KAAA,Kc/kEC,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A,KlBq7FM,WAAY,KkBn7FhB,QAAA,IASN,eC3DE,yBACA,eAAA,KpBi+FD,aoB99FC,MAAA,KnB0+FA,iBAAkB,KmBx+FhB,aAAA,KpBk+FH,mBoBh+FO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBi+FH,mBoB99FC,MAAA,KnB0+FA,iBAAkB,QAClB,aAAc,QmBt+FR,oBADJ,oBpBi+FH,mCoB99FG,MAAA,KnB0+FF,iBAAkB,QAClB,aAAc,QmBt+FN,0BnB4+FV,0BAHA,0BmB1+FM,0BnB4+FN,0BAHA,0BDFC,yCoBx+FK,yCnB4+FN,yCmBv+FE,MAAA,KnB++FA,iBAAkB,QAClB,aAAc,QmBx+FZ,oBpBg+FH,oBoBh+FG,mCnB6+FF,iBAAkB,KmBz+FV,4BnB8+FV,4BAHA,4BDHC,6BCOD,6BAHA,6BkB39FA,sCClBM,sCnB8+FN,sCmBx+FI,iBAAA,KACA,aAAA,KDcJ,oBC9DE,MAAA,KACA,iBAAA,KpB0hGD,aoBvhGC,MAAA,KnBmiGA,iBAAkB,QmBjiGhB,aAAA,QpB2hGH,mBoBzhGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB0hGH,mBoBvhGC,MAAA,KnBmiGA,iBAAkB,QAClB,aAAc,QmB/hGR,oBADJ,oBpB0hGH,mCoBvhGG,MAAA,KnBmiGF,iBAAkB,QAClB,aAAc,QmB/hGN,0BnBqiGV,0BAHA,0BmBniGM,0BnBqiGN,0BAHA,0BDFC,yCoBjiGK,yCnBqiGN,yCmBhiGE,MAAA,KnBwiGA,iBAAkB,QAClB,aAAc,QmBjiGZ,oBpByhGH,oBoBzhGG,mCnBsiGF,iBAAkB,KmBliGV,4BnBuiGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBjhGA,sCCrBM,sCnBuiGN,sCmBjiGI,iBAAA,QACA,aAAA,QDkBJ,oBClEE,MAAA,QACA,iBAAA,KpBmlGD,aoBhlGC,MAAA,KnB4lGA,iBAAkB,QmB1lGhB,aAAA,QpBolGH,mBoBllGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBmlGH,mBoBhlGC,MAAA,KnB4lGA,iBAAkB,QAClB,aAAc,QmBxlGR,oBADJ,oBpBmlGH,mCoBhlGG,MAAA,KnB4lGF,iBAAkB,QAClB,aAAc,QmBxlGN,0BnB8lGV,0BAHA,0BmB5lGM,0BnB8lGN,0BAHA,0BDFC,yCoB1lGK,yCnB8lGN,yCmBzlGE,MAAA,KnBimGA,iBAAkB,QAClB,aAAc,QmB1lGZ,oBpBklGH,oBoBllGG,mCnB+lGF,iBAAkB,KmB3lGV,4BnBgmGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBtkGA,sCCzBM,sCnBgmGN,sCmB1lGI,iBAAA,QACA,aAAA,QDsBJ,oBCtEE,MAAA,QACA,iBAAA,KpB4oGD,UoBzoGC,MAAA,KnBqpGA,iBAAkB,QmBnpGhB,aAAA,QpB6oGH,gBoB3oGO,gBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB4oGH,gBoBzoGC,MAAA,KnBqpGA,iBAAkB,QAClB,aAAc,QmBjpGR,iBADJ,iBpB4oGH,gCoBzoGG,MAAA,KnBqpGF,iBAAkB,QAClB,aAAc,QmBjpGN,uBnBupGV,uBAHA,uBmBrpGM,uBnBupGN,uBAHA,uBDFC,sCoBnpGK,sCnBupGN,sCmBlpGE,MAAA,KnB0pGA,iBAAkB,QAClB,aAAc,QmBnpGZ,iBpB2oGH,iBoB3oGG,gCnBwpGF,iBAAkB,KmBppGV,yBnBypGV,yBAHA,yBDHC,0BCOD,0BAHA,0BkB3nGA,mCC7BM,mCnBypGN,mCmBnpGI,iBAAA,QACA,aAAA,QD0BJ,iBC1EE,MAAA,QACA,iBAAA,KpBqsGD,aoBlsGC,MAAA,KnB8sGA,iBAAkB,QmB5sGhB,aAAA,QpBssGH,mBoBpsGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBqsGH,mBoBlsGC,MAAA,KnB8sGA,iBAAkB,QAClB,aAAc,QmB1sGR,oBADJ,oBpBqsGH,mCoBlsGG,MAAA,KnB8sGF,iBAAkB,QAClB,aAAc,QmB1sGN,0BnBgtGV,0BAHA,0BmB9sGM,0BnBgtGN,0BAHA,0BDFC,yCoB5sGK,yCnBgtGN,yCmB3sGE,MAAA,KnBmtGA,iBAAkB,QAClB,aAAc,QmB5sGZ,oBpBosGH,oBoBpsGG,mCnBitGF,iBAAkB,KmB7sGV,4BnBktGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBhrGA,sCCjC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A,QkDrxKC,WAAY,IAAI,MAAM,KjDizKtB,2BAA4B,IiD9yK1B,0BAAA,IAHJ,mBAAA,mCAMM,cAAA,ElDwxKL,oCkDnxKG,oDjD+yKF,aAAc,IAAI,EiD7yKZ,cAAA,EvBtEL,4D3B61KF,4EkDjxKG,WAAA,EjD6yKF,uBAAwB,IiD3yKlB,wBAAA,IvBtEL,0D3B21KF,0EkD1yKC,cAAe,EvB1Df,2BAAA,IACC,0BAAA,IuB0FH,+EAEI,uBAAA,ElD8wKH,wBAAA,EkD1wKC,wDlD6wKD,iBAAA,EC4BD,0BACE,iBAAkB,EiDlyKpB,8BlD0wKC,ckD1wKD,gCjDuyKE,cAAe,EiDvyKjB,sCAQM,sBlDwwKL,wCC4BC,cAAe,K0Br5Kf,aAAA,KuByGF,wDlDqxKC,0BC4BC,uBAAwB,IACxB,wBAAyB,IiDlzK3B,yFAoBQ,yFlDwwKP,2DkDzwKO,2DjDqyKN,uBAAwB,IACxB,wBAAyB,IAK3B,wGiD9zKA,wGjD4zKA,wGDtBC,wGCuBD,0EiD7zKA,0EjD2zKA,0EiDnyKU,0EjD2yKR,uBAAwB,IAK1B,uGiDx0KA,uGjDs0KA,uGDtBC,uGCuBD,yEiDv0KA,yEjDq0KA,yEiDzyKU,yEvB7HR,wBAAA,IuBiGF,sDlDqzKC,yBC4BC,2BAA4B,IAC5B,0BAA2B,IiDxyKrB,qFA1CR,qFAyCQ,wDlDmxKP,wDC4BC,2BAA4B,IAC5B,0BAA2B,IAG7B,oGDtBC,oGCwBD,oGiD91KA,oGjD21KA,uEiD7yKU,uEjD+yKV,uEiD71KA,uEjDm2KE,0BAA2B,IAG7B,mGDtBC,mGCwBD,mGiDx2KA,mGjDq2KA,sEiDnzKU,sEjDqzKV,sEiDv2KA,sEjD62KE,2BAA4B,IiDlzK1B,0BlD2xKH,qCkDt1KD,0BAAA,qCA+DI,WAAA,IAAA,MAAA,KA/DJ,kDAAA,kDAmEI,WAAA,EAnEJ,uBAAA,yCjD23KE,OAAQ,EiDjzKA,+CjDqzKV,+CiD/3KA,+CjDi4KA,+CAEA,+CANA,+CDjBC,iECoBD,iEiDh4KA,iEjDk4KA,iEAEA,iEANA,iEAWE,YAAa,EiD3zKL,8CjD+zKV,8CiD74KA,8CjD+4KA,8CAEA,8CANA,8CDjBC,gECo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Dk2MD,kBACF,QAAA,iB+D91MG,yB/Dk2MD,mBACF,QAAA,kBgEh6MC,yBhEo6MC,yBgEn6MD,QAAA,wBACA,+CAAU,YhEw6MT,QAAA,gBC4BD,iB+Dl8MU,QAAA,gBhE26MX,c+Dr2MG,QAAS,oB/Dy2MV,c+D32MC,c/D42MH,QAAA,sB+Dv2MG,+C/D22MD,kBACF,QAAA,iB+Dv2MG,+C/D22MD,mBACF,QAAA,kBgE97MC,+ChEk8MC,yBgEj8MD,QAAA,wBACA,gDAAU,YhEs8MT,QAAA,gBC4BD,iB+Dh+MU,QAAA,gBhEy8MX,c+D92MG,QAAS,oB/Dk3MV,c+Dp3MC,c/Dq3MH,QAAA,sB+Dh3MG,gD/Do3MD,kBACF,QAAA,iB+Dh3MG,gD/Do3MD,mBACF,QAAA,kBgE59MC,gDhEg+MC,yBgE/9MD,QAAA,wBACA,0BAAU,YhEo+MT,QAAA,gBC4BD,iB+D9/MU,QAAA,gBhEu+MX,c+Dv3MG,QAAS,oB/D23MV,c+D73MC,c/D83MH,QAAA,sB+Dz3MG,0B/D63MD,kBACF,QAAA,iB+Dz3MG,0B/D63MD,mBACF,QAAA,kBgEl/MC,0BhEs/MC,yBACF,QAAA,wBgEv/MC,yBhE2/MC,WACF,QAAA,gBgE5/MC,+ChEggNC,WACF,QAAA,gBgEjgNC,gDhEqgNC,WACF,QAAA,gBAGA,0B+Dh3MC,WA4BE,QAAS,gBC5LX,eAAU,QAAA,eACV,aAAU,ehEyhNT,QAAA,gBC4BD,oB+DnjNU,QAAA,gBhE4hNX,iB+D93MG,QAAS,oBAMX,iB/D23MD,iB+Dt2MG,QAAS,sB/D22MZ,qB+D/3MC,QAAS,e/Dk4MV,a+D53MC,qBAcE,QAAS,iB/Dm3MZ,sB+Dh4MC,QAAS,e/Dm4MV,a+D73MC,sBAOE,QAAS,kB/D23MZ,4B+D53MC,QAAS,eCpLT,ahEojNC,4BACF,QAAA,wBC6BD,aACE,cACE,QAAS","sourcesContent":["/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\n\n//\n// 1. Set default font family to sans-serif.\n// 2. Prevent iOS and IE text size adjust after device orientation change,\n// without disabling user zoom.\n//\n\nhtml {\n font-family: sans-serif; // 1\n -ms-text-size-adjust: 100%; // 2\n -webkit-text-size-adjust: 100%; // 2\n}\n\n//\n// Remove default margin.\n//\n\nbody {\n margin: 0;\n}\n\n// HTML5 display definitions\n// ==========================================================================\n\n//\n// Correct `block` display not defined for any HTML5 element in IE 8/9.\n// Correct `block` display not defined for `details` or `summary` in IE 10/11\n// and Firefox.\n// Correct `block` display not defined for `main` in IE 11.\n//\n\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\n\n//\n// 1. Correct `inline-block` display not defined in IE 8/9.\n// 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.\n//\n\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block; // 1\n vertical-align: baseline; // 2\n}\n\n//\n// Prevent modern browsers from displaying `audio` without controls.\n// Remove excess height in iOS 5 devices.\n//\n\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n\n//\n// Address `[hidden]` styling not present in IE 8/9/10.\n// Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.\n//\n\n[hidden],\ntemplate {\n display: none;\n}\n\n// Links\n// ==========================================================================\n\n//\n// Remove the gray background color from active links in IE 10.\n//\n\na {\n background-color: transparent;\n}\n\n//\n// Improve readability of focused elements when they are also in an\n// active/hover state.\n//\n\na:active,\na:hover {\n outline: 0;\n}\n\n// Text-level semantics\n// ==========================================================================\n\n//\n// Address styling not present in IE 8/9/10/11, Safari, and Chrome.\n//\n\nabbr[title] {\n border-bottom: 1px dotted;\n}\n\n//\n// Address style set to `bolder` in Firefox 4+, Safari, and Chrome.\n//\n\nb,\nstrong {\n font-weight: bold;\n}\n\n//\n// Address styling not present in Safari and Chrome.\n//\n\ndfn {\n font-style: italic;\n}\n\n//\n// Address variable `h1` font-size and margin within `section` and `article`\n// contexts in Firefox 4+, Safari, and Chrome.\n//\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n//\n// Address styling not present in IE 8/9.\n//\n\nmark {\n background: #ff0;\n color: #000;\n}\n\n//\n// Address inconsistent and variable font size in all browsers.\n//\n\nsmall {\n font-size: 80%;\n}\n\n//\n// Prevent `sub` and `sup` affecting `line-height` in all browsers.\n//\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsup {\n top: -0.5em;\n}\n\nsub {\n bottom: -0.25em;\n}\n\n// Embedded content\n// ==========================================================================\n\n//\n// Remove border when inside `a` element in IE 8/9/10.\n//\n\nimg {\n border: 0;\n}\n\n//\n// Correct overflow not hidden in IE 9/10/11.\n//\n\nsvg:not(:root) {\n overflow: hidden;\n}\n\n// Grouping content\n// ==========================================================================\n\n//\n// Address margin not present in IE 8/9 and Safari.\n//\n\nfigure {\n margin: 1em 40px;\n}\n\n//\n// Address differences between Firefox and other browsers.\n//\n\nhr {\n box-sizing: content-box;\n height: 0;\n}\n\n//\n// Contain overflow in all browsers.\n//\n\npre {\n overflow: auto;\n}\n\n//\n// Address odd `em`-unit font size rendering in all browsers.\n//\n\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\n\n// Forms\n// ==========================================================================\n\n//\n// Known limitation: by default, Chrome and Safari on OS X allow very limited\n// styling of `select`, unless a `border` property is set.\n//\n\n//\n// 1. Correct color not being inherited.\n// Known issue: affects color of disabled elements.\n// 2. Correct font properties not being inherited.\n// 3. Address margins set differently in Firefox 4+, Safari, and Chrome.\n//\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit; // 1\n font: inherit; // 2\n margin: 0; // 3\n}\n\n//\n// Address `overflow` set to `hidden` in IE 8/9/10/11.\n//\n\nbutton {\n overflow: visible;\n}\n\n//\n// Address inconsistent `text-transform` inheritance for `button` and `select`.\n// All other form control elements do not inherit `text-transform` values.\n// Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.\n// Correct `select` style inheritance in Firefox.\n//\n\nbutton,\nselect {\n text-transform: none;\n}\n\n//\n// 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`\n// and `video` controls.\n// 2. Correct inability to style clickable `input` types in iOS.\n// 3. Improve usability and consistency of cursor style between image-type\n// `input` and others.\n//\n\nbutton,\nhtml input[type=\"button\"], // 1\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button; // 2\n cursor: pointer; // 3\n}\n\n//\n// Re-set default cursor for disabled elements.\n//\n\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\n\n//\n// Remove inner padding and border in Firefox 4+.\n//\n\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\n\n//\n// Address Firefox 4+ setting `line-height` on `input` using `!important` in\n// the UA stylesheet.\n//\n\ninput {\n line-height: normal;\n}\n\n//\n// It's recommended that you don't attempt to style these elements.\n// Firefox's implementation doesn't respect box-sizing, padding, or width.\n//\n// 1. Address box sizing set to `content-box` in IE 8/9/10.\n// 2. Remove excess padding in IE 8/9/10.\n//\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box; // 1\n padding: 0; // 2\n}\n\n//\n// Fix the cursor style for Chrome's increment/decrement buttons. For certain\n// `font-size` values of the `input`, it causes the cursor style of the\n// decrement button to change from `default` to `text`.\n//\n\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n//\n// 1. Address `appearance` set to `searchfield` in Safari and Chrome.\n// 2. Address `box-sizing` set to `border-box` in Safari and Chrome.\n//\n\ninput[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n box-sizing: content-box; //2\n}\n\n//\n// Remove inner padding and search cancel button in Safari and Chrome on OS X.\n// Safari (but not Chrome) clips the cancel button when the search input has\n// padding (and `textfield` appearance).\n//\n\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// Define consistent border, margin, and padding.\n//\n\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\n\n//\n// 1. Correct `color` not being inherited in IE 8/9/10/11.\n// 2. Remove padding so people aren't caught out if they zero out fieldsets.\n//\n\nlegend {\n border: 0; // 1\n padding: 0; // 2\n}\n\n//\n// Remove default vertical scrollbar in IE 8/9/10/11.\n//\n\ntextarea {\n overflow: auto;\n}\n\n//\n// Don't inherit the `font-weight` (applied by a rule above).\n// NOTE: the default cannot safely be changed in Chrome and Safari on OS X.\n//\n\noptgroup {\n font-weight: bold;\n}\n\n// Tables\n// ==========================================================================\n\n//\n// Remove most spacing between table cells.\n//\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\ntd,\nth {\n padding: 0;\n}\n","/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n\n// ==========================================================================\n// Print styles.\n// Inlined to avoid the additional HTTP request: h5bp.com/r\n// ==========================================================================\n\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important; // Black prints faster: h5bp.com/s\n box-shadow: none !important;\n text-shadow: none !important;\n }\n\n a,\n a:visited {\n text-decoration: underline;\n }\n\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n\n // Don't show links that are fragment identifiers,\n // or use the `javascript:` pseudo protocol\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n\n thead {\n display: table-header-group; // h5bp.com/t\n }\n\n tr,\n img {\n page-break-inside: avoid;\n }\n\n img {\n max-width: 100% !important;\n }\n\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n\n h2,\n h3 {\n page-break-after: avoid;\n }\n\n // Bootstrap specific changes start\n\n // Bootstrap components\n .navbar {\n display: none;\n }\n .btn,\n .dropup > .btn {\n > .caret {\n border-top-color: #000 !important;\n }\n }\n .label {\n border: 1px solid #000;\n }\n\n .table {\n border-collapse: collapse !important;\n\n td,\n th {\n background-color: #fff !important;\n }\n }\n .table-bordered {\n th,\n td {\n border: 1px solid #ddd !important;\n }\n }\n\n // Bootstrap specific changes end\n}\n","/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\nhtml {\n font-family: sans-serif;\n -ms-text-size-adjust: 100%;\n -webkit-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\nmark {\n background: #ff0;\n color: #000;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsup {\n top: -0.5em;\n}\nsub {\n bottom: -0.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n box-sizing: content-box;\n height: 0;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit;\n font: inherit;\n margin: 0;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: textfield;\n box-sizing: content-box;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\nlegend {\n border: 0;\n padding: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important;\n box-shadow: none !important;\n text-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\002a\";\n}\n.glyphicon-plus:before {\n content: \"\\002b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-btc:before {\n content: \"\\e227\";\n}\n.glyphicon-xbt:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-jpy:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-rub:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333333;\n background-color: #fff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n padding: 4px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: all 0.2s ease-in-out;\n -o-transition: all 0.2s ease-in-out;\n transition: all 0.2s ease-in-out;\n display: inline-block;\n max-width: 100%;\n height: auto;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eeeeee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\n[role=\"button\"] {\n cursor: pointer;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n background-color: #fcf8e3;\n padding: .2em;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover,\na.text-primary:focus {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover,\na.text-success:focus {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover,\na.text-info:focus {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover,\na.text-warning:focus {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover,\na.text-danger:focus {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover,\na.bg-primary:focus {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover,\na.bg-success:focus {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover,\na.bg-info:focus {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover,\na.bg-warning:focus {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover,\na.bg-danger:focus {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eeeeee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n list-style: none;\n margin-left: -5px;\n}\n.list-inline > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n clear: left;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eeeeee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid #eeeeee;\n border-left: 0;\n text-align: right;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #fff;\n background-color: #333;\n border-radius: 3px;\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n word-break: break-all;\n word-wrap: break-word;\n color: #333333;\n background-color: #f5f5f5;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n.row {\n margin-left: -15px;\n margin-right: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0%;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0%;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #ddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #ddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #ddd;\n}\n.table .table {\n background-color: #fff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-column;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-cell;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #ddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n min-width: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n background-color: #fff;\n background-image: none;\n border: 1px solid #ccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n}\n.form-control::-moz-placeholder {\n color: #999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999;\n}\n.form-control::-ms-expand {\n border: 0;\n background-color: transparent;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n background-color: #eeeeee;\n opacity: 1;\n}\n.form-control[disabled],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"].form-control,\n input[type=\"time\"].form-control,\n input[type=\"datetime-local\"].form-control,\n input[type=\"month\"].form-control {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n min-height: 34px;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-left: 0;\n padding-right: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.form-group-sm select.form-control {\n height: 30px;\n line-height: 30px;\n}\n.form-group-sm textarea.form-control,\n.form-group-sm select[multiple].form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n min-height: 32px;\n padding: 6px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.form-group-lg select.form-control {\n height: 46px;\n line-height: 46px;\n}\n.form-group-lg textarea.form-control,\n.form-group-lg select[multiple].form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n min-height: 38px;\n padding: 11px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n border-color: #3c763d;\n background-color: #dff0d8;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n border-color: #8a6d3b;\n background-color: #fcf8e3;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n border-color: #a94442;\n background-color: #f2dede;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n margin-top: 0;\n margin-bottom: 0;\n padding-top: 7px;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-left: -15px;\n margin-right: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n text-align: right;\n margin-bottom: 0;\n padding-top: 7px;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 11px;\n font-size: 18px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n font-size: 12px;\n }\n}\n.btn {\n display: inline-block;\n margin-bottom: 0;\n font-weight: normal;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none;\n border: 1px solid transparent;\n white-space: nowrap;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n border-radius: 4px;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n outline: 0;\n background-image: none;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n opacity: 0.65;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n}\na.btn.disabled,\nfieldset[disabled] a.btn {\n pointer-events: none;\n}\n.btn-default {\n color: #333;\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default:focus,\n.btn-default.focus {\n color: #333;\n background-color: #e6e6e6;\n border-color: #8c8c8c;\n}\n.btn-default:hover {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active:hover,\n.btn-default.active:hover,\n.open > .dropdown-toggle.btn-default:hover,\n.btn-default:active:focus,\n.btn-default.active:focus,\n.open > .dropdown-toggle.btn-default:focus,\n.btn-default:active.focus,\n.btn-default.active.focus,\n.open > .dropdown-toggle.btn-default.focus {\n color: #333;\n background-color: #d4d4d4;\n border-color: #8c8c8c;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus {\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default .badge {\n color: #fff;\n background-color: #333;\n}\n.btn-primary {\n color: #fff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:focus,\n.btn-primary.focus {\n color: #fff;\n background-color: #286090;\n border-color: #122b40;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active:hover,\n.btn-primary.active:hover,\n.open > .dropdown-toggle.btn-primary:hover,\n.btn-primary:active:focus,\n.btn-primary.active:focus,\n.open > .dropdown-toggle.btn-primary:focus,\n.btn-primary:active.focus,\n.btn-primary.active.focus,\n.open > .dropdown-toggle.btn-primary.focus {\n color: #fff;\n background-color: #204d74;\n border-color: #122b40;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.btn-success {\n color: #fff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:focus,\n.btn-success.focus {\n color: #fff;\n background-color: #449d44;\n border-color: #255625;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active:hover,\n.btn-success.active:hover,\n.open > .dropdown-toggle.btn-success:hover,\n.btn-success:active:focus,\n.btn-success.active:focus,\n.open > .dropdown-toggle.btn-success:focus,\n.btn-success:active.focus,\n.btn-success.active.focus,\n.open > .dropdown-toggle.btn-success.focus {\n color: #fff;\n background-color: #398439;\n border-color: #255625;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #fff;\n}\n.btn-info {\n color: #fff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:focus,\n.btn-info.focus {\n color: #fff;\n background-color: #31b0d5;\n border-color: #1b6d85;\n}\n.btn-info:hover {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active:hover,\n.btn-info.active:hover,\n.open > .dropdown-toggle.btn-info:hover,\n.btn-info:active:focus,\n.btn-info.active:focus,\n.open > .dropdown-toggle.btn-info:focus,\n.btn-info:active.focus,\n.btn-info.active.focus,\n.open > .dropdown-toggle.btn-info.focus {\n color: #fff;\n background-color: #269abc;\n border-color: #1b6d85;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #fff;\n}\n.btn-warning {\n color: #fff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:focus,\n.btn-warning.focus {\n color: #fff;\n background-color: #ec971f;\n border-color: #985f0d;\n}\n.btn-warning:hover {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active:hover,\n.btn-warning.active:hover,\n.open > .dropdown-toggle.btn-warning:hover,\n.btn-warning:active:focus,\n.btn-warning.active:focus,\n.open > .dropdown-toggle.btn-warning:focus,\n.btn-warning:active.focus,\n.btn-warning.active.focus,\n.open > .dropdown-toggle.btn-warning.focus {\n color: #fff;\n background-color: #d58512;\n border-color: #985f0d;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #fff;\n}\n.btn-danger {\n color: #fff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:focus,\n.btn-danger.focus {\n color: #fff;\n background-color: #c9302c;\n border-color: #761c19;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active:hover,\n.btn-danger.active:hover,\n.open > .dropdown-toggle.btn-danger:hover,\n.btn-danger:active:focus,\n.btn-danger.active:focus,\n.open > .dropdown-toggle.btn-danger:focus,\n.btn-danger:active.focus,\n.btn-danger.active.focus,\n.open > .dropdown-toggle.btn-danger.focus {\n color: #fff;\n background-color: #ac2925;\n border-color: #761c19;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #fff;\n}\n.btn-link {\n color: #337ab7;\n font-weight: normal;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity 0.15s linear;\n -o-transition: opacity 0.15s linear;\n transition: opacity 0.15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n}\n.collapse.in {\n display: block;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-property: height, visibility;\n transition-property: height, visibility;\n -webkit-transition-duration: 0.35s;\n transition-duration: 0.35s;\n -webkit-transition-timing-function: ease;\n transition-timing-function: ease;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px dashed;\n border-top: 4px solid \\9;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n list-style: none;\n font-size: 14px;\n text-align: left;\n background-color: #fff;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n background-clip: padding-box;\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n text-decoration: none;\n color: #262626;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n background-color: #337ab7;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n cursor: not-allowed;\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n left: auto;\n right: 0;\n}\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n border-top: 0;\n border-bottom: 4px dashed;\n border-bottom: 4px solid \\9;\n content: \"\";\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n left: auto;\n right: 0;\n }\n .navbar-right .dropdown-menu-left {\n left: 0;\n right: auto;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn,\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group .form-control:focus {\n z-index: 3;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555555;\n text-align: center;\n background-color: #eeeeee;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n z-index: 2;\n margin-left: -1px;\n}\n.nav {\n margin-bottom: 0;\n padding-left: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.nav > li.disabled > a {\n color: #777777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777777;\n text-decoration: none;\n background-color: transparent;\n cursor: not-allowed;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eeeeee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #ddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eeeeee #eeeeee #ddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555555;\n background-color: #fff;\n border: 1px solid #ddd;\n border-bottom-color: transparent;\n cursor: default;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #fff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n overflow-x: visible;\n padding-right: 15px;\n padding-left: 15px;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);\n -webkit-overflow-scrolling: touch;\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-left: 0;\n padding-right: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n height: 50px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: 15px;\n padding: 9px 10px;\n margin-top: 8px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n margin-left: -15px;\n margin-right: -15px;\n padding: 10px 15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n margin-top: 8px;\n margin-bottom: 8px;\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-left: 15px;\n margin-right: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #ddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #ddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n background-color: #e7e7e7;\n color: #555;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777;\n}\n.navbar-default .navbar-link:hover {\n color: #333;\n}\n.navbar-default .btn-link {\n color: #777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #ccc;\n}\n.navbar-inverse {\n background-color: #222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #fff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #fff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n background-color: #080808;\n color: #fff;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #fff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #fff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #fff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n content: \"/\\00a0\";\n padding: 0 5px;\n color: #ccc;\n}\n.breadcrumb > .active {\n color: #777777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n line-height: 1.42857143;\n text-decoration: none;\n color: #337ab7;\n background-color: #fff;\n border: 1px solid #ddd;\n margin-left: -1px;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-bottom-left-radius: 4px;\n border-top-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-bottom-right-radius: 4px;\n border-top-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n z-index: 2;\n color: #23527c;\n background-color: #eeeeee;\n border-color: #ddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 3;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n cursor: default;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777777;\n background-color: #fff;\n border-color: #ddd;\n cursor: not-allowed;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-bottom-left-radius: 6px;\n border-top-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-bottom-right-radius: 6px;\n border-top-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-bottom-left-radius: 3px;\n border-top-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-bottom-right-radius: 3px;\n border-top-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n list-style: none;\n text-align: center;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777777;\n background-color: #fff;\n cursor: not-allowed;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n color: #fff;\n line-height: 1;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: #777777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge,\n.btn-group-xs > .btn .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding-top: 30px;\n padding-bottom: 30px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eeeeee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n border-radius: 6px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding-top: 48px;\n padding-bottom: 48px;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-left: 60px;\n padding-right: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: border 0.2s ease-in-out;\n -o-transition: border 0.2s ease-in-out;\n transition: border 0.2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-left: auto;\n margin-right: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n background-color: #dff0d8;\n border-color: #d6e9c6;\n color: #3c763d;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n background-color: #d9edf7;\n border-color: #bce8f1;\n color: #31708f;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n background-color: #fcf8e3;\n border-color: #faebcc;\n color: #8a6d3b;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n background-color: #f2dede;\n border-color: #ebccd1;\n color: #a94442;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n overflow: hidden;\n height: 20px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #fff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n -webkit-transition: width 0.6s ease;\n -o-transition: width 0.6s ease;\n transition: width 0.6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-object.img-thumbnail {\n max-width: none;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n margin-bottom: 20px;\n padding-left: 0;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.list-group-item:first-child {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item,\nbutton.list-group-item {\n color: #555;\n}\na.list-group-item .list-group-item-heading,\nbutton.list-group-item .list-group-item-heading {\n color: #333;\n}\na.list-group-item:hover,\nbutton.list-group-item:hover,\na.list-group-item:focus,\nbutton.list-group-item:focus {\n text-decoration: none;\n color: #555;\n background-color: #f5f5f5;\n}\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n background-color: #eeeeee;\n color: #777777;\n cursor: not-allowed;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success,\nbutton.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading,\nbutton.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\nbutton.list-group-item-success:hover,\na.list-group-item-success:focus,\nbutton.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\nbutton.list-group-item-success.active,\na.list-group-item-success.active:hover,\nbutton.list-group-item-success.active:hover,\na.list-group-item-success.active:focus,\nbutton.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info,\nbutton.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading,\nbutton.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\nbutton.list-group-item-info:hover,\na.list-group-item-info:focus,\nbutton.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\nbutton.list-group-item-info.active,\na.list-group-item-info.active:hover,\nbutton.list-group-item-info.active:hover,\na.list-group-item-info.active:focus,\nbutton.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning,\nbutton.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading,\nbutton.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\nbutton.list-group-item-warning:hover,\na.list-group-item-warning:focus,\nbutton.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\nbutton.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\nbutton.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus,\nbutton.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger,\nbutton.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading,\nbutton.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\nbutton.list-group-item-danger:hover,\na.list-group-item-danger:focus,\nbutton.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\nbutton.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\nbutton.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus,\nbutton.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #fff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #ddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-left: 15px;\n padding-right: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-left-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #ddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n border: 0;\n margin-bottom: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #ddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #ddd;\n}\n.panel-default {\n border-color: #ddd;\n}\n.panel-default > .panel-heading {\n color: #333333;\n background-color: #f5f5f5;\n border-color: #ddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n}\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, 0.15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n opacity: 0.2;\n filter: alpha(opacity=20);\n}\n.close:hover,\n.close:focus {\n color: #000;\n text-decoration: none;\n cursor: pointer;\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\nbutton.close {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n -webkit-transition: -webkit-transform 0.3s ease-out;\n -moz-transition: -moz-transform 0.3s ease-out;\n -o-transition: -o-transform 0.3s ease-out;\n transition: transform 0.3s ease-out;\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #fff;\n border: 1px solid #999;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n background-clip: padding-box;\n outline: 0;\n}\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n.modal-backdrop.fade {\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.modal-backdrop.in {\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 12px;\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.tooltip.in {\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.tooltip.top {\n margin-top: -3px;\n padding: 5px 0;\n}\n.tooltip.right {\n margin-left: 3px;\n padding: 0 5px;\n}\n.tooltip.bottom {\n margin-top: 3px;\n padding: 5px 0;\n}\n.tooltip.left {\n margin-left: -3px;\n padding: 0 5px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-left .tooltip-arrow {\n bottom: 0;\n right: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 14px;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n margin: 0;\n padding: 8px 14px;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n border-width: 10px;\n content: \"\";\n}\n.popover.top > .arrow {\n left: 50%;\n margin-left: -11px;\n border-bottom-width: 0;\n border-top-color: #999999;\n border-top-color: rgba(0, 0, 0, 0.25);\n bottom: -11px;\n}\n.popover.top > .arrow:after {\n content: \" \";\n bottom: 1px;\n margin-left: -10px;\n border-bottom-width: 0;\n border-top-color: #fff;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-left-width: 0;\n border-right-color: #999999;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n.popover.right > .arrow:after {\n content: \" \";\n left: 1px;\n bottom: -10px;\n border-left-width: 0;\n border-right-color: #fff;\n}\n.popover.bottom > .arrow {\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999999;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n top: -11px;\n}\n.popover.bottom > .arrow:after {\n content: \" \";\n top: 1px;\n margin-left: -10px;\n border-top-width: 0;\n border-bottom-color: #fff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999999;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n.popover.left > .arrow:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: #fff;\n bottom: -10px;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n}\n.carousel-inner > .item {\n display: none;\n position: relative;\n -webkit-transition: 0.6s ease-in-out left;\n -o-transition: 0.6s ease-in-out left;\n transition: 0.6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform 0.6s ease-in-out;\n -moz-transition: -moz-transform 0.6s ease-in-out;\n -o-transition: -o-transform 0.6s ease-in-out;\n transition: transform 0.6s ease-in-out;\n -webkit-backface-visibility: hidden;\n -moz-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000px;\n -moz-perspective: 1000px;\n perspective: 1000px;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n left: 0;\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 15%;\n opacity: 0.5;\n filter: alpha(opacity=50);\n font-size: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n}\n.carousel-control.right {\n left: auto;\n right: 0;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n}\n.carousel-control:hover,\n.carousel-control:focus {\n outline: 0;\n color: #fff;\n text-decoration: none;\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n margin-top: -10px;\n z-index: 5;\n display: inline-block;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n line-height: 1;\n font-family: serif;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid #fff;\n border-radius: 10px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-indicators .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: #fff;\n}\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -10px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -10px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -10px;\n }\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-header:before,\n.modal-header:after,\n.modal-footer:before,\n.modal-footer:after {\n content: \" \";\n display: table;\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-header:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table !important;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table !important;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table !important;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table !important;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table !important;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */","/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\nhtml {\n font-family: sans-serif;\n -webkit-text-size-adjust: 100%;\n -ms-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n margin: .67em 0;\n font-size: 2em;\n}\nmark {\n color: #000;\n background: #ff0;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\nsup {\n top: -.5em;\n}\nsub {\n bottom: -.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n height: 0;\n -webkit-box-sizing: content-box;\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n margin: 0;\n font: inherit;\n color: inherit;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n padding: 0;\n border: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: content-box;\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n -webkit-appearance: textfield;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n padding: .35em .625em .75em;\n margin: 0 2px;\n border: 1px solid #c0c0c0;\n}\nlegend {\n padding: 0;\n border: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-spacing: 0;\n border-collapse: collapse;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n color: #000 !important;\n text-shadow: none !important;\n background: transparent !important;\n -webkit-box-shadow: none !important;\n box-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\002a\";\n}\n.glyphicon-plus:before {\n content: \"\\002b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-btc:before {\n content: \"\\e227\";\n}\n.glyphicon-xbt:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-jpy:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-rub:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333;\n background-color: #fff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n display: inline-block;\n max-width: 100%;\n height: auto;\n padding: 4px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: all .2s ease-in-out;\n -o-transition: all .2s ease-in-out;\n transition: all .2s ease-in-out;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\n[role=\"button\"] {\n cursor: pointer;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n padding: .2em;\n background-color: #fcf8e3;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover,\na.text-primary:focus {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover,\na.text-success:focus {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover,\na.text-info:focus {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover,\na.text-warning:focus {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover,\na.text-danger:focus {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover,\na.bg-primary:focus {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover,\na.bg-success:focus {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover,\na.bg-info:focus {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover,\na.bg-warning:focus {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover,\na.bg-danger:focus {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n margin-left: -5px;\n list-style: none;\n}\n.list-inline > li {\n display: inline-block;\n padding-right: 5px;\n padding-left: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n overflow: hidden;\n clear: left;\n text-align: right;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n text-align: right;\n border-right: 5px solid #eee;\n border-left: 0;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #fff;\n background-color: #333;\n border-radius: 3px;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n color: #333;\n word-break: break-all;\n word-wrap: break-word;\n background-color: #f5f5f5;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n.row {\n margin-right: -15px;\n margin-left: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-right: 15px;\n padding-left: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #ddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #ddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #ddd;\n}\n.table .table {\n background-color: #fff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n display: table-column;\n float: none;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n display: table-cell;\n float: none;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n min-height: .01%;\n overflow-x: auto;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #ddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555;\n background-color: #fff;\n background-image: none;\n border: 1px solid #ccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6);\n}\n.form-control::-moz-placeholder {\n color: #999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999;\n}\n.form-control::-ms-expand {\n background-color: transparent;\n border: 0;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n background-color: #eee;\n opacity: 1;\n}\n.form-control[disabled],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"].form-control,\n input[type=\"time\"].form-control,\n input[type=\"datetime-local\"].form-control,\n input[type=\"month\"].form-control {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-top: 4px \\9;\n margin-left: -20px;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n vertical-align: middle;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n min-height: 34px;\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-right: 0;\n padding-left: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.form-group-sm select.form-control {\n height: 30px;\n line-height: 30px;\n}\n.form-group-sm textarea.form-control,\n.form-group-sm select[multiple].form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n min-height: 32px;\n padding: 6px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.form-group-lg select.form-control {\n height: 46px;\n line-height: 46px;\n}\n.form-group-lg textarea.form-control,\n.form-group-lg select[multiple].form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n min-height: 38px;\n padding: 11px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #3c763d;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #8a6d3b;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n background-color: #f2dede;\n border-color: #a94442;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n padding-top: 7px;\n margin-top: 0;\n margin-bottom: 0;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n padding-top: 7px;\n margin-bottom: 0;\n text-align: right;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 11px;\n font-size: 18px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n font-size: 12px;\n }\n}\n.btn {\n display: inline-block;\n padding: 6px 12px;\n margin-bottom: 0;\n font-size: 14px;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: center;\n white-space: nowrap;\n vertical-align: middle;\n -ms-touch-action: manipulation;\n touch-action: manipulation;\n cursor: pointer;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n background-image: none;\n outline: 0;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n opacity: .65;\n}\na.btn.disabled,\nfieldset[disabled] a.btn {\n pointer-events: none;\n}\n.btn-default {\n color: #333;\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default:focus,\n.btn-default.focus {\n color: #333;\n background-color: #e6e6e6;\n border-color: #8c8c8c;\n}\n.btn-default:hover {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active:hover,\n.btn-default.active:hover,\n.open > .dropdown-toggle.btn-default:hover,\n.btn-default:active:focus,\n.btn-default.active:focus,\n.open > .dropdown-toggle.btn-default:focus,\n.btn-default:active.focus,\n.btn-default.active.focus,\n.open > .dropdown-toggle.btn-default.focus {\n color: #333;\n background-color: #d4d4d4;\n border-color: #8c8c8c;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus {\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default .badge {\n color: #fff;\n background-color: #333;\n}\n.btn-primary {\n color: #fff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:focus,\n.btn-primary.focus {\n color: #fff;\n background-color: #286090;\n border-color: #122b40;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active:hover,\n.btn-primary.active:hover,\n.open > .dropdown-toggle.btn-primary:hover,\n.btn-primary:active:focus,\n.btn-primary.active:focus,\n.open > .dropdown-toggle.btn-primary:focus,\n.btn-primary:active.focus,\n.btn-primary.active.focus,\n.open > .dropdown-toggle.btn-primary.focus {\n color: #fff;\n background-color: #204d74;\n border-color: #122b40;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.btn-success {\n color: #fff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:focus,\n.btn-success.focus {\n color: #fff;\n background-color: #449d44;\n border-color: #255625;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active:hover,\n.btn-success.active:hover,\n.open > .dropdown-toggle.btn-success:hover,\n.btn-success:active:focus,\n.btn-success.active:focus,\n.open > .dropdown-toggle.btn-success:focus,\n.btn-success:active.focus,\n.btn-success.active.focus,\n.open > .dropdown-toggle.btn-success.focus {\n color: #fff;\n background-color: #398439;\n border-color: #255625;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #fff;\n}\n.btn-info {\n color: #fff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:focus,\n.btn-info.focus {\n color: #fff;\n background-color: #31b0d5;\n border-color: #1b6d85;\n}\n.btn-info:hover {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active:hover,\n.btn-info.active:hover,\n.open > .dropdown-toggle.btn-info:hover,\n.btn-info:active:focus,\n.btn-info.active:focus,\n.open > .dropdown-toggle.btn-info:focus,\n.btn-info:active.focus,\n.btn-info.active.focus,\n.open > .dropdown-toggle.btn-info.focus {\n color: #fff;\n background-color: #269abc;\n border-color: #1b6d85;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #fff;\n}\n.btn-warning {\n color: #fff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:focus,\n.btn-warning.focus {\n color: #fff;\n background-color: #ec971f;\n border-color: #985f0d;\n}\n.btn-warning:hover {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active:hover,\n.btn-warning.active:hover,\n.open > .dropdown-toggle.btn-warning:hover,\n.btn-warning:active:focus,\n.btn-warning.active:focus,\n.open > .dropdown-toggle.btn-warning:focus,\n.btn-warning:active.focus,\n.btn-warning.active.focus,\n.open > .dropdown-toggle.btn-warning.focus {\n color: #fff;\n background-color: #d58512;\n border-color: #985f0d;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #fff;\n}\n.btn-danger {\n color: #fff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:focus,\n.btn-danger.focus {\n color: #fff;\n background-color: #c9302c;\n border-color: #761c19;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active:hover,\n.btn-danger.active:hover,\n.open > .dropdown-toggle.btn-danger:hover,\n.btn-danger:active:focus,\n.btn-danger.active:focus,\n.open > .dropdown-toggle.btn-danger:focus,\n.btn-danger:active.focus,\n.btn-danger.active.focus,\n.open > .dropdown-toggle.btn-danger.focus {\n color: #fff;\n background-color: #ac2925;\n border-color: #761c19;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #fff;\n}\n.btn-link {\n font-weight: normal;\n color: #337ab7;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity .15s linear;\n -o-transition: opacity .15s linear;\n transition: opacity .15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n}\n.collapse.in {\n display: block;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-timing-function: ease;\n -o-transition-timing-function: ease;\n transition-timing-function: ease;\n -webkit-transition-duration: .35s;\n -o-transition-duration: .35s;\n transition-duration: .35s;\n -webkit-transition-property: height, visibility;\n -o-transition-property: height, visibility;\n transition-property: height, visibility;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px dashed;\n border-top: 4px solid \\9;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n font-size: 14px;\n text-align: left;\n list-style: none;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, .15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, .175);\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n color: #262626;\n text-decoration: none;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #fff;\n text-decoration: none;\n background-color: #337ab7;\n outline: 0;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n cursor: not-allowed;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu-left {\n right: auto;\n left: 0;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n content: \"\";\n border-top: 0;\n border-bottom: 4px dashed;\n border-bottom: 4px solid \\9;\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n right: 0;\n left: auto;\n }\n .navbar-right .dropdown-menu-left {\n right: auto;\n left: 0;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn,\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-right: 8px;\n padding-left: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-right: 12px;\n padding-left: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n display: table-cell;\n float: none;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-right: 0;\n padding-left: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group .form-control:focus {\n z-index: 3;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555;\n text-align: center;\n background-color: #eee;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n z-index: 2;\n margin-left: -1px;\n}\n.nav {\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eee;\n}\n.nav > li.disabled > a {\n color: #777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777;\n text-decoration: none;\n cursor: not-allowed;\n background-color: transparent;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #ddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eee #eee #ddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555;\n cursor: default;\n background-color: #fff;\n border: 1px solid #ddd;\n border-bottom-color: transparent;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n margin-bottom: 5px;\n text-align: center;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #fff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n margin-bottom: 5px;\n text-align: center;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n padding-right: 15px;\n padding-left: 15px;\n overflow-x: visible;\n -webkit-overflow-scrolling: touch;\n border-top: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-right: 0;\n padding-left: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n height: 50px;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n padding: 9px 10px;\n margin-top: 8px;\n margin-right: 15px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n padding: 10px 15px;\n margin-top: 8px;\n margin-right: -15px;\n margin-bottom: 8px;\n margin-left: -15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n padding-top: 0;\n padding-bottom: 0;\n margin-right: 0;\n margin-left: 0;\n border: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-right: 15px;\n margin-left: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #ddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #ddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777;\n}\n.navbar-default .navbar-link:hover {\n color: #333;\n}\n.navbar-default .btn-link {\n color: #777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #ccc;\n}\n.navbar-inverse {\n background-color: #222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #fff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #fff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n color: #fff;\n background-color: #080808;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #fff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #fff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #fff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n padding: 0 5px;\n color: #ccc;\n content: \"/\\00a0\";\n}\n.breadcrumb > .active {\n color: #777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n margin-left: -1px;\n line-height: 1.42857143;\n color: #337ab7;\n text-decoration: none;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-top-left-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n z-index: 2;\n color: #23527c;\n background-color: #eee;\n border-color: #ddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 3;\n color: #fff;\n cursor: default;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777;\n cursor: not-allowed;\n background-color: #fff;\n border-color: #ddd;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-top-left-radius: 6px;\n border-bottom-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-top-right-radius: 6px;\n border-bottom-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-top-left-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-top-right-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n text-align: center;\n list-style: none;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777;\n cursor: not-allowed;\n background-color: #fff;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: middle;\n background-color: #777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge,\n.btn-group-xs > .btn .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding-top: 30px;\n padding-bottom: 30px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n padding-right: 15px;\n padding-left: 15px;\n border-radius: 6px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding-top: 48px;\n padding-bottom: 48px;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-right: 60px;\n padding-left: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: border .2s ease-in-out;\n -o-transition: border .2s ease-in-out;\n transition: border .2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-right: auto;\n margin-left: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@-o-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n height: 20px;\n margin-bottom: 20px;\n overflow: hidden;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);\n}\n.progress-bar {\n float: left;\n width: 0;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #fff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);\n -webkit-transition: width .6s ease;\n -o-transition: width .6s ease;\n transition: width .6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n -webkit-background-size: 40px 40px;\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n overflow: hidden;\n zoom: 1;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-object.img-thumbnail {\n max-width: none;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n padding-left: 0;\n margin-bottom: 20px;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.list-group-item:first-child {\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item,\nbutton.list-group-item {\n color: #555;\n}\na.list-group-item .list-group-item-heading,\nbutton.list-group-item .list-group-item-heading {\n color: #333;\n}\na.list-group-item:hover,\nbutton.list-group-item:hover,\na.list-group-item:focus,\nbutton.list-group-item:focus {\n color: #555;\n text-decoration: none;\n background-color: #f5f5f5;\n}\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n color: #777;\n cursor: not-allowed;\n background-color: #eee;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success,\nbutton.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading,\nbutton.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\nbutton.list-group-item-success:hover,\na.list-group-item-success:focus,\nbutton.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\nbutton.list-group-item-success.active,\na.list-group-item-success.active:hover,\nbutton.list-group-item-success.active:hover,\na.list-group-item-success.active:focus,\nbutton.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info,\nbutton.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading,\nbutton.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\nbutton.list-group-item-info:hover,\na.list-group-item-info:focus,\nbutton.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\nbutton.list-group-item-info.active,\na.list-group-item-info.active:hover,\nbutton.list-group-item-info.active:hover,\na.list-group-item-info.active:focus,\nbutton.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning,\nbutton.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading,\nbutton.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\nbutton.list-group-item-warning:hover,\na.list-group-item-warning:focus,\nbutton.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\nbutton.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\nbutton.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus,\nbutton.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger,\nbutton.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading,\nbutton.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\nbutton.list-group-item-danger:hover,\na.list-group-item-danger:focus,\nbutton.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\nbutton.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\nbutton.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus,\nbutton.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #fff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, .05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #ddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-right: 15px;\n padding-left: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #ddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n margin-bottom: 0;\n border: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #ddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #ddd;\n}\n.panel-default {\n border-color: #ddd;\n}\n.panel-default > .panel-heading {\n color: #333;\n background-color: #f5f5f5;\n border-color: #ddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 100%;\n border: 0;\n}\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, .15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n filter: alpha(opacity=20);\n opacity: .2;\n}\n.close:hover,\n.close:focus {\n color: #000;\n text-decoration: none;\n cursor: pointer;\n filter: alpha(opacity=50);\n opacity: .5;\n}\nbutton.close {\n -webkit-appearance: none;\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n display: none;\n overflow: hidden;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transition: -webkit-transform .3s ease-out;\n -o-transition: -o-transform .3s ease-out;\n transition: transform .3s ease-out;\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #999;\n border: 1px solid rgba(0, 0, 0, .2);\n border-radius: 6px;\n outline: 0;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, .5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, .5);\n}\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n.modal-backdrop.fade {\n filter: alpha(opacity=0);\n opacity: 0;\n}\n.modal-backdrop.in {\n filter: alpha(opacity=50);\n opacity: .5;\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-bottom: 0;\n margin-left: 5px;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, .5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 12px;\n font-style: normal;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n white-space: normal;\n filter: alpha(opacity=0);\n opacity: 0;\n\n line-break: auto;\n}\n.tooltip.in {\n filter: alpha(opacity=90);\n opacity: .9;\n}\n.tooltip.top {\n padding: 5px 0;\n margin-top: -3px;\n}\n.tooltip.right {\n padding: 0 5px;\n margin-left: 3px;\n}\n.tooltip.bottom {\n padding: 5px 0;\n margin-top: 3px;\n}\n.tooltip.left {\n padding: 0 5px;\n margin-left: -3px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-left .tooltip-arrow {\n right: 5px;\n bottom: 0;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n font-style: normal;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n white-space: normal;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, .2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, .2);\n\n line-break: auto;\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n padding: 8px 14px;\n margin: 0;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n content: \"\";\n border-width: 10px;\n}\n.popover.top > .arrow {\n bottom: -11px;\n left: 50%;\n margin-left: -11px;\n border-top-color: #999;\n border-top-color: rgba(0, 0, 0, .25);\n border-bottom-width: 0;\n}\n.popover.top > .arrow:after {\n bottom: 1px;\n margin-left: -10px;\n content: \" \";\n border-top-color: #fff;\n border-bottom-width: 0;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-right-color: #999;\n border-right-color: rgba(0, 0, 0, .25);\n border-left-width: 0;\n}\n.popover.right > .arrow:after {\n bottom: -10px;\n left: 1px;\n content: \" \";\n border-right-color: #fff;\n border-left-width: 0;\n}\n.popover.bottom > .arrow {\n top: -11px;\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999;\n border-bottom-color: rgba(0, 0, 0, .25);\n}\n.popover.bottom > .arrow:after {\n top: 1px;\n margin-left: -10px;\n content: \" \";\n border-top-width: 0;\n border-bottom-color: #fff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999;\n border-left-color: rgba(0, 0, 0, .25);\n}\n.popover.left > .arrow:after {\n right: 1px;\n bottom: -10px;\n content: \" \";\n border-right-width: 0;\n border-left-color: #fff;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n.carousel-inner > .item {\n position: relative;\n display: none;\n -webkit-transition: .6s ease-in-out left;\n -o-transition: .6s ease-in-out left;\n transition: .6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform .6s ease-in-out;\n -o-transition: -o-transform .6s ease-in-out;\n transition: transform .6s ease-in-out;\n\n -webkit-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000px;\n perspective: 1000px;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n left: 0;\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n left: 0;\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n left: 0;\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 15%;\n font-size: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, .6);\n background-color: rgba(0, 0, 0, 0);\n filter: alpha(opacity=50);\n opacity: .5;\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .5)), to(rgba(0, 0, 0, .0001)));\n background-image: linear-gradient(to right, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n background-repeat: repeat-x;\n}\n.carousel-control.right {\n right: 0;\n left: auto;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .0001)), to(rgba(0, 0, 0, .5)));\n background-image: linear-gradient(to right, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n background-repeat: repeat-x;\n}\n.carousel-control:hover,\n.carousel-control:focus {\n color: #fff;\n text-decoration: none;\n filter: alpha(opacity=90);\n outline: 0;\n opacity: .9;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n z-index: 5;\n display: inline-block;\n margin-top: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n font-family: serif;\n line-height: 1;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n padding-left: 0;\n margin-left: -30%;\n text-align: center;\n list-style: none;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n border: 1px solid #fff;\n border-radius: 10px;\n}\n.carousel-indicators .active {\n width: 12px;\n height: 12px;\n margin: 0;\n background-color: #fff;\n}\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 20px;\n left: 15%;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, .6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -10px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -10px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -10px;\n }\n .carousel-caption {\n right: 20%;\n left: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-header:before,\n.modal-header:after,\n.modal-footer:before,\n.modal-footer:after {\n display: table;\n content: \" \";\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-header:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-right: auto;\n margin-left: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table !important;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table !important;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table !important;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table !important;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table !important;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */\n","//\n// Glyphicons for Bootstrap\n//\n// Since icons are fonts, they can be placed anywhere text is placed and are\n// thus automatically sized to match the surrounding child. To use, create an\n// inline element with the appropriate classes, like so:\n//\n// Star\n\n// Import the fonts\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('@{icon-font-path}@{icon-font-name}.eot');\n src: url('@{icon-font-path}@{icon-font-name}.eot?#iefix') format('embedded-opentype'),\n url('@{icon-font-path}@{icon-font-name}.woff2') format('woff2'),\n url('@{icon-font-path}@{icon-font-name}.woff') format('woff'),\n url('@{icon-font-path}@{icon-font-name}.ttf') format('truetype'),\n url('@{icon-font-path}@{icon-font-name}.svg#@{icon-font-svg-id}') format('svg');\n}\n\n// Catchall baseclass\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\n// Individual icons\n.glyphicon-asterisk { &:before { content: \"\\002a\"; } }\n.glyphicon-plus { &:before { content: \"\\002b\"; } }\n.glyphicon-euro,\n.glyphicon-eur { &:before { content: \"\\20ac\"; } }\n.glyphicon-minus { &:before { content: \"\\2212\"; } }\n.glyphicon-cloud { &:before { content: \"\\2601\"; } }\n.glyphicon-envelope { &:before { content: \"\\2709\"; } }\n.glyphicon-pencil { &:before { content: \"\\270f\"; } }\n.glyphicon-glass { &:before { content: \"\\e001\"; } }\n.glyphicon-music { &:before { content: \"\\e002\"; } }\n.glyphicon-search { &:before { content: \"\\e003\"; } }\n.glyphicon-heart { &:before { content: \"\\e005\"; } }\n.glyphicon-star { &:before { content: \"\\e006\"; } }\n.glyphicon-star-empty { &:before { content: \"\\e007\"; } }\n.glyphicon-user { &:before { content: \"\\e008\"; } }\n.glyphicon-film { &:before { content: \"\\e009\"; } }\n.glyphicon-th-large { &:before { content: \"\\e010\"; } }\n.glyphicon-th { &:before { content: \"\\e011\"; } }\n.glyphicon-th-list { &:before { content: \"\\e012\"; } }\n.glyphicon-ok { &:before { content: \"\\e013\"; } }\n.glyphicon-remove { &:before { content: \"\\e014\"; } }\n.glyphicon-zoom-in { &:before { content: \"\\e015\"; } }\n.glyphicon-zoom-out { &:before { content: \"\\e016\"; } }\n.glyphicon-off { &:before { content: \"\\e017\"; } }\n.glyphicon-signal { &:before { content: \"\\e018\"; } }\n.glyphicon-cog { &:before { content: \"\\e019\"; } }\n.glyphicon-trash { &:before { content: \"\\e020\"; } }\n.glyphicon-home { &:before { content: \"\\e021\"; } }\n.glyphicon-file { &:before { content: \"\\e022\"; } }\n.glyphicon-time { &:before { content: \"\\e023\"; } }\n.glyphicon-road { &:before { content: \"\\e024\"; } }\n.glyphicon-download-alt { &:before { content: \"\\e025\"; } }\n.glyphicon-download { &:before { content: \"\\e026\"; } }\n.glyphicon-upload { &:before { content: \"\\e027\"; } }\n.glyphicon-inbox { &:before { content: \"\\e028\"; } }\n.glyphicon-play-circle { &:before { content: \"\\e029\"; } }\n.glyphicon-repeat { &:before { content: \"\\e030\"; } }\n.glyphicon-refresh { &:before { content: \"\\e031\"; } }\n.glyphicon-list-alt { &:before { content: \"\\e032\"; } }\n.glyphicon-lock { &:before { content: \"\\e033\"; } }\n.glyphicon-flag { &:before { content: \"\\e034\"; } }\n.glyphicon-headphones { &:before { content: \"\\e035\"; } }\n.glyphicon-volume-off { &:before { content: \"\\e036\"; } }\n.glyphicon-volume-down { &:before { content: \"\\e037\"; } }\n.glyphicon-volume-up { &:before { content: \"\\e038\"; } }\n.glyphicon-qrcode { &:before { content: \"\\e039\"; } }\n.glyphicon-barcode { &:before { content: \"\\e040\"; } }\n.glyphicon-tag { &:before { content: \"\\e041\"; } }\n.glyphicon-tags { &:before { content: \"\\e042\"; } }\n.glyphicon-book { &:before { content: \"\\e043\"; } }\n.glyphicon-bookmark { &:before { content: \"\\e044\"; } }\n.glyphicon-print { &:before { content: \"\\e045\"; } }\n.glyphicon-camera { &:before { content: \"\\e046\"; } }\n.glyphicon-font { &:before { content: \"\\e047\"; } }\n.glyphicon-bold { &:before { content: \"\\e048\"; } }\n.glyphicon-italic { &:before { content: \"\\e049\"; } }\n.glyphicon-text-height { &:before { content: \"\\e050\"; } }\n.glyphicon-text-width { &:before { content: \"\\e051\"; } }\n.glyphicon-align-left { &:before { content: \"\\e052\"; } }\n.glyphicon-align-center { &:before { content: \"\\e053\"; } }\n.glyphicon-align-right { &:before { content: \"\\e054\"; } }\n.glyphicon-align-justify { &:before { content: \"\\e055\"; } }\n.glyphicon-list { &:before { content: \"\\e056\"; } }\n.glyphicon-indent-left { &:before { content: \"\\e057\"; } }\n.glyphicon-indent-right { &:before { content: \"\\e058\"; } }\n.glyphicon-facetime-video { &:before { content: \"\\e059\"; } }\n.glyphicon-picture { &:before { content: \"\\e060\"; } }\n.glyphicon-map-marker { &:before { content: \"\\e062\"; } }\n.glyphicon-adjust { &:before { content: \"\\e063\"; } }\n.glyphicon-tint { &:before { content: \"\\e064\"; } }\n.glyphicon-edit { &:before { content: \"\\e065\"; } }\n.glyphicon-share { &:before { content: \"\\e066\"; } }\n.glyphicon-check { &:before { content: \"\\e067\"; } }\n.glyphicon-move { &:before { content: \"\\e068\"; } }\n.glyphicon-step-backward { &:before { content: \"\\e069\"; } }\n.glyphicon-fast-backward { &:before { content: \"\\e070\"; } }\n.glyphicon-backward { &:before { content: \"\\e071\"; } }\n.glyphicon-play { &:before { content: \"\\e072\"; } }\n.glyphicon-pause { &:before { content: \"\\e073\"; } }\n.glyphicon-stop { &:before { content: \"\\e074\"; } }\n.glyphicon-forward { &:before { content: \"\\e075\"; } }\n.glyphicon-fast-forward { &:before { content: \"\\e076\"; } }\n.glyphicon-step-forward { &:before { content: \"\\e077\"; } }\n.glyphicon-eject { &:before { content: \"\\e078\"; } }\n.glyphicon-chevron-left { &:before { content: \"\\e079\"; } }\n.glyphicon-chevron-right { &:before { content: \"\\e080\"; } }\n.glyphicon-plus-sign { &:before { content: \"\\e081\"; } }\n.glyphicon-minus-sign { &:before { content: \"\\e082\"; } }\n.glyphicon-remove-sign { &:before { content: \"\\e083\"; } }\n.glyphicon-ok-sign { &:before { content: \"\\e084\"; } }\n.glyphicon-question-sign { &:before { content: \"\\e085\"; } }\n.glyphicon-info-sign { &:before { content: \"\\e086\"; } }\n.glyphicon-screenshot { &:before { content: \"\\e087\"; } }\n.glyphicon-remove-circle { &:before { content: \"\\e088\"; } }\n.glyphicon-ok-circle { &:before { content: \"\\e089\"; } }\n.glyphicon-ban-circle { &:before { content: \"\\e090\"; } }\n.glyphicon-arrow-left { &:before { content: \"\\e091\"; } }\n.glyphicon-arrow-right { &:before { content: \"\\e092\"; } }\n.glyphicon-arrow-up { &:before { content: \"\\e093\"; } }\n.glyphicon-arrow-down { &:before { content: \"\\e094\"; } }\n.glyphicon-share-alt { &:before { content: \"\\e095\"; } }\n.glyphicon-resize-full { &:before { content: \"\\e096\"; } }\n.glyphicon-resize-small { &:before { content: \"\\e097\"; } }\n.glyphicon-exclamation-sign { &:before { content: \"\\e101\"; } }\n.glyphicon-gift { &:before { content: \"\\e102\"; } }\n.glyphicon-leaf { &:before { content: \"\\e103\"; } }\n.glyphicon-fire { &:before { content: \"\\e104\"; } }\n.glyphicon-eye-open { &:before { content: \"\\e105\"; } }\n.glyphicon-eye-close { &:before { content: \"\\e106\"; } }\n.glyphicon-warning-sign { &:before { content: \"\\e107\"; } }\n.glyphicon-plane { &:before { content: \"\\e108\"; } }\n.glyphicon-calendar { &:before { content: \"\\e109\"; } }\n.glyphicon-random { &:before { content: \"\\e110\"; } }\n.glyphicon-comment { &:before { content: \"\\e111\"; } }\n.glyphicon-magnet { &:before { content: \"\\e112\"; } }\n.glyphicon-chevron-up { &:before { content: \"\\e113\"; } }\n.glyphicon-chevron-down { &:before { content: \"\\e114\"; } }\n.glyphicon-retweet { &:before { content: \"\\e115\"; } }\n.glyphicon-shopping-cart { &:before { content: \"\\e116\"; } }\n.glyphicon-folder-close { &:before { content: \"\\e117\"; } }\n.glyphicon-folder-open { &:before { content: \"\\e118\"; } }\n.glyphicon-resize-vertical { &:before { content: \"\\e119\"; } }\n.glyphicon-resize-horizontal { &:before { content: \"\\e120\"; } }\n.glyphicon-hdd { &:before { content: \"\\e121\"; } }\n.glyphicon-bullhorn { &:before { content: \"\\e122\"; } }\n.glyphicon-bell { &:before { content: \"\\e123\"; } }\n.glyphicon-certificate { &:before { content: \"\\e124\"; } }\n.glyphicon-thumbs-up { &:before { content: \"\\e125\"; } }\n.glyphicon-thumbs-down { &:before { content: \"\\e126\"; } }\n.glyphicon-hand-right { &:before { content: \"\\e127\"; } }\n.glyphicon-hand-left { &:before { content: \"\\e128\"; } }\n.glyphicon-hand-up { &:before { content: \"\\e129\"; } }\n.glyphicon-hand-down { &:before { content: \"\\e130\"; } }\n.glyphicon-circle-arrow-right { &:before { content: \"\\e131\"; } }\n.glyphicon-circle-arrow-left { &:before { content: \"\\e132\"; } }\n.glyphicon-circle-arrow-up { &:before { content: \"\\e133\"; } }\n.glyphicon-circle-arrow-down { &:before { content: \"\\e134\"; } }\n.glyphicon-globe { &:before { content: \"\\e135\"; } }\n.glyphicon-wrench { &:before { content: \"\\e136\"; } }\n.glyphicon-tasks { &:before { content: \"\\e137\"; } }\n.glyphicon-filter { &:before { content: \"\\e138\"; } }\n.glyphicon-briefcase { &:before { content: \"\\e139\"; } }\n.glyphicon-fullscreen { &:before { content: \"\\e140\"; } }\n.glyphicon-dashboard { &:before { content: \"\\e141\"; } }\n.glyphicon-paperclip { &:before { content: \"\\e142\"; } }\n.glyphicon-heart-empty { &:before { content: \"\\e143\"; } }\n.glyphicon-link { &:before { content: \"\\e144\"; } }\n.glyphicon-phone { &:before { content: \"\\e145\"; } }\n.glyphicon-pushpin { &:before { content: \"\\e146\"; } }\n.glyphicon-usd { &:before { content: \"\\e148\"; } }\n.glyphicon-gbp { &:before { content: \"\\e149\"; } }\n.glyphicon-sort { &:before { content: \"\\e150\"; } }\n.glyphicon-sort-by-alphabet { &:before { content: \"\\e151\"; } }\n.glyphicon-sort-by-alphabet-alt { &:before { content: \"\\e152\"; } }\n.glyphicon-sort-by-order { &:before { content: \"\\e153\"; } }\n.glyphicon-sort-by-order-alt { &:before { content: \"\\e154\"; } }\n.glyphicon-sort-by-attributes { &:before { content: \"\\e155\"; } }\n.glyphicon-sort-by-attributes-alt { &:before { content: \"\\e156\"; } }\n.glyphicon-unchecked { &:before { content: \"\\e157\"; } }\n.glyphicon-expand { &:before { content: \"\\e158\"; } }\n.glyphicon-collapse-down { &:before { content: \"\\e159\"; } }\n.glyphicon-collapse-up { &:before { content: \"\\e160\"; } }\n.glyphicon-log-in { &:before { content: \"\\e161\"; } }\n.glyphicon-flash { &:before { content: \"\\e162\"; } }\n.glyphicon-log-out { &:before { content: \"\\e163\"; } }\n.glyphicon-new-window { &:before { content: \"\\e164\"; } }\n.glyphicon-record { &:before { content: \"\\e165\"; } }\n.glyphicon-save { &:before { content: \"\\e166\"; } }\n.glyphicon-open { &:before { content: \"\\e167\"; } }\n.glyphicon-saved { &:before { content: \"\\e168\"; } }\n.glyphicon-import { &:before { content: \"\\e169\"; } }\n.glyphicon-export { &:before { content: \"\\e170\"; } }\n.glyphicon-send { &:before { content: \"\\e171\"; } }\n.glyphicon-floppy-disk { &:before { content: \"\\e172\"; } }\n.glyphicon-floppy-saved { &:before { content: \"\\e173\"; } }\n.glyphicon-floppy-remove { &:before { content: \"\\e174\"; } }\n.glyphicon-floppy-save { &:before { content: \"\\e175\"; } }\n.glyphicon-floppy-open { &:before { content: \"\\e176\"; } }\n.glyphicon-credit-card { &:before { content: \"\\e177\"; } }\n.glyphicon-transfer { &:before { content: \"\\e178\"; } }\n.glyphicon-cutlery { &:before { content: \"\\e179\"; } }\n.glyphicon-header { &:before { content: \"\\e180\"; } }\n.glyphicon-compressed { &:before { content: \"\\e181\"; } }\n.glyphicon-earphone { &:before { content: \"\\e182\"; } }\n.glyphicon-phone-alt { &:before { content: \"\\e183\"; } }\n.glyphicon-tower { &:before { content: \"\\e184\"; } }\n.glyphicon-stats { &:before { content: \"\\e185\"; } }\n.glyphicon-sd-video { &:before { content: \"\\e186\"; } }\n.glyphicon-hd-video { &:before { content: \"\\e187\"; } }\n.glyphicon-subtitles { &:before { content: \"\\e188\"; } }\n.glyphicon-sound-stereo { &:before { content: \"\\e189\"; } }\n.glyphicon-sound-dolby { &:before { content: \"\\e190\"; } }\n.glyphicon-sound-5-1 { &:before { content: \"\\e191\"; } }\n.glyphicon-sound-6-1 { &:before { content: \"\\e192\"; } }\n.glyphicon-sound-7-1 { &:before { content: \"\\e193\"; } }\n.glyphicon-copyright-mark { &:before { content: \"\\e194\"; } }\n.glyphicon-registration-mark { &:before { content: \"\\e195\"; } }\n.glyphicon-cloud-download { &:before { content: \"\\e197\"; } }\n.glyphicon-cloud-upload { &:before { content: \"\\e198\"; } }\n.glyphicon-tree-conifer { &:before { content: \"\\e199\"; } }\n.glyphicon-tree-deciduous { &:before { content: \"\\e200\"; } }\n.glyphicon-cd { &:before { content: \"\\e201\"; } }\n.glyphicon-save-file { &:before { content: \"\\e202\"; } }\n.glyphicon-open-file { &:before { content: \"\\e203\"; } }\n.glyphicon-level-up { &:before { content: \"\\e204\"; } }\n.glyphicon-copy { &:before { content: \"\\e205\"; } }\n.glyphicon-paste { &:before { content: \"\\e206\"; } }\n// The following 2 Glyphicons are omitted for the time being because\n// they currently use Unicode codepoints that are outside the\n// Basic Multilingual Plane (BMP). Older buggy versions of WebKit can't handle\n// non-BMP codepoints in CSS string escapes, and thus can't display these two icons.\n// Notably, the bug affects some older versions of the Android Browser.\n// More info: https://github.com/twbs/bootstrap/issues/10106\n// .glyphicon-door { &:before { content: \"\\1f6aa\"; } }\n// .glyphicon-key { &:before { content: \"\\1f511\"; } }\n.glyphicon-alert { &:before { content: \"\\e209\"; } }\n.glyphicon-equalizer { &:before { content: \"\\e210\"; } }\n.glyphicon-king { &:before { content: \"\\e211\"; } }\n.glyphicon-queen { &:before { content: \"\\e212\"; } }\n.glyphicon-pawn { &:before { content: \"\\e213\"; } }\n.glyphicon-bishop { &:before { content: \"\\e214\"; } }\n.glyphicon-knight { &:before { content: \"\\e215\"; } }\n.glyphicon-baby-formula { &:before { content: \"\\e216\"; } }\n.glyphicon-tent { &:before { content: \"\\26fa\"; } }\n.glyphicon-blackboard { &:before { content: \"\\e218\"; } }\n.glyphicon-bed { &:before { content: \"\\e219\"; } }\n.glyphicon-apple { &:before { content: \"\\f8ff\"; } }\n.glyphicon-erase { &:before { content: \"\\e221\"; } }\n.glyphicon-hourglass { &:before { content: \"\\231b\"; } }\n.glyphicon-lamp { &:before { content: \"\\e223\"; } }\n.glyphicon-duplicate { &:before { content: \"\\e224\"; } }\n.glyphicon-piggy-bank { &:before { content: \"\\e225\"; } }\n.glyphicon-scissors { &:before { content: \"\\e226\"; } }\n.glyphicon-bitcoin { &:before { content: \"\\e227\"; } }\n.glyphicon-btc { &:before { content: \"\\e227\"; } }\n.glyphicon-xbt { &:before { content: \"\\e227\"; } }\n.glyphicon-yen { &:before { content: \"\\00a5\"; } }\n.glyphicon-jpy { &:before { content: \"\\00a5\"; } }\n.glyphicon-ruble { &:before { content: \"\\20bd\"; } }\n.glyphicon-rub { &:before { content: \"\\20bd\"; } }\n.glyphicon-scale { &:before { content: \"\\e230\"; } }\n.glyphicon-ice-lolly { &:before { content: \"\\e231\"; } }\n.glyphicon-ice-lolly-tasted { &:before { content: \"\\e232\"; } }\n.glyphicon-education { &:before { content: \"\\e233\"; } }\n.glyphicon-option-horizontal { &:before { content: \"\\e234\"; } }\n.glyphicon-option-vertical { &:before { content: \"\\e235\"; } }\n.glyphicon-menu-hamburger { &:before { content: \"\\e236\"; } }\n.glyphicon-modal-window { &:before { content: \"\\e237\"; } }\n.glyphicon-oil { &:before { content: \"\\e238\"; } }\n.glyphicon-grain { &:before { content: \"\\e239\"; } }\n.glyphicon-sunglasses { &:before { content: \"\\e240\"; } }\n.glyphicon-text-size { &:before { content: \"\\e241\"; } }\n.glyphicon-text-color { &:before { content: \"\\e242\"; } }\n.glyphicon-text-background { &:before { content: \"\\e243\"; } }\n.glyphicon-object-align-top { &:before { content: \"\\e244\"; } }\n.glyphicon-object-align-bottom { &:before { content: \"\\e245\"; } }\n.glyphicon-object-align-horizontal{ &:before { content: \"\\e246\"; } }\n.glyphicon-object-align-left { &:before { content: \"\\e247\"; } }\n.glyphicon-object-align-vertical { &:before { content: \"\\e248\"; } }\n.glyphicon-object-align-right { &:before { content: \"\\e249\"; } }\n.glyphicon-triangle-right { &:before { content: \"\\e250\"; } }\n.glyphicon-triangle-left { &:before { content: \"\\e251\"; } }\n.glyphicon-triangle-bottom { &:before { content: \"\\e252\"; } }\n.glyphicon-triangle-top { &:before { content: \"\\e253\"; } }\n.glyphicon-console { &:before { content: \"\\e254\"; } }\n.glyphicon-superscript { &:before { content: \"\\e255\"; } }\n.glyphicon-subscript { &:before { content: \"\\e256\"; } }\n.glyphicon-menu-left { &:before { content: \"\\e257\"; } }\n.glyphicon-menu-right { &:before { content: \"\\e258\"; } }\n.glyphicon-menu-down { &:before { content: \"\\e259\"; } }\n.glyphicon-menu-up { &:before { content: \"\\e260\"; } }\n","//\n// Scaffolding\n// --------------------------------------------------\n\n\n// Reset the box-sizing\n//\n// Heads up! This reset may cause conflicts with some third-party widgets.\n// For recommendations on resolving such conflicts, see\n// http://getbootstrap.com/getting-started/#third-box-sizing\n* {\n .box-sizing(border-box);\n}\n*:before,\n*:after {\n .box-sizing(border-box);\n}\n\n\n// Body reset\n\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n}\n\nbody {\n font-family: @font-family-base;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @text-color;\n background-color: @body-bg;\n}\n\n// Reset fonts for relevant elements\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\n\n// Links\n\na {\n color: @link-color;\n text-decoration: none;\n\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n }\n\n &:focus {\n .tab-focus();\n }\n}\n\n\n// Figures\n//\n// We reset this here because previously Normalize had no `figure` margins. This\n// ensures we don't break anyone's use of the element.\n\nfigure {\n margin: 0;\n}\n\n\n// Images\n\nimg {\n vertical-align: middle;\n}\n\n// Responsive images (ensure images don't scale beyond their parents)\n.img-responsive {\n .img-responsive();\n}\n\n// Rounded corners\n.img-rounded {\n border-radius: @border-radius-large;\n}\n\n// Image thumbnails\n//\n// Heads up! This is mixin-ed into thumbnails.less for `.thumbnail`.\n.img-thumbnail {\n padding: @thumbnail-padding;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(all .2s ease-in-out);\n\n // Keep them at most 100% wide\n .img-responsive(inline-block);\n}\n\n// Perfect circle\n.img-circle {\n border-radius: 50%; // set radius in percents\n}\n\n\n// Horizontal rules\n\nhr {\n margin-top: @line-height-computed;\n margin-bottom: @line-height-computed;\n border: 0;\n border-top: 1px solid @hr-border;\n}\n\n\n// Only display content to screen readers\n//\n// See: http://a11yproject.com/posts/how-to-hide-content\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0,0,0,0);\n border: 0;\n}\n\n// Use in conjunction with .sr-only to only display content when it's focused.\n// Useful for \"Skip to main content\" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1\n// Credit: HTML5 Boilerplate\n\n.sr-only-focusable {\n &:active,\n &:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n }\n}\n\n\n// iOS \"clickable elements\" fix for role=\"button\"\n//\n// Fixes \"clickability\" issue (and more generally, the firing of events such as focus as well)\n// for traditionally non-focusable elements with role=\"button\"\n// see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n\n[role=\"button\"] {\n cursor: pointer;\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// WebKit-style focus\n\n.tab-focus() {\n // WebKit-specific. Other browsers will keep their default outline style.\n // (Initially tried to also force default via `outline: initial`,\n // but that seems to erroneously remove the outline in Firefox altogether.)\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n.img-responsive(@display: block) {\n display: @display;\n max-width: 100%; // Part 1: Set a maximum relative to the parent\n height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching\n}\n\n\n// Retina image\n//\n// Short retina mixin for setting background-image and -size. Note that the\n// spelling of `min--moz-device-pixel-ratio` is intentional.\n.img-retina(@file-1x; @file-2x; @width-1x; @height-1x) {\n background-image: url(\"@{file-1x}\");\n\n @media\n only screen and (-webkit-min-device-pixel-ratio: 2),\n only screen and ( min--moz-device-pixel-ratio: 2),\n only screen and ( -o-min-device-pixel-ratio: 2/1),\n only screen and ( min-device-pixel-ratio: 2),\n only screen and ( min-resolution: 192dpi),\n only screen and ( min-resolution: 2dppx) {\n background-image: url(\"@{file-2x}\");\n background-size: @width-1x @height-1x;\n }\n}\n","//\n// Typography\n// --------------------------------------------------\n\n\n// Headings\n// -------------------------\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n font-family: @headings-font-family;\n font-weight: @headings-font-weight;\n line-height: @headings-line-height;\n color: @headings-color;\n\n small,\n .small {\n font-weight: normal;\n line-height: 1;\n color: @headings-small-color;\n }\n}\n\nh1, .h1,\nh2, .h2,\nh3, .h3 {\n margin-top: @line-height-computed;\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 65%;\n }\n}\nh4, .h4,\nh5, .h5,\nh6, .h6 {\n margin-top: (@line-height-computed / 2);\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 75%;\n }\n}\n\nh1, .h1 { font-size: @font-size-h1; }\nh2, .h2 { font-size: @font-size-h2; }\nh3, .h3 { font-size: @font-size-h3; }\nh4, .h4 { font-size: @font-size-h4; }\nh5, .h5 { font-size: @font-size-h5; }\nh6, .h6 { font-size: @font-size-h6; }\n\n\n// Body text\n// -------------------------\n\np {\n margin: 0 0 (@line-height-computed / 2);\n}\n\n.lead {\n margin-bottom: @line-height-computed;\n font-size: floor((@font-size-base * 1.15));\n font-weight: 300;\n line-height: 1.4;\n\n @media (min-width: @screen-sm-min) {\n font-size: (@font-size-base * 1.5);\n }\n}\n\n\n// Emphasis & misc\n// -------------------------\n\n// Ex: (12px small font / 14px base font) * 100% = about 85%\nsmall,\n.small {\n font-size: floor((100% * @font-size-small / @font-size-base));\n}\n\nmark,\n.mark {\n background-color: @state-warning-bg;\n padding: .2em;\n}\n\n// Alignment\n.text-left { text-align: left; }\n.text-right { text-align: right; }\n.text-center { text-align: center; }\n.text-justify { text-align: justify; }\n.text-nowrap { white-space: nowrap; }\n\n// Transformation\n.text-lowercase { text-transform: lowercase; }\n.text-uppercase { text-transform: uppercase; }\n.text-capitalize { text-transform: capitalize; }\n\n// Contextual colors\n.text-muted {\n color: @text-muted;\n}\n.text-primary {\n .text-emphasis-variant(@brand-primary);\n}\n.text-success {\n .text-emphasis-variant(@state-success-text);\n}\n.text-info {\n .text-emphasis-variant(@state-info-text);\n}\n.text-warning {\n .text-emphasis-variant(@state-warning-text);\n}\n.text-danger {\n .text-emphasis-variant(@state-danger-text);\n}\n\n// Contextual backgrounds\n// For now we'll leave these alongside the text classes until v4 when we can\n// safely shift things around (per SemVer rules).\n.bg-primary {\n // Given the contrast here, this is the only class to have its color inverted\n // automatically.\n color: #fff;\n .bg-variant(@brand-primary);\n}\n.bg-success {\n .bg-variant(@state-success-bg);\n}\n.bg-info {\n .bg-variant(@state-info-bg);\n}\n.bg-warning {\n .bg-variant(@state-warning-bg);\n}\n.bg-danger {\n .bg-variant(@state-danger-bg);\n}\n\n\n// Page header\n// -------------------------\n\n.page-header {\n padding-bottom: ((@line-height-computed / 2) - 1);\n margin: (@line-height-computed * 2) 0 @line-height-computed;\n border-bottom: 1px solid @page-header-border-color;\n}\n\n\n// Lists\n// -------------------------\n\n// Unordered and Ordered lists\nul,\nol {\n margin-top: 0;\n margin-bottom: (@line-height-computed / 2);\n ul,\n ol {\n margin-bottom: 0;\n }\n}\n\n// List options\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n .list-unstyled();\n margin-left: -5px;\n\n > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n }\n}\n\n// Description Lists\ndl {\n margin-top: 0; // Remove browser default\n margin-bottom: @line-height-computed;\n}\ndt,\ndd {\n line-height: @line-height-base;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0; // Undo browser default\n}\n\n// Horizontal description lists\n//\n// Defaults to being stacked without any of the below styles applied, until the\n// grid breakpoint is reached (default of ~768px).\n\n.dl-horizontal {\n dd {\n &:extend(.clearfix all); // Clear the floated `dt` if an empty `dd` is present\n }\n\n @media (min-width: @dl-horizontal-breakpoint) {\n dt {\n float: left;\n width: (@dl-horizontal-offset - 20);\n clear: left;\n text-align: right;\n .text-overflow();\n }\n dd {\n margin-left: @dl-horizontal-offset;\n }\n }\n}\n\n\n// Misc\n// -------------------------\n\n// Abbreviations and acronyms\nabbr[title],\n// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted @abbr-border-color;\n}\n.initialism {\n font-size: 90%;\n .text-uppercase();\n}\n\n// Blockquotes\nblockquote {\n padding: (@line-height-computed / 2) @line-height-computed;\n margin: 0 0 @line-height-computed;\n font-size: @blockquote-font-size;\n border-left: 5px solid @blockquote-border-color;\n\n p,\n ul,\n ol {\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n // Note: Deprecated small and .small as of v3.1.0\n // Context: https://github.com/twbs/bootstrap/issues/11660\n footer,\n small,\n .small {\n display: block;\n font-size: 80%; // back to default font-size\n line-height: @line-height-base;\n color: @blockquote-small-color;\n\n &:before {\n content: '\\2014 \\00A0'; // em dash, nbsp\n }\n }\n}\n\n// Opposite alignment of blockquote\n//\n// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0.\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid @blockquote-border-color;\n border-left: 0;\n text-align: right;\n\n // Account for citation\n footer,\n small,\n .small {\n &:before { content: ''; }\n &:after {\n content: '\\00A0 \\2014'; // nbsp, em dash\n }\n }\n}\n\n// Addresses\naddress {\n margin-bottom: @line-height-computed;\n font-style: normal;\n line-height: @line-height-base;\n}\n","// Typography\n\n.text-emphasis-variant(@color) {\n color: @color;\n a&:hover,\n a&:focus {\n color: darken(@color, 10%);\n }\n}\n","// Contextual backgrounds\n\n.bg-variant(@color) {\n background-color: @color;\n a&:hover,\n a&:focus {\n background-color: darken(@color, 10%);\n }\n}\n","// Text overflow\n// Requires inline-block or block for proper styling\n\n.text-overflow() {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n","//\n// Code (inline and block)\n// --------------------------------------------------\n\n\n// Inline and block code styles\ncode,\nkbd,\npre,\nsamp {\n font-family: @font-family-monospace;\n}\n\n// Inline code\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: @code-color;\n background-color: @code-bg;\n border-radius: @border-radius-base;\n}\n\n// User input typically entered via keyboard\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: @kbd-color;\n background-color: @kbd-bg;\n border-radius: @border-radius-small;\n box-shadow: inset 0 -1px 0 rgba(0,0,0,.25);\n\n kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n }\n}\n\n// Blocks of code\npre {\n display: block;\n padding: ((@line-height-computed - 1) / 2);\n margin: 0 0 (@line-height-computed / 2);\n font-size: (@font-size-base - 1); // 14px to 13px\n line-height: @line-height-base;\n word-break: break-all;\n word-wrap: break-word;\n color: @pre-color;\n background-color: @pre-bg;\n border: 1px solid @pre-border-color;\n border-radius: @border-radius-base;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n max-height: @pre-scrollable-max-height;\n overflow-y: scroll;\n}\n","//\n// Grid system\n// --------------------------------------------------\n\n\n// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n.container {\n .container-fixed();\n\n @media (min-width: @screen-sm-min) {\n width: @container-sm;\n }\n @media (min-width: @screen-md-min) {\n width: @container-md;\n }\n @media (min-width: @screen-lg-min) {\n width: @container-lg;\n }\n}\n\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but without any defined\n// width for fluid, full width layouts.\n\n.container-fluid {\n .container-fixed();\n}\n\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n.row {\n .make-row();\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n.make-grid-columns();\n\n\n// Extra small grid\n//\n// Columns, offsets, pushes, and pulls for extra small devices like\n// smartphones.\n\n.make-grid(xs);\n\n\n// Small grid\n//\n// Columns, offsets, pushes, and pulls for the small device range, from phones\n// to tablets.\n\n@media (min-width: @screen-sm-min) {\n .make-grid(sm);\n}\n\n\n// Medium grid\n//\n// Columns, offsets, pushes, and pulls for the desktop device range.\n\n@media (min-width: @screen-md-min) {\n .make-grid(md);\n}\n\n\n// Large grid\n//\n// Columns, offsets, pushes, and pulls for the large desktop device range.\n\n@media (min-width: @screen-lg-min) {\n .make-grid(lg);\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n// Centered container element\n.container-fixed(@gutter: @grid-gutter-width) {\n margin-right: auto;\n margin-left: auto;\n padding-left: floor((@gutter / 2));\n padding-right: ceil((@gutter / 2));\n &:extend(.clearfix all);\n}\n\n// Creates a wrapper for a series of columns\n.make-row(@gutter: @grid-gutter-width) {\n margin-left: ceil((@gutter / -2));\n margin-right: floor((@gutter / -2));\n &:extend(.clearfix all);\n}\n\n// Generate the extra small columns\n.make-xs-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n float: left;\n width: percentage((@columns / @grid-columns));\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n}\n.make-xs-column-offset(@columns) {\n margin-left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-push(@columns) {\n left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-pull(@columns) {\n right: percentage((@columns / @grid-columns));\n}\n\n// Generate the small columns\n.make-sm-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-sm-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-offset(@columns) {\n @media (min-width: @screen-sm-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-push(@columns) {\n @media (min-width: @screen-sm-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-pull(@columns) {\n @media (min-width: @screen-sm-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the medium columns\n.make-md-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-md-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-offset(@columns) {\n @media (min-width: @screen-md-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-push(@columns) {\n @media (min-width: @screen-md-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-pull(@columns) {\n @media (min-width: @screen-md-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the large columns\n.make-lg-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-lg-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-offset(@columns) {\n @media (min-width: @screen-lg-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-push(@columns) {\n @media (min-width: @screen-lg-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-pull(@columns) {\n @media (min-width: @screen-lg-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n","// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `@grid-columns`.\n\n.make-grid-columns() {\n // Common styles for all sizes of grid columns, widths 1-12\n .col(@index) { // initial\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general; \"=<\" isn't a typo\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n position: relative;\n // Prevent columns from collapsing when empty\n min-height: 1px;\n // Inner gutter via padding\n padding-left: ceil((@grid-gutter-width / 2));\n padding-right: floor((@grid-gutter-width / 2));\n }\n }\n .col(1); // kickstart it\n}\n\n.float-grid-columns(@class) {\n .col(@index) { // initial\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n float: left;\n }\n }\n .col(1); // kickstart it\n}\n\n.calc-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) {\n .col-@{class}-@{index} {\n width: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) {\n .col-@{class}-push-@{index} {\n left: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) {\n .col-@{class}-push-0 {\n left: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) {\n .col-@{class}-pull-@{index} {\n right: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) {\n .col-@{class}-pull-0 {\n right: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = offset) {\n .col-@{class}-offset-@{index} {\n margin-left: percentage((@index / @grid-columns));\n }\n}\n\n// Basic looping in LESS\n.loop-grid-columns(@index, @class, @type) when (@index >= 0) {\n .calc-grid-column(@index, @class, @type);\n // next iteration\n .loop-grid-columns((@index - 1), @class, @type);\n}\n\n// Create grid for specific class\n.make-grid(@class) {\n .float-grid-columns(@class);\n .loop-grid-columns(@grid-columns, @class, width);\n .loop-grid-columns(@grid-columns, @class, pull);\n .loop-grid-columns(@grid-columns, @class, push);\n .loop-grid-columns(@grid-columns, @class, offset);\n}\n","//\n// Tables\n// --------------------------------------------------\n\n\ntable {\n background-color: @table-bg;\n}\ncaption {\n padding-top: @table-cell-padding;\n padding-bottom: @table-cell-padding;\n color: @text-muted;\n text-align: left;\n}\nth {\n text-align: left;\n}\n\n\n// Baseline styles\n\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: @line-height-computed;\n // Cells\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-cell-padding;\n line-height: @line-height-base;\n vertical-align: top;\n border-top: 1px solid @table-border-color;\n }\n }\n }\n // Bottom align for column headings\n > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid @table-border-color;\n }\n // Remove top border from thead by default\n > caption + thead,\n > colgroup + thead,\n > thead:first-child {\n > tr:first-child {\n > th,\n > td {\n border-top: 0;\n }\n }\n }\n // Account for multiple tbody instances\n > tbody + tbody {\n border-top: 2px solid @table-border-color;\n }\n\n // Nesting\n .table {\n background-color: @body-bg;\n }\n}\n\n\n// Condensed table w/ half padding\n\n.table-condensed {\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-condensed-cell-padding;\n }\n }\n }\n}\n\n\n// Bordered version\n//\n// Add borders all around the table and between all the columns.\n\n.table-bordered {\n border: 1px solid @table-border-color;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n border: 1px solid @table-border-color;\n }\n }\n }\n > thead > tr {\n > th,\n > td {\n border-bottom-width: 2px;\n }\n }\n}\n\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n > tbody > tr:nth-of-type(odd) {\n background-color: @table-bg-accent;\n }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n > tbody > tr:hover {\n background-color: @table-bg-hover;\n }\n}\n\n\n// Table cell sizing\n//\n// Reset default table behavior\n\ntable col[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-column;\n}\ntable {\n td,\n th {\n &[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-cell;\n }\n }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n// Generate the contextual variants\n.table-row-variant(active; @table-bg-active);\n.table-row-variant(success; @state-success-bg);\n.table-row-variant(info; @state-info-bg);\n.table-row-variant(warning; @state-warning-bg);\n.table-row-variant(danger; @state-danger-bg);\n\n\n// Responsive tables\n//\n// Wrap your tables in `.table-responsive` and we'll make them mobile friendly\n// by enabling horizontal scrolling. Only applies <768px. Everything above that\n// will display normally.\n\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%; // Workaround for IE9 bug (see https://github.com/twbs/bootstrap/issues/14837)\n\n @media screen and (max-width: @screen-xs-max) {\n width: 100%;\n margin-bottom: (@line-height-computed * 0.75);\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid @table-border-color;\n\n // Tighten up spacing\n > .table {\n margin-bottom: 0;\n\n // Ensure the content doesn't wrap\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n white-space: nowrap;\n }\n }\n }\n }\n\n // Special overrides for the bordered tables\n > .table-bordered {\n border: 0;\n\n // Nuke the appropriate borders so that the parent can handle them\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n\n // Only nuke the last row's bottom-border in `tbody` and `tfoot` since\n // chances are there will be only one `tr` in a `thead` and that would\n // remove the border altogether.\n > tbody,\n > tfoot {\n > tr:last-child {\n > th,\n > td {\n border-bottom: 0;\n }\n }\n }\n\n }\n }\n}\n","// Tables\n\n.table-row-variant(@state; @background) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table > thead > tr,\n .table > tbody > tr,\n .table > tfoot > tr {\n > td.@{state},\n > th.@{state},\n &.@{state} > td,\n &.@{state} > th {\n background-color: @background;\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover > tbody > tr {\n > td.@{state}:hover,\n > th.@{state}:hover,\n &.@{state}:hover > td,\n &:hover > .@{state},\n &.@{state}:hover > th {\n background-color: darken(@background, 5%);\n }\n }\n}\n","//\n// Forms\n// --------------------------------------------------\n\n\n// Normalize non-controls\n//\n// Restyle and baseline non-control form elements.\n\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n // Chrome and Firefox set a `min-width: min-content;` on fieldsets,\n // so we reset that to ensure it behaves more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359.\n min-width: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: @line-height-computed;\n font-size: (@font-size-base * 1.5);\n line-height: inherit;\n color: @legend-color;\n border: 0;\n border-bottom: 1px solid @legend-border-color;\n}\n\nlabel {\n display: inline-block;\n max-width: 100%; // Force IE8 to wrap long content (see https://github.com/twbs/bootstrap/issues/13141)\n margin-bottom: 5px;\n font-weight: bold;\n}\n\n\n// Normalize form controls\n//\n// While most of our form styles require extra classes, some basic normalization\n// is required to ensure optimum display with or without those classes to better\n// address browser inconsistencies.\n\n// Override content-box in Normalize (* isn't specific enough)\ninput[type=\"search\"] {\n .box-sizing(border-box);\n}\n\n// Position radios and checkboxes better\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9; // IE8-9\n line-height: normal;\n}\n\ninput[type=\"file\"] {\n display: block;\n}\n\n// Make range inputs behave like textual form controls\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\n\n// Make multiple select elements height not fixed\nselect[multiple],\nselect[size] {\n height: auto;\n}\n\n// Focus for file, radio, and checkbox\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n .tab-focus();\n}\n\n// Adjust output element\noutput {\n display: block;\n padding-top: (@padding-base-vertical + 1);\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n}\n\n\n// Common form controls\n//\n// Shared size and type resets for form controls. Apply `.form-control` to any\n// of the following form controls:\n//\n// select\n// textarea\n// input[type=\"text\"]\n// input[type=\"password\"]\n// input[type=\"datetime\"]\n// input[type=\"datetime-local\"]\n// input[type=\"date\"]\n// input[type=\"month\"]\n// input[type=\"time\"]\n// input[type=\"week\"]\n// input[type=\"number\"]\n// input[type=\"email\"]\n// input[type=\"url\"]\n// input[type=\"search\"]\n// input[type=\"tel\"]\n// input[type=\"color\"]\n\n.form-control {\n display: block;\n width: 100%;\n height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n background-color: @input-bg;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid @input-border;\n border-radius: @input-border-radius; // Note: This has no effect on s in CSS.\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.075));\n .transition(~\"border-color ease-in-out .15s, box-shadow ease-in-out .15s\");\n\n // Customize the `:focus` state to imitate native WebKit styles.\n .form-control-focus();\n\n // Placeholder\n .placeholder();\n\n // Unstyle the caret on ``\n// element gets special love because it's special, and that's a fact!\n.input-size(@input-height; @padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n height: @input-height;\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n\n select& {\n height: @input-height;\n line-height: @input-height;\n }\n\n textarea&,\n select[multiple]& {\n height: auto;\n }\n}\n","//\n// Buttons\n// --------------------------------------------------\n\n\n// Base styles\n// --------------------------------------------------\n\n.btn {\n display: inline-block;\n margin-bottom: 0; // For input.btn\n font-weight: @btn-font-weight;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n white-space: nowrap;\n .button-size(@padding-base-vertical; @padding-base-horizontal; @font-size-base; @line-height-base; @btn-border-radius-base);\n .user-select(none);\n\n &,\n &:active,\n &.active {\n &:focus,\n &.focus {\n .tab-focus();\n }\n }\n\n &:hover,\n &:focus,\n &.focus {\n color: @btn-default-color;\n text-decoration: none;\n }\n\n &:active,\n &.active {\n outline: 0;\n background-image: none;\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n .opacity(.65);\n .box-shadow(none);\n }\n\n a& {\n &.disabled,\n fieldset[disabled] & {\n pointer-events: none; // Future-proof disabling of clicks on `` elements\n }\n }\n}\n\n\n// Alternate buttons\n// --------------------------------------------------\n\n.btn-default {\n .button-variant(@btn-default-color; @btn-default-bg; @btn-default-border);\n}\n.btn-primary {\n .button-variant(@btn-primary-color; @btn-primary-bg; @btn-primary-border);\n}\n// Success appears as green\n.btn-success {\n .button-variant(@btn-success-color; @btn-success-bg; @btn-success-border);\n}\n// Info appears as blue-green\n.btn-info {\n .button-variant(@btn-info-color; @btn-info-bg; @btn-info-border);\n}\n// Warning appears as orange\n.btn-warning {\n .button-variant(@btn-warning-color; @btn-warning-bg; @btn-warning-border);\n}\n// Danger and error appear as red\n.btn-danger {\n .button-variant(@btn-danger-color; @btn-danger-bg; @btn-danger-border);\n}\n\n\n// Link buttons\n// -------------------------\n\n// Make a button look and behave like a link\n.btn-link {\n color: @link-color;\n font-weight: normal;\n border-radius: 0;\n\n &,\n &:active,\n &.active,\n &[disabled],\n fieldset[disabled] & {\n background-color: transparent;\n .box-shadow(none);\n }\n &,\n &:hover,\n &:focus,\n &:active {\n border-color: transparent;\n }\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n background-color: transparent;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @btn-link-disabled-color;\n text-decoration: none;\n }\n }\n}\n\n\n// Button Sizes\n// --------------------------------------------------\n\n.btn-lg {\n // line-height: ensure even-numbered height of button next to large input\n .button-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @btn-border-radius-large);\n}\n.btn-sm {\n // line-height: ensure proper height of button next to small input\n .button-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n.btn-xs {\n .button-size(@padding-xs-vertical; @padding-xs-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n\n\n// Block button\n// --------------------------------------------------\n\n.btn-block {\n display: block;\n width: 100%;\n}\n\n// Vertically space out multiple block buttons\n.btn-block + .btn-block {\n margin-top: 5px;\n}\n\n// Specificity overrides\ninput[type=\"submit\"],\ninput[type=\"reset\"],\ninput[type=\"button\"] {\n &.btn-block {\n width: 100%;\n }\n}\n","// Button variants\n//\n// Easily pump out default styles, as well as :hover, :focus, :active,\n// and disabled options for all buttons\n\n.button-variant(@color; @background; @border) {\n color: @color;\n background-color: @background;\n border-color: @border;\n\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 25%);\n }\n &:hover {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n\n &:hover,\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 17%);\n border-color: darken(@border, 25%);\n }\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n background-image: none;\n }\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus,\n &.focus {\n background-color: @background;\n border-color: @border;\n }\n }\n\n .badge {\n color: @background;\n background-color: @color;\n }\n}\n\n// Button sizes\n.button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n}\n","// Opacity\n\n.opacity(@opacity) {\n opacity: @opacity;\n // IE8 filter\n @opacity-ie: (@opacity * 100);\n filter: ~\"alpha(opacity=@{opacity-ie})\";\n}\n","//\n// Component animations\n// --------------------------------------------------\n\n// Heads up!\n//\n// We don't use the `.opacity()` mixin here since it causes a bug with text\n// fields in IE7-8. Source: https://github.com/twbs/bootstrap/pull/3552.\n\n.fade {\n opacity: 0;\n .transition(opacity .15s linear);\n &.in {\n opacity: 1;\n }\n}\n\n.collapse {\n display: none;\n\n &.in { display: block; }\n tr&.in { display: table-row; }\n tbody&.in { display: table-row-group; }\n}\n\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n .transition-property(~\"height, visibility\");\n .transition-duration(.35s);\n .transition-timing-function(ease);\n}\n","//\n// Dropdown menus\n// --------------------------------------------------\n\n\n// Dropdown arrow/caret\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: @caret-width-base dashed;\n border-top: @caret-width-base solid ~\"\\9\"; // IE8\n border-right: @caret-width-base solid transparent;\n border-left: @caret-width-base solid transparent;\n}\n\n// The dropdown wrapper (div)\n.dropup,\n.dropdown {\n position: relative;\n}\n\n// Prevent the focus on the dropdown toggle when closing dropdowns\n.dropdown-toggle:focus {\n outline: 0;\n}\n\n// The dropdown menu (ul)\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: @zindex-dropdown;\n display: none; // none by default, but block on \"open\" of the menu\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0; // override default ul\n list-style: none;\n font-size: @font-size-base;\n text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)\n background-color: @dropdown-bg;\n border: 1px solid @dropdown-fallback-border; // IE8 fallback\n border: 1px solid @dropdown-border;\n border-radius: @border-radius-base;\n .box-shadow(0 6px 12px rgba(0,0,0,.175));\n background-clip: padding-box;\n\n // Aligns the dropdown menu to right\n //\n // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]`\n &.pull-right {\n right: 0;\n left: auto;\n }\n\n // Dividers (basically an hr) within the dropdown\n .divider {\n .nav-divider(@dropdown-divider-bg);\n }\n\n // Links within the dropdown menu\n > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: @line-height-base;\n color: @dropdown-link-color;\n white-space: nowrap; // prevent links from randomly breaking onto new lines\n }\n}\n\n// Hover/Focus state\n.dropdown-menu > li > a {\n &:hover,\n &:focus {\n text-decoration: none;\n color: @dropdown-link-hover-color;\n background-color: @dropdown-link-hover-bg;\n }\n}\n\n// Active state\n.dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-active-color;\n text-decoration: none;\n outline: 0;\n background-color: @dropdown-link-active-bg;\n }\n}\n\n// Disabled state\n//\n// Gray out text and ensure the hover/focus state remains gray\n\n.dropdown-menu > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-disabled-color;\n }\n\n // Nuke hover/focus effects\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none; // Remove CSS gradient\n .reset-filter();\n cursor: @cursor-disabled;\n }\n}\n\n// Open state for the dropdown\n.open {\n // Show the menu\n > .dropdown-menu {\n display: block;\n }\n\n // Remove the outline when :focus is triggered\n > a {\n outline: 0;\n }\n}\n\n// Menu positioning\n//\n// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown\n// menu with the parent.\n.dropdown-menu-right {\n left: auto; // Reset the default from `.dropdown-menu`\n right: 0;\n}\n// With v3, we enabled auto-flipping if you have a dropdown within a right\n// aligned nav component. To enable the undoing of that, we provide an override\n// to restore the default dropdown menu alignment.\n//\n// This is only for left-aligning a dropdown menu within a `.navbar-right` or\n// `.pull-right` nav component.\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n\n// Dropdown section headers\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: @font-size-small;\n line-height: @line-height-base;\n color: @dropdown-header-color;\n white-space: nowrap; // as with > li > a\n}\n\n// Backdrop to catch body clicks on mobile, etc.\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: (@zindex-dropdown - 10);\n}\n\n// Right aligned dropdowns\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n\n// Allow for dropdowns to go bottom up (aka, dropup-menu)\n//\n// Just add .dropup after the standard .dropdown class and you're set, bro.\n// TODO: abstract this so that the navbar fixed styles are not placed here?\n\n.dropup,\n.navbar-fixed-bottom .dropdown {\n // Reverse the caret\n .caret {\n border-top: 0;\n border-bottom: @caret-width-base dashed;\n border-bottom: @caret-width-base solid ~\"\\9\"; // IE8\n content: \"\";\n }\n // Different positioning for bottom up menu\n .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n }\n}\n\n\n// Component alignment\n//\n// Reiterate per navbar.less and the modified component alignment there.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-right {\n .dropdown-menu {\n .dropdown-menu-right();\n }\n // Necessary for overrides of the default right aligned menu.\n // Will remove come v4 in all likelihood.\n .dropdown-menu-left {\n .dropdown-menu-left();\n }\n }\n}\n","// Horizontal dividers\n//\n// Dividers (basically an hr) within dropdowns and nav lists\n\n.nav-divider(@color: #e5e5e5) {\n height: 1px;\n margin: ((@line-height-computed / 2) - 1) 0;\n overflow: hidden;\n background-color: @color;\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n","//\n// Button groups\n// --------------------------------------------------\n\n// Make the div behave like a button\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle; // match .btn alignment given font-size hack above\n > .btn {\n position: relative;\n float: left;\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active,\n &.active {\n z-index: 2;\n }\n }\n}\n\n// Prevent double borders when buttons are next to each other\n.btn-group {\n .btn + .btn,\n .btn + .btn-group,\n .btn-group + .btn,\n .btn-group + .btn-group {\n margin-left: -1px;\n }\n}\n\n// Optional: Group multiple button groups together for a toolbar\n.btn-toolbar {\n margin-left: -5px; // Offset the first child's margin\n &:extend(.clearfix all);\n\n .btn,\n .btn-group,\n .input-group {\n float: left;\n }\n > .btn,\n > .btn-group,\n > .input-group {\n margin-left: 5px;\n }\n}\n\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n\n// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match\n.btn-group > .btn:first-child {\n margin-left: 0;\n &:not(:last-child):not(.dropdown-toggle) {\n .border-right-radius(0);\n }\n}\n// Need .dropdown-toggle since :last-child doesn't apply, given that a .dropdown-menu is used immediately after it\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n .border-left-radius(0);\n}\n\n// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group)\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-right-radius(0);\n }\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-left-radius(0);\n}\n\n// On active and open, don't show outline\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n\n\n// Sizing\n//\n// Remix the default button sizing classes into new ones for easier manipulation.\n\n.btn-group-xs > .btn { &:extend(.btn-xs); }\n.btn-group-sm > .btn { &:extend(.btn-sm); }\n.btn-group-lg > .btn { &:extend(.btn-lg); }\n\n\n// Split button dropdowns\n// ----------------------\n\n// Give the line between buttons some depth\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n\n// The clickable button for toggling the menu\n// Remove the gradient and set the same inset shadow as the :active state\n.btn-group.open .dropdown-toggle {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n\n // Show no shadow for `.btn-link` since it has no other button styles.\n &.btn-link {\n .box-shadow(none);\n }\n}\n\n\n// Reposition the caret\n.btn .caret {\n margin-left: 0;\n}\n// Carets in other button sizes\n.btn-lg .caret {\n border-width: @caret-width-large @caret-width-large 0;\n border-bottom-width: 0;\n}\n// Upside down carets for .dropup\n.dropup .btn-lg .caret {\n border-width: 0 @caret-width-large @caret-width-large;\n}\n\n\n// Vertical button groups\n// ----------------------\n\n.btn-group-vertical {\n > .btn,\n > .btn-group,\n > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n }\n\n // Clear floats so dropdown menus can be properly placed\n > .btn-group {\n &:extend(.clearfix all);\n > .btn {\n float: none;\n }\n }\n\n > .btn + .btn,\n > .btn + .btn-group,\n > .btn-group + .btn,\n > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n }\n}\n\n.btn-group-vertical > .btn {\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n &:first-child:not(:last-child) {\n .border-top-radius(@btn-border-radius-base);\n .border-bottom-radius(0);\n }\n &:last-child:not(:first-child) {\n .border-top-radius(0);\n .border-bottom-radius(@btn-border-radius-base);\n }\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-bottom-radius(0);\n }\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-top-radius(0);\n}\n\n\n// Justified button groups\n// ----------------------\n\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n > .btn,\n > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n }\n > .btn-group .btn {\n width: 100%;\n }\n\n > .btn-group .dropdown-menu {\n left: auto;\n }\n}\n\n\n// Checkbox and radio options\n//\n// In order to support the browser's form validation feedback, powered by the\n// `required` attribute, we have to \"hide\" the inputs via `clip`. We cannot use\n// `display: none;` or `visibility: hidden;` as that also hides the popover.\n// Simply visually hiding the inputs via `opacity` would leave them clickable in\n// certain cases which is prevented by using `clip` and `pointer-events`.\n// This way, we ensure a DOM element is visible to position the popover from.\n//\n// See https://github.com/twbs/bootstrap/pull/12794 and\n// https://github.com/twbs/bootstrap/pull/14559 for more information.\n\n[data-toggle=\"buttons\"] {\n > .btn,\n > .btn-group > .btn {\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0,0,0,0);\n pointer-events: none;\n }\n }\n}\n","// Single side border-radius\n\n.border-top-radius(@radius) {\n border-top-right-radius: @radius;\n border-top-left-radius: @radius;\n}\n.border-right-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-top-right-radius: @radius;\n}\n.border-bottom-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-bottom-left-radius: @radius;\n}\n.border-left-radius(@radius) {\n border-bottom-left-radius: @radius;\n border-top-left-radius: @radius;\n}\n","//\n// Input groups\n// --------------------------------------------------\n\n// Base styles\n// -------------------------\n.input-group {\n position: relative; // For dropdowns\n display: table;\n border-collapse: separate; // prevent input groups from inheriting border styles from table cells when placed within a table\n\n // Undo padding and float of grid classes\n &[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n }\n\n .form-control {\n // Ensure that the input is always above the *appended* addon button for\n // proper border colors.\n position: relative;\n z-index: 2;\n\n // IE9 fubars the placeholder attribute in text inputs and the arrows on\n // select elements in input groups. To fix it, we float the input. Details:\n // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855\n float: left;\n\n width: 100%;\n margin-bottom: 0;\n\n &:focus {\n z-index: 3;\n }\n }\n}\n\n// Sizing options\n//\n// Remix the default form control sizing classes into new ones for easier\n// manipulation.\n\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n .input-lg();\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n .input-sm();\n}\n\n\n// Display as table-cell\n// -------------------------\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n}\n// Addon and addon wrapper for buttons\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle; // Match the inputs\n}\n\n// Text input groups\n// -------------------------\n.input-group-addon {\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n font-weight: normal;\n line-height: 1;\n color: @input-color;\n text-align: center;\n background-color: @input-group-addon-bg;\n border: 1px solid @input-group-addon-border-color;\n border-radius: @input-border-radius;\n\n // Sizing\n &.input-sm {\n padding: @padding-small-vertical @padding-small-horizontal;\n font-size: @font-size-small;\n border-radius: @input-border-radius-small;\n }\n &.input-lg {\n padding: @padding-large-vertical @padding-large-horizontal;\n font-size: @font-size-large;\n border-radius: @input-border-radius-large;\n }\n\n // Nuke default margins from checkboxes and radios to vertically center within.\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n margin-top: 0;\n }\n}\n\n// Reset rounded corners\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n .border-right-radius(0);\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n .border-left-radius(0);\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n\n// Button input groups\n// -------------------------\n.input-group-btn {\n position: relative;\n // Jankily prevent input button groups from wrapping with `white-space` and\n // `font-size` in combination with `inline-block` on buttons.\n font-size: 0;\n white-space: nowrap;\n\n // Negative margin for spacing, position for bringing hovered/focused/actived\n // element above the siblings.\n > .btn {\n position: relative;\n + .btn {\n margin-left: -1px;\n }\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active {\n z-index: 2;\n }\n }\n\n // Negative margin to only have a 1px border between the two\n &:first-child {\n > .btn,\n > .btn-group {\n margin-right: -1px;\n }\n }\n &:last-child {\n > .btn,\n > .btn-group {\n z-index: 2;\n margin-left: -1px;\n }\n }\n}\n","//\n// Navs\n// --------------------------------------------------\n\n\n// Base class\n// --------------------------------------------------\n\n.nav {\n margin-bottom: 0;\n padding-left: 0; // Override default ul/ol\n list-style: none;\n &:extend(.clearfix all);\n\n > li {\n position: relative;\n display: block;\n\n > a {\n position: relative;\n display: block;\n padding: @nav-link-padding;\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: @nav-link-hover-bg;\n }\n }\n\n // Disabled state sets text to gray and nukes hover/tab effects\n &.disabled > a {\n color: @nav-disabled-link-color;\n\n &:hover,\n &:focus {\n color: @nav-disabled-link-hover-color;\n text-decoration: none;\n background-color: transparent;\n cursor: @cursor-disabled;\n }\n }\n }\n\n // Open dropdowns\n .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @nav-link-hover-bg;\n border-color: @link-color;\n }\n }\n\n // Nav dividers (deprecated with v3.0.1)\n //\n // This should have been removed in v3 with the dropping of `.nav-list`, but\n // we missed it. We don't currently support this anywhere, but in the interest\n // of maintaining backward compatibility in case you use it, it's deprecated.\n .nav-divider {\n .nav-divider();\n }\n\n // Prevent IE8 from misplacing imgs\n //\n // See https://github.com/h5bp/html5-boilerplate/issues/984#issuecomment-3985989\n > li > a > img {\n max-width: none;\n }\n}\n\n\n// Tabs\n// -------------------------\n\n// Give the tabs something to sit on\n.nav-tabs {\n border-bottom: 1px solid @nav-tabs-border-color;\n > li {\n float: left;\n // Make the list-items overlay the bottom border\n margin-bottom: -1px;\n\n // Actual tabs (as links)\n > a {\n margin-right: 2px;\n line-height: @line-height-base;\n border: 1px solid transparent;\n border-radius: @border-radius-base @border-radius-base 0 0;\n &:hover {\n border-color: @nav-tabs-link-hover-border-color @nav-tabs-link-hover-border-color @nav-tabs-border-color;\n }\n }\n\n // Active state, and its :hover to override normal :hover\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-tabs-active-link-hover-color;\n background-color: @nav-tabs-active-link-hover-bg;\n border: 1px solid @nav-tabs-active-link-hover-border-color;\n border-bottom-color: transparent;\n cursor: default;\n }\n }\n }\n // pulling this in mainly for less shorthand\n &.nav-justified {\n .nav-justified();\n .nav-tabs-justified();\n }\n}\n\n\n// Pills\n// -------------------------\n.nav-pills {\n > li {\n float: left;\n\n // Links rendered as pills\n > a {\n border-radius: @nav-pills-border-radius;\n }\n + li {\n margin-left: 2px;\n }\n\n // Active state\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-pills-active-link-hover-color;\n background-color: @nav-pills-active-link-hover-bg;\n }\n }\n }\n}\n\n\n// Stacked pills\n.nav-stacked {\n > li {\n float: none;\n + li {\n margin-top: 2px;\n margin-left: 0; // no need for this gap between nav items\n }\n }\n}\n\n\n// Nav variations\n// --------------------------------------------------\n\n// Justified nav links\n// -------------------------\n\n.nav-justified {\n width: 100%;\n\n > li {\n float: none;\n > a {\n text-align: center;\n margin-bottom: 5px;\n }\n }\n\n > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n }\n\n @media (min-width: @screen-sm-min) {\n > li {\n display: table-cell;\n width: 1%;\n > a {\n margin-bottom: 0;\n }\n }\n }\n}\n\n// Move borders to anchors instead of bottom of list\n//\n// Mixin for adding on top the shared `.nav-justified` styles for our tabs\n.nav-tabs-justified {\n border-bottom: 0;\n\n > li > a {\n // Override margin from .nav-tabs\n margin-right: 0;\n border-radius: @border-radius-base;\n }\n\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border: 1px solid @nav-tabs-justified-link-border-color;\n }\n\n @media (min-width: @screen-sm-min) {\n > li > a {\n border-bottom: 1px solid @nav-tabs-justified-link-border-color;\n border-radius: @border-radius-base @border-radius-base 0 0;\n }\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border-bottom-color: @nav-tabs-justified-active-link-border-color;\n }\n }\n}\n\n\n// Tabbable tabs\n// -------------------------\n\n// Hide tabbable panes to start, show them when `.active`\n.tab-content {\n > .tab-pane {\n display: none;\n }\n > .active {\n display: block;\n }\n}\n\n\n// Dropdowns\n// -------------------------\n\n// Specific dropdowns\n.nav-tabs .dropdown-menu {\n // make dropdown border overlap tab border\n margin-top: -1px;\n // Remove the top rounded corners here since there is a hard edge above the menu\n .border-top-radius(0);\n}\n","//\n// Navbars\n// --------------------------------------------------\n\n\n// Wrapper and base class\n//\n// Provide a static navbar from which we expand to create full-width, fixed, and\n// other navbar variations.\n\n.navbar {\n position: relative;\n min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode)\n margin-bottom: @navbar-margin-bottom;\n border: 1px solid transparent;\n\n // Prevent floats from breaking the navbar\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: @navbar-border-radius;\n }\n}\n\n\n// Navbar heading\n//\n// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy\n// styling of responsive aspects.\n\n.navbar-header {\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n }\n}\n\n\n// Navbar collapse (body)\n//\n// Group your navbar content into this for easy collapsing and expanding across\n// various device sizes. By default, this content is collapsed when <768px, but\n// will expand past that for a horizontal display.\n//\n// To start (on mobile devices) the navbar links, forms, and buttons are stacked\n// vertically and include a `max-height` to overflow in case you have too much\n// content for the user's viewport.\n\n.navbar-collapse {\n overflow-x: visible;\n padding-right: @navbar-padding-horizontal;\n padding-left: @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.1);\n &:extend(.clearfix all);\n -webkit-overflow-scrolling: touch;\n\n &.in {\n overflow-y: auto;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border-top: 0;\n box-shadow: none;\n\n &.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0; // Override default setting\n overflow: visible !important;\n }\n\n &.in {\n overflow-y: visible;\n }\n\n // Undo the collapse side padding for navbars with containers to ensure\n // alignment of right-aligned contents.\n .navbar-fixed-top &,\n .navbar-static-top &,\n .navbar-fixed-bottom & {\n padding-left: 0;\n padding-right: 0;\n }\n }\n}\n\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n .navbar-collapse {\n max-height: @navbar-collapse-max-height;\n\n @media (max-device-width: @screen-xs-min) and (orientation: landscape) {\n max-height: 200px;\n }\n }\n}\n\n\n// Both navbar header and collapse\n//\n// When a container is present, change the behavior of the header and collapse.\n\n.container,\n.container-fluid {\n > .navbar-header,\n > .navbar-collapse {\n margin-right: -@navbar-padding-horizontal;\n margin-left: -@navbar-padding-horizontal;\n\n @media (min-width: @grid-float-breakpoint) {\n margin-right: 0;\n margin-left: 0;\n }\n }\n}\n\n\n//\n// Navbar alignment options\n//\n// Display the navbar across the entirety of the page or fixed it to the top or\n// bottom of the page.\n\n// Static top (unfixed, but 100% wide) navbar\n.navbar-static-top {\n z-index: @zindex-navbar;\n border-width: 0 0 1px;\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n\n// Fix the top/bottom navbars when screen real estate supports it\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: @zindex-navbar-fixed;\n\n // Undo the rounded corners\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0; // override .navbar defaults\n border-width: 1px 0 0;\n}\n\n\n// Brand/project name\n\n.navbar-brand {\n float: left;\n padding: @navbar-padding-vertical @navbar-padding-horizontal;\n font-size: @font-size-large;\n line-height: @line-height-computed;\n height: @navbar-height;\n\n &:hover,\n &:focus {\n text-decoration: none;\n }\n\n > img {\n display: block;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n .navbar > .container &,\n .navbar > .container-fluid & {\n margin-left: -@navbar-padding-horizontal;\n }\n }\n}\n\n\n// Navbar toggle\n//\n// Custom button for toggling the `.navbar-collapse`, powered by the collapse\n// JavaScript plugin.\n\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: @navbar-padding-horizontal;\n padding: 9px 10px;\n .navbar-vertical-align(34px);\n background-color: transparent;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n border-radius: @border-radius-base;\n\n // We remove the `outline` here, but later compensate by attaching `:hover`\n // styles to `:focus`.\n &:focus {\n outline: 0;\n }\n\n // Bars\n .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n }\n .icon-bar + .icon-bar {\n margin-top: 4px;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n display: none;\n }\n}\n\n\n// Navbar nav links\n//\n// Builds on top of the `.nav` components with its own modifier class to make\n// the nav the full height of the horizontal nav (above 768px).\n\n.navbar-nav {\n margin: (@navbar-padding-vertical / 2) -@navbar-padding-horizontal;\n\n > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: @line-height-computed;\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n > li > a,\n .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n > li > a {\n line-height: @line-height-computed;\n &:hover,\n &:focus {\n background-image: none;\n }\n }\n }\n }\n\n // Uncollapse the nav\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin: 0;\n\n > li {\n float: left;\n > a {\n padding-top: @navbar-padding-vertical;\n padding-bottom: @navbar-padding-vertical;\n }\n }\n }\n}\n\n\n// Navbar form\n//\n// Extension of the `.form-inline` with some extra flavor for optimum display in\n// our navbars.\n\n.navbar-form {\n margin-left: -@navbar-padding-horizontal;\n margin-right: -@navbar-padding-horizontal;\n padding: 10px @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n @shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n\n // Mixin behavior for optimum display\n .form-inline();\n\n .form-group {\n @media (max-width: @grid-float-breakpoint-max) {\n margin-bottom: 5px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n }\n\n // Vertically center in expanded, horizontal navbar\n .navbar-vertical-align(@input-height-base);\n\n // Undo 100% width for pull classes\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n .box-shadow(none);\n }\n}\n\n\n// Dropdown menus\n\n// Menu position and menu carets\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n .border-top-radius(0);\n}\n// Menu position and menu caret support for dropups via extra dropup class\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n .border-top-radius(@navbar-border-radius);\n .border-bottom-radius(0);\n}\n\n\n// Buttons in navbars\n//\n// Vertically center a button within a navbar (when *not* in a form).\n\n.navbar-btn {\n .navbar-vertical-align(@input-height-base);\n\n &.btn-sm {\n .navbar-vertical-align(@input-height-small);\n }\n &.btn-xs {\n .navbar-vertical-align(22);\n }\n}\n\n\n// Text in navbars\n//\n// Add a class to make any element properly align itself vertically within the navbars.\n\n.navbar-text {\n .navbar-vertical-align(@line-height-computed);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin-left: @navbar-padding-horizontal;\n margin-right: @navbar-padding-horizontal;\n }\n}\n\n\n// Component alignment\n//\n// Repurpose the pull utilities as their own navbar utilities to avoid specificity\n// issues with parents and chaining. Only do this when the navbar is uncollapsed\n// though so that navbar contents properly stack and align in mobile.\n//\n// Declared after the navbar components to ensure more specificity on the margins.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-left { .pull-left(); }\n .navbar-right {\n .pull-right();\n margin-right: -@navbar-padding-horizontal;\n\n ~ .navbar-right {\n margin-right: 0;\n }\n }\n}\n\n\n// Alternate navbars\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n background-color: @navbar-default-bg;\n border-color: @navbar-default-border;\n\n .navbar-brand {\n color: @navbar-default-brand-color;\n &:hover,\n &:focus {\n color: @navbar-default-brand-hover-color;\n background-color: @navbar-default-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-default-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-default-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n\n .navbar-toggle {\n border-color: @navbar-default-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-default-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-default-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: @navbar-default-border;\n }\n\n // Dropdown menu items\n .navbar-nav {\n // Remove background color from open dropdown\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-default-link-active-bg;\n color: @navbar-default-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n > li > a {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n }\n }\n\n\n // Links in navbars\n //\n // Add a class to ensure links outside the navbar nav are colored correctly.\n\n .navbar-link {\n color: @navbar-default-link-color;\n &:hover {\n color: @navbar-default-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n }\n }\n }\n}\n\n// Inverse navbar\n\n.navbar-inverse {\n background-color: @navbar-inverse-bg;\n border-color: @navbar-inverse-border;\n\n .navbar-brand {\n color: @navbar-inverse-brand-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-brand-hover-color;\n background-color: @navbar-inverse-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-inverse-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-inverse-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n\n // Darken the responsive nav toggle\n .navbar-toggle {\n border-color: @navbar-inverse-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-inverse-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-inverse-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: darken(@navbar-inverse-bg, 7%);\n }\n\n // Dropdowns\n .navbar-nav {\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-inverse-link-active-bg;\n color: @navbar-inverse-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display\n .open .dropdown-menu {\n > .dropdown-header {\n border-color: @navbar-inverse-border;\n }\n .divider {\n background-color: @navbar-inverse-border;\n }\n > li > a {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n }\n }\n\n .navbar-link {\n color: @navbar-inverse-link-color;\n &:hover {\n color: @navbar-inverse-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n }\n }\n }\n}\n","// Navbar vertical align\n//\n// Vertically center elements in the navbar.\n// Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin.\n\n.navbar-vertical-align(@element-height) {\n margin-top: ((@navbar-height - @element-height) / 2);\n margin-bottom: ((@navbar-height - @element-height) / 2);\n}\n","//\n// Utility classes\n// --------------------------------------------------\n\n\n// Floats\n// -------------------------\n\n.clearfix {\n .clearfix();\n}\n.center-block {\n .center-block();\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n\n\n// Toggling content\n// -------------------------\n\n// Note: Deprecated .hide in favor of .hidden or .sr-only (as appropriate) in v3.0.1\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n .text-hide();\n}\n\n\n// Hide from screenreaders and browsers\n//\n// Credit: HTML5 Boilerplate\n\n.hidden {\n display: none !important;\n}\n\n\n// For Affix plugin\n// -------------------------\n\n.affix {\n position: fixed;\n}\n","//\n// Breadcrumbs\n// --------------------------------------------------\n\n\n.breadcrumb {\n padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal;\n margin-bottom: @line-height-computed;\n list-style: none;\n background-color: @breadcrumb-bg;\n border-radius: @border-radius-base;\n\n > li {\n display: inline-block;\n\n + li:before {\n content: \"@{breadcrumb-separator}\\00a0\"; // Unicode space added since inline-block means non-collapsing white-space\n padding: 0 5px;\n color: @breadcrumb-color;\n }\n }\n\n > .active {\n color: @breadcrumb-active-color;\n }\n}\n","//\n// Pagination (multiple pages)\n// --------------------------------------------------\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: @line-height-computed 0;\n border-radius: @border-radius-base;\n\n > li {\n display: inline; // Remove list-style and block-level defaults\n > a,\n > span {\n position: relative;\n float: left; // Collapse white-space\n padding: @padding-base-vertical @padding-base-horizontal;\n line-height: @line-height-base;\n text-decoration: none;\n color: @pagination-color;\n background-color: @pagination-bg;\n border: 1px solid @pagination-border;\n margin-left: -1px;\n }\n &:first-child {\n > a,\n > span {\n margin-left: 0;\n .border-left-radius(@border-radius-base);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius-base);\n }\n }\n }\n\n > li > a,\n > li > span {\n &:hover,\n &:focus {\n z-index: 2;\n color: @pagination-hover-color;\n background-color: @pagination-hover-bg;\n border-color: @pagination-hover-border;\n }\n }\n\n > .active > a,\n > .active > span {\n &,\n &:hover,\n &:focus {\n z-index: 3;\n color: @pagination-active-color;\n background-color: @pagination-active-bg;\n border-color: @pagination-active-border;\n cursor: default;\n }\n }\n\n > .disabled {\n > span,\n > span:hover,\n > span:focus,\n > a,\n > a:hover,\n > a:focus {\n color: @pagination-disabled-color;\n background-color: @pagination-disabled-bg;\n border-color: @pagination-disabled-border;\n cursor: @cursor-disabled;\n }\n }\n}\n\n// Sizing\n// --------------------------------------------------\n\n// Large\n.pagination-lg {\n .pagination-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large);\n}\n\n// Small\n.pagination-sm {\n .pagination-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small);\n}\n","// Pagination\n\n.pagination-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n > li {\n > a,\n > span {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n }\n &:first-child {\n > a,\n > span {\n .border-left-radius(@border-radius);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius);\n }\n }\n }\n}\n","//\n// Pager pagination\n// --------------------------------------------------\n\n\n.pager {\n padding-left: 0;\n margin: @line-height-computed 0;\n list-style: none;\n text-align: center;\n &:extend(.clearfix all);\n li {\n display: inline;\n > a,\n > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: @pager-bg;\n border: 1px solid @pager-border;\n border-radius: @pager-border-radius;\n }\n\n > a:hover,\n > a:focus {\n text-decoration: none;\n background-color: @pager-hover-bg;\n }\n }\n\n .next {\n > a,\n > span {\n float: right;\n }\n }\n\n .previous {\n > a,\n > span {\n float: left;\n }\n }\n\n .disabled {\n > a,\n > a:hover,\n > a:focus,\n > span {\n color: @pager-disabled-color;\n background-color: @pager-bg;\n cursor: @cursor-disabled;\n }\n }\n}\n","//\n// Labels\n// --------------------------------------------------\n\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: @label-color;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n\n // Add hover effects, but only for links\n a& {\n &:hover,\n &:focus {\n color: @label-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Empty labels collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for labels in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n}\n\n// Colors\n// Contextual variations (linked labels get darker on :hover)\n\n.label-default {\n .label-variant(@label-default-bg);\n}\n\n.label-primary {\n .label-variant(@label-primary-bg);\n}\n\n.label-success {\n .label-variant(@label-success-bg);\n}\n\n.label-info {\n .label-variant(@label-info-bg);\n}\n\n.label-warning {\n .label-variant(@label-warning-bg);\n}\n\n.label-danger {\n .label-variant(@label-danger-bg);\n}\n","// Labels\n\n.label-variant(@color) {\n background-color: @color;\n\n &[href] {\n &:hover,\n &:focus {\n background-color: darken(@color, 10%);\n }\n }\n}\n","//\n// Badges\n// --------------------------------------------------\n\n\n// Base class\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: @font-size-small;\n font-weight: @badge-font-weight;\n color: @badge-color;\n line-height: @badge-line-height;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: @badge-bg;\n border-radius: @badge-border-radius;\n\n // Empty badges collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for badges in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n\n .btn-xs &,\n .btn-group-xs > .btn & {\n top: 0;\n padding: 1px 5px;\n }\n\n // Hover state, but only for links\n a& {\n &:hover,\n &:focus {\n color: @badge-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Account for badges in navs\n .list-group-item.active > &,\n .nav-pills > .active > a > & {\n color: @badge-active-color;\n background-color: @badge-active-bg;\n }\n\n .list-group-item > & {\n float: right;\n }\n\n .list-group-item > & + & {\n margin-right: 5px;\n }\n\n .nav-pills > li > a > & {\n margin-left: 3px;\n }\n}\n","//\n// Jumbotron\n// --------------------------------------------------\n\n\n.jumbotron {\n padding-top: @jumbotron-padding;\n padding-bottom: @jumbotron-padding;\n margin-bottom: @jumbotron-padding;\n color: @jumbotron-color;\n background-color: @jumbotron-bg;\n\n h1,\n .h1 {\n color: @jumbotron-heading-color;\n }\n\n p {\n margin-bottom: (@jumbotron-padding / 2);\n font-size: @jumbotron-font-size;\n font-weight: 200;\n }\n\n > hr {\n border-top-color: darken(@jumbotron-bg, 10%);\n }\n\n .container &,\n .container-fluid & {\n border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container\n padding-left: (@grid-gutter-width / 2);\n padding-right: (@grid-gutter-width / 2);\n }\n\n .container {\n max-width: 100%;\n }\n\n @media screen and (min-width: @screen-sm-min) {\n padding-top: (@jumbotron-padding * 1.6);\n padding-bottom: (@jumbotron-padding * 1.6);\n\n .container &,\n .container-fluid & {\n padding-left: (@jumbotron-padding * 2);\n padding-right: (@jumbotron-padding * 2);\n }\n\n h1,\n .h1 {\n font-size: @jumbotron-heading-font-size;\n }\n }\n}\n","//\n// Thumbnails\n// --------------------------------------------------\n\n\n// Mixin and adjust the regular image class\n.thumbnail {\n display: block;\n padding: @thumbnail-padding;\n margin-bottom: @line-height-computed;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(border .2s ease-in-out);\n\n > img,\n a > img {\n &:extend(.img-responsive);\n margin-left: auto;\n margin-right: auto;\n }\n\n // Add a hover state for linked versions only\n a&:hover,\n a&:focus,\n a&.active {\n border-color: @link-color;\n }\n\n // Image captions\n .caption {\n padding: @thumbnail-caption-padding;\n color: @thumbnail-caption-color;\n }\n}\n","//\n// Alerts\n// --------------------------------------------------\n\n\n// Base styles\n// -------------------------\n\n.alert {\n padding: @alert-padding;\n margin-bottom: @line-height-computed;\n border: 1px solid transparent;\n border-radius: @alert-border-radius;\n\n // Headings for larger alerts\n h4 {\n margin-top: 0;\n // Specified for the h4 to prevent conflicts of changing @headings-color\n color: inherit;\n }\n\n // Provide class for links that match alerts\n .alert-link {\n font-weight: @alert-link-font-weight;\n }\n\n // Improve alignment and spacing of inner content\n > p,\n > ul {\n margin-bottom: 0;\n }\n\n > p + p {\n margin-top: 5px;\n }\n}\n\n// Dismissible alerts\n//\n// Expand the right padding and account for the close button's positioning.\n\n.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0.\n.alert-dismissible {\n padding-right: (@alert-padding + 20);\n\n // Adjust close link position\n .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n }\n}\n\n// Alternate styles\n//\n// Generate contextual modifier classes for colorizing the alert.\n\n.alert-success {\n .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text);\n}\n\n.alert-info {\n .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text);\n}\n\n.alert-warning {\n .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text);\n}\n\n.alert-danger {\n .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text);\n}\n","// Alerts\n\n.alert-variant(@background; @border; @text-color) {\n background-color: @background;\n border-color: @border;\n color: @text-color;\n\n hr {\n border-top-color: darken(@border, 5%);\n }\n .alert-link {\n color: darken(@text-color, 10%);\n }\n}\n","//\n// Progress bars\n// --------------------------------------------------\n\n\n// Bar animations\n// -------------------------\n\n// WebKit\n@-webkit-keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n// Spec and IE10+\n@keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n\n// Bar itself\n// -------------------------\n\n// Outer container\n.progress {\n overflow: hidden;\n height: @line-height-computed;\n margin-bottom: @line-height-computed;\n background-color: @progress-bg;\n border-radius: @progress-border-radius;\n .box-shadow(inset 0 1px 2px rgba(0,0,0,.1));\n}\n\n// Bar of progress\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: @font-size-small;\n line-height: @line-height-computed;\n color: @progress-bar-color;\n text-align: center;\n background-color: @progress-bar-bg;\n .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15));\n .transition(width .6s ease);\n}\n\n// Striped bars\n//\n// `.progress-striped .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar-striped` class, which you just add to an existing\n// `.progress-bar`.\n.progress-striped .progress-bar,\n.progress-bar-striped {\n #gradient > .striped();\n background-size: 40px 40px;\n}\n\n// Call animation for the active one\n//\n// `.progress.active .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar.active` approach.\n.progress.active .progress-bar,\n.progress-bar.active {\n .animation(progress-bar-stripes 2s linear infinite);\n}\n\n\n// Variations\n// -------------------------\n\n.progress-bar-success {\n .progress-bar-variant(@progress-bar-success-bg);\n}\n\n.progress-bar-info {\n .progress-bar-variant(@progress-bar-info-bg);\n}\n\n.progress-bar-warning {\n .progress-bar-variant(@progress-bar-warning-bg);\n}\n\n.progress-bar-danger {\n .progress-bar-variant(@progress-bar-danger-bg);\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Progress bars\n\n.progress-bar-variant(@color) {\n background-color: @color;\n\n // Deprecated parent class requirement as of v3.2.0\n .progress-striped & {\n #gradient > .striped();\n }\n}\n",".media {\n // Proper spacing between instances of .media\n margin-top: 15px;\n\n &:first-child {\n margin-top: 0;\n }\n}\n\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n\n.media-body {\n width: 10000px;\n}\n\n.media-object {\n display: block;\n\n // Fix collapse in webkit from max-width: 100% and display: table-cell.\n &.img-thumbnail {\n max-width: none;\n }\n}\n\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n\n.media-middle {\n vertical-align: middle;\n}\n\n.media-bottom {\n vertical-align: bottom;\n}\n\n// Reset margins on headings for tighter default spacing\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n\n// Media list variation\n//\n// Undo default ul/ol styles\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n","//\n// List groups\n// --------------------------------------------------\n\n\n// Base class\n//\n// Easily usable on
    ,
      , or
      .\n\n.list-group {\n // No need to set list-style: none; since .list-group-item is block level\n margin-bottom: 20px;\n padding-left: 0; // reset padding because ul and ol\n}\n\n\n// Individual list items\n//\n// Use on `li`s or `div`s within the `.list-group` parent.\n\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n // Place the border on the list items and negative margin up for better styling\n margin-bottom: -1px;\n background-color: @list-group-bg;\n border: 1px solid @list-group-border;\n\n // Round the first and last items\n &:first-child {\n .border-top-radius(@list-group-border-radius);\n }\n &:last-child {\n margin-bottom: 0;\n .border-bottom-radius(@list-group-border-radius);\n }\n}\n\n\n// Interactive list items\n//\n// Use anchor or button elements instead of `li`s or `div`s to create interactive items.\n// Includes an extra `.active` modifier class for showing selected items.\n\na.list-group-item,\nbutton.list-group-item {\n color: @list-group-link-color;\n\n .list-group-item-heading {\n color: @list-group-link-heading-color;\n }\n\n // Hover state\n &:hover,\n &:focus {\n text-decoration: none;\n color: @list-group-link-hover-color;\n background-color: @list-group-hover-bg;\n }\n}\n\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n\n.list-group-item {\n // Disabled state\n &.disabled,\n &.disabled:hover,\n &.disabled:focus {\n background-color: @list-group-disabled-bg;\n color: @list-group-disabled-color;\n cursor: @cursor-disabled;\n\n // Force color to inherit for custom content\n .list-group-item-heading {\n color: inherit;\n }\n .list-group-item-text {\n color: @list-group-disabled-text-color;\n }\n }\n\n // Active class on item itself, not parent\n &.active,\n &.active:hover,\n &.active:focus {\n z-index: 2; // Place active items above their siblings for proper border styling\n color: @list-group-active-color;\n background-color: @list-group-active-bg;\n border-color: @list-group-active-border;\n\n // Force color to inherit for custom content\n .list-group-item-heading,\n .list-group-item-heading > small,\n .list-group-item-heading > .small {\n color: inherit;\n }\n .list-group-item-text {\n color: @list-group-active-text-color;\n }\n }\n}\n\n\n// Contextual variants\n//\n// Add modifier classes to change text and background color on individual items.\n// Organizationally, this must come after the `:hover` states.\n\n.list-group-item-variant(success; @state-success-bg; @state-success-text);\n.list-group-item-variant(info; @state-info-bg; @state-info-text);\n.list-group-item-variant(warning; @state-warning-bg; @state-warning-text);\n.list-group-item-variant(danger; @state-danger-bg; @state-danger-text);\n\n\n// Custom content options\n//\n// Extra classes for creating well-formatted content within `.list-group-item`s.\n\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n","// List Groups\n\n.list-group-item-variant(@state; @background; @color) {\n .list-group-item-@{state} {\n color: @color;\n background-color: @background;\n\n a&,\n button& {\n color: @color;\n\n .list-group-item-heading {\n color: inherit;\n }\n\n &:hover,\n &:focus {\n color: @color;\n background-color: darken(@background, 5%);\n }\n &.active,\n &.active:hover,\n &.active:focus {\n color: #fff;\n background-color: @color;\n border-color: @color;\n }\n }\n }\n}\n","//\n// Panels\n// --------------------------------------------------\n\n\n// Base class\n.panel {\n margin-bottom: @line-height-computed;\n background-color: @panel-bg;\n border: 1px solid transparent;\n border-radius: @panel-border-radius;\n .box-shadow(0 1px 1px rgba(0,0,0,.05));\n}\n\n// Panel contents\n.panel-body {\n padding: @panel-body-padding;\n &:extend(.clearfix all);\n}\n\n// Optional heading\n.panel-heading {\n padding: @panel-heading-padding;\n border-bottom: 1px solid transparent;\n .border-top-radius((@panel-border-radius - 1));\n\n > .dropdown .dropdown-toggle {\n color: inherit;\n }\n}\n\n// Within heading, strip any `h*` tag of its default margins for spacing.\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: ceil((@font-size-base * 1.125));\n color: inherit;\n\n > a,\n > small,\n > .small,\n > small > a,\n > .small > a {\n color: inherit;\n }\n}\n\n// Optional footer (stays gray in every modifier class)\n.panel-footer {\n padding: @panel-footer-padding;\n background-color: @panel-footer-bg;\n border-top: 1px solid @panel-inner-border;\n .border-bottom-radius((@panel-border-radius - 1));\n}\n\n\n// List groups in panels\n//\n// By default, space out list group content from panel headings to account for\n// any kind of custom content between the two.\n\n.panel {\n > .list-group,\n > .panel-collapse > .list-group {\n margin-bottom: 0;\n\n .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n }\n\n // Add border top radius for first one\n &:first-child {\n .list-group-item:first-child {\n border-top: 0;\n .border-top-radius((@panel-border-radius - 1));\n }\n }\n\n // Add border bottom radius for last one\n &:last-child {\n .list-group-item:last-child {\n border-bottom: 0;\n .border-bottom-radius((@panel-border-radius - 1));\n }\n }\n }\n > .panel-heading + .panel-collapse > .list-group {\n .list-group-item:first-child {\n .border-top-radius(0);\n }\n }\n}\n// Collapse space between when there's no additional content.\n.panel-heading + .list-group {\n .list-group-item:first-child {\n border-top-width: 0;\n }\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n\n// Tables in panels\n//\n// Place a non-bordered `.table` within a panel (not within a `.panel-body`) and\n// watch it go full width.\n\n.panel {\n > .table,\n > .table-responsive > .table,\n > .panel-collapse > .table {\n margin-bottom: 0;\n\n caption {\n padding-left: @panel-body-padding;\n padding-right: @panel-body-padding;\n }\n }\n // Add border top radius for first one\n > .table:first-child,\n > .table-responsive:first-child > .table:first-child {\n .border-top-radius((@panel-border-radius - 1));\n\n > thead:first-child,\n > tbody:first-child {\n > tr:first-child {\n border-top-left-radius: (@panel-border-radius - 1);\n border-top-right-radius: (@panel-border-radius - 1);\n\n td:first-child,\n th:first-child {\n border-top-left-radius: (@panel-border-radius - 1);\n }\n td:last-child,\n th:last-child {\n border-top-right-radius: (@panel-border-radius - 1);\n }\n }\n }\n }\n // Add border bottom radius for last one\n > .table:last-child,\n > .table-responsive:last-child > .table:last-child {\n .border-bottom-radius((@panel-border-radius - 1));\n\n > tbody:last-child,\n > tfoot:last-child {\n > tr:last-child {\n border-bottom-left-radius: (@panel-border-radius - 1);\n border-bottom-right-radius: (@panel-border-radius - 1);\n\n td:first-child,\n th:first-child {\n border-bottom-left-radius: (@panel-border-radius - 1);\n }\n td:last-child,\n th:last-child {\n border-bottom-right-radius: (@panel-border-radius - 1);\n }\n }\n }\n }\n > .panel-body + .table,\n > .panel-body + .table-responsive,\n > .table + .panel-body,\n > .table-responsive + .panel-body {\n border-top: 1px solid @table-border-color;\n }\n > .table > tbody:first-child > tr:first-child th,\n > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n }\n > .table-bordered,\n > .table-responsive > .table-bordered {\n border: 0;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n > thead,\n > tbody {\n > tr:first-child {\n > td,\n > th {\n border-bottom: 0;\n }\n }\n }\n > tbody,\n > tfoot {\n > tr:last-child {\n > td,\n > th {\n border-bottom: 0;\n }\n }\n }\n }\n > .table-responsive {\n border: 0;\n margin-bottom: 0;\n }\n}\n\n\n// Collapsible panels (aka, accordion)\n//\n// Wrap a series of panels in `.panel-group` to turn them into an accordion with\n// the help of our collapse JavaScript plugin.\n\n.panel-group {\n margin-bottom: @line-height-computed;\n\n // Tighten up margin so it's only between panels\n .panel {\n margin-bottom: 0;\n border-radius: @panel-border-radius;\n\n + .panel {\n margin-top: 5px;\n }\n }\n\n .panel-heading {\n border-bottom: 0;\n\n + .panel-collapse > .panel-body,\n + .panel-collapse > .list-group {\n border-top: 1px solid @panel-inner-border;\n }\n }\n\n .panel-footer {\n border-top: 0;\n + .panel-collapse .panel-body {\n border-bottom: 1px solid @panel-inner-border;\n }\n }\n}\n\n\n// Contextual variations\n.panel-default {\n .panel-variant(@panel-default-border; @panel-default-text; @panel-default-heading-bg; @panel-default-border);\n}\n.panel-primary {\n .panel-variant(@panel-primary-border; @panel-primary-text; @panel-primary-heading-bg; @panel-primary-border);\n}\n.panel-success {\n .panel-variant(@panel-success-border; @panel-success-text; @panel-success-heading-bg; @panel-success-border);\n}\n.panel-info {\n .panel-variant(@panel-info-border; @panel-info-text; @panel-info-heading-bg; @panel-info-border);\n}\n.panel-warning {\n .panel-variant(@panel-warning-border; @panel-warning-text; @panel-warning-heading-bg; @panel-warning-border);\n}\n.panel-danger {\n .panel-variant(@panel-danger-border; @panel-danger-text; @panel-danger-heading-bg; @panel-danger-border);\n}\n","// Panels\n\n.panel-variant(@border; @heading-text-color; @heading-bg-color; @heading-border) {\n border-color: @border;\n\n & > .panel-heading {\n color: @heading-text-color;\n background-color: @heading-bg-color;\n border-color: @heading-border;\n\n + .panel-collapse > .panel-body {\n border-top-color: @border;\n }\n .badge {\n color: @heading-bg-color;\n background-color: @heading-text-color;\n }\n }\n & > .panel-footer {\n + .panel-collapse > .panel-body {\n border-bottom-color: @border;\n }\n }\n}\n","// Embeds responsive\n//\n// Credit: Nicolas Gallagher and SUIT CSS.\n\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n\n .embed-responsive-item,\n iframe,\n embed,\n object,\n video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n }\n}\n\n// Modifier class for 16:9 aspect ratio\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n\n// Modifier class for 4:3 aspect ratio\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n","//\n// Wells\n// --------------------------------------------------\n\n\n// Base class\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: @well-bg;\n border: 1px solid @well-border;\n border-radius: @border-radius-base;\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.05));\n blockquote {\n border-color: #ddd;\n border-color: rgba(0,0,0,.15);\n }\n}\n\n// Sizes\n.well-lg {\n padding: 24px;\n border-radius: @border-radius-large;\n}\n.well-sm {\n padding: 9px;\n border-radius: @border-radius-small;\n}\n","//\n// Close icons\n// --------------------------------------------------\n\n\n.close {\n float: right;\n font-size: (@font-size-base * 1.5);\n font-weight: @close-font-weight;\n line-height: 1;\n color: @close-color;\n text-shadow: @close-text-shadow;\n .opacity(.2);\n\n &:hover,\n &:focus {\n color: @close-color;\n text-decoration: none;\n cursor: pointer;\n .opacity(.5);\n }\n\n // Additional properties for button version\n // iOS requires the button element instead of an anchor tag.\n // If you want the anchor version, it requires `href=\"#\"`.\n // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n button& {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n }\n}\n","//\n// Modals\n// --------------------------------------------------\n\n// .modal-open - body class for killing the scroll\n// .modal - container to scroll within\n// .modal-dialog - positioning shell for the actual modal\n// .modal-content - actual modal w/ bg and corners and shit\n\n// Kill the scroll on the body\n.modal-open {\n overflow: hidden;\n}\n\n// Container that the modal scrolls within\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: @zindex-modal;\n -webkit-overflow-scrolling: touch;\n\n // Prevent Chrome on Windows from adding a focus outline. For details, see\n // https://github.com/twbs/bootstrap/pull/10951.\n outline: 0;\n\n // When fading in the modal, animate it to slide down\n &.fade .modal-dialog {\n .translate(0, -25%);\n .transition-transform(~\"0.3s ease-out\");\n }\n &.in .modal-dialog { .translate(0, 0) }\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n// Shell div to position the modal with bottom padding\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n\n// Actual modal\n.modal-content {\n position: relative;\n background-color: @modal-content-bg;\n border: 1px solid @modal-content-fallback-border-color; //old browsers fallback (ie8 etc)\n border: 1px solid @modal-content-border-color;\n border-radius: @border-radius-large;\n .box-shadow(0 3px 9px rgba(0,0,0,.5));\n background-clip: padding-box;\n // Remove focus outline from opened modal\n outline: 0;\n}\n\n// Modal background\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: @zindex-modal-background;\n background-color: @modal-backdrop-bg;\n // Fade for backdrop\n &.fade { .opacity(0); }\n &.in { .opacity(@modal-backdrop-opacity); }\n}\n\n// Modal header\n// Top section of the modal w/ title and dismiss\n.modal-header {\n padding: @modal-title-padding;\n border-bottom: 1px solid @modal-header-border-color;\n &:extend(.clearfix all);\n}\n// Close icon\n.modal-header .close {\n margin-top: -2px;\n}\n\n// Title text within header\n.modal-title {\n margin: 0;\n line-height: @modal-title-line-height;\n}\n\n// Modal body\n// Where all modal content resides (sibling of .modal-header and .modal-footer)\n.modal-body {\n position: relative;\n padding: @modal-inner-padding;\n}\n\n// Footer (for actions)\n.modal-footer {\n padding: @modal-inner-padding;\n text-align: right; // right align buttons\n border-top: 1px solid @modal-footer-border-color;\n &:extend(.clearfix all); // clear it in case folks use .pull-* classes on buttons\n\n // Properly space out buttons\n .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0; // account for input[type=\"submit\"] which gets the bottom margin like all other inputs\n }\n // but override that for button groups\n .btn-group .btn + .btn {\n margin-left: -1px;\n }\n // and override it for block buttons as well\n .btn-block + .btn-block {\n margin-left: 0;\n }\n}\n\n// Measure scrollbar width for padding body during modal show/hide\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n\n// Scale up the modal\n@media (min-width: @screen-sm-min) {\n // Automatically set modal's width for larger viewports\n .modal-dialog {\n width: @modal-md;\n margin: 30px auto;\n }\n .modal-content {\n .box-shadow(0 5px 15px rgba(0,0,0,.5));\n }\n\n // Modal sizes\n .modal-sm { width: @modal-sm; }\n}\n\n@media (min-width: @screen-md-min) {\n .modal-lg { width: @modal-lg; }\n}\n","//\n// Tooltips\n// --------------------------------------------------\n\n\n// Base class\n.tooltip {\n position: absolute;\n z-index: @zindex-tooltip;\n display: block;\n // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n .reset-text();\n font-size: @font-size-small;\n\n .opacity(0);\n\n &.in { .opacity(@tooltip-opacity); }\n &.top { margin-top: -3px; padding: @tooltip-arrow-width 0; }\n &.right { margin-left: 3px; padding: 0 @tooltip-arrow-width; }\n &.bottom { margin-top: 3px; padding: @tooltip-arrow-width 0; }\n &.left { margin-left: -3px; padding: 0 @tooltip-arrow-width; }\n}\n\n// Wrapper for the tooltip content\n.tooltip-inner {\n max-width: @tooltip-max-width;\n padding: 3px 8px;\n color: @tooltip-color;\n text-align: center;\n background-color: @tooltip-bg;\n border-radius: @border-radius-base;\n}\n\n// Arrows\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n// Note: Deprecated .top-left, .top-right, .bottom-left, and .bottom-right as of v3.3.1\n.tooltip {\n &.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.top-left .tooltip-arrow {\n bottom: 0;\n right: @tooltip-arrow-width;\n margin-bottom: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.top-right .tooltip-arrow {\n bottom: 0;\n left: @tooltip-arrow-width;\n margin-bottom: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width @tooltip-arrow-width 0;\n border-right-color: @tooltip-arrow-color;\n }\n &.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-left-color: @tooltip-arrow-color;\n }\n &.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n &.bottom-left .tooltip-arrow {\n top: 0;\n right: @tooltip-arrow-width;\n margin-top: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n &.bottom-right .tooltip-arrow {\n top: 0;\n left: @tooltip-arrow-width;\n margin-top: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n}\n",".reset-text() {\n font-family: @font-family-base;\n // We deliberately do NOT reset font-size.\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: @line-height-base;\n text-align: left; // Fallback for where `start` is not supported\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n}\n","//\n// Popovers\n// --------------------------------------------------\n\n\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: @zindex-popover;\n display: none;\n max-width: @popover-max-width;\n padding: 1px;\n // Our parent element can be arbitrary since popovers are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n .reset-text();\n font-size: @font-size-base;\n\n background-color: @popover-bg;\n background-clip: padding-box;\n border: 1px solid @popover-fallback-border-color;\n border: 1px solid @popover-border-color;\n border-radius: @border-radius-large;\n .box-shadow(0 5px 10px rgba(0,0,0,.2));\n\n // Offset the popover to account for the popover arrow\n &.top { margin-top: -@popover-arrow-width; }\n &.right { margin-left: @popover-arrow-width; }\n &.bottom { margin-top: @popover-arrow-width; }\n &.left { margin-left: -@popover-arrow-width; }\n}\n\n.popover-title {\n margin: 0; // reset heading margin\n padding: 8px 14px;\n font-size: @font-size-base;\n background-color: @popover-title-bg;\n border-bottom: 1px solid darken(@popover-title-bg, 5%);\n border-radius: (@border-radius-large - 1) (@border-radius-large - 1) 0 0;\n}\n\n.popover-content {\n padding: 9px 14px;\n}\n\n// Arrows\n//\n// .arrow is outer, .arrow:after is inner\n\n.popover > .arrow {\n &,\n &:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n }\n}\n.popover > .arrow {\n border-width: @popover-arrow-outer-width;\n}\n.popover > .arrow:after {\n border-width: @popover-arrow-width;\n content: \"\";\n}\n\n.popover {\n &.top > .arrow {\n left: 50%;\n margin-left: -@popover-arrow-outer-width;\n border-bottom-width: 0;\n border-top-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-top-color: @popover-arrow-outer-color;\n bottom: -@popover-arrow-outer-width;\n &:after {\n content: \" \";\n bottom: 1px;\n margin-left: -@popover-arrow-width;\n border-bottom-width: 0;\n border-top-color: @popover-arrow-color;\n }\n }\n &.right > .arrow {\n top: 50%;\n left: -@popover-arrow-outer-width;\n margin-top: -@popover-arrow-outer-width;\n border-left-width: 0;\n border-right-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-right-color: @popover-arrow-outer-color;\n &:after {\n content: \" \";\n left: 1px;\n bottom: -@popover-arrow-width;\n border-left-width: 0;\n border-right-color: @popover-arrow-color;\n }\n }\n &.bottom > .arrow {\n left: 50%;\n margin-left: -@popover-arrow-outer-width;\n border-top-width: 0;\n border-bottom-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-bottom-color: @popover-arrow-outer-color;\n top: -@popover-arrow-outer-width;\n &:after {\n content: \" \";\n top: 1px;\n margin-left: -@popover-arrow-width;\n border-top-width: 0;\n border-bottom-color: @popover-arrow-color;\n }\n }\n\n &.left > .arrow {\n top: 50%;\n right: -@popover-arrow-outer-width;\n margin-top: -@popover-arrow-outer-width;\n border-right-width: 0;\n border-left-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-left-color: @popover-arrow-outer-color;\n &:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: @popover-arrow-color;\n bottom: -@popover-arrow-width;\n }\n }\n}\n","//\n// Carousel\n// --------------------------------------------------\n\n\n// Wrapper for the slide container and indicators\n.carousel {\n position: relative;\n}\n\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n\n > .item {\n display: none;\n position: relative;\n .transition(.6s ease-in-out left);\n\n // Account for jankitude on images\n > img,\n > a > img {\n &:extend(.img-responsive);\n line-height: 1;\n }\n\n // WebKit CSS3 transforms for supported devices\n @media all and (transform-3d), (-webkit-transform-3d) {\n .transition-transform(~'0.6s ease-in-out');\n .backface-visibility(~'hidden');\n .perspective(1000px);\n\n &.next,\n &.active.right {\n .translate3d(100%, 0, 0);\n left: 0;\n }\n &.prev,\n &.active.left {\n .translate3d(-100%, 0, 0);\n left: 0;\n }\n &.next.left,\n &.prev.right,\n &.active {\n .translate3d(0, 0, 0);\n left: 0;\n }\n }\n }\n\n > .active,\n > .next,\n > .prev {\n display: block;\n }\n\n > .active {\n left: 0;\n }\n\n > .next,\n > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n }\n\n > .next {\n left: 100%;\n }\n > .prev {\n left: -100%;\n }\n > .next.left,\n > .prev.right {\n left: 0;\n }\n\n > .active.left {\n left: -100%;\n }\n > .active.right {\n left: 100%;\n }\n\n}\n\n// Left/right controls for nav\n// ---------------------------\n\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: @carousel-control-width;\n .opacity(@carousel-control-opacity);\n font-size: @carousel-control-font-size;\n color: @carousel-control-color;\n text-align: center;\n text-shadow: @carousel-text-shadow;\n background-color: rgba(0, 0, 0, 0); // Fix IE9 click-thru bug\n // We can't have this transition here because WebKit cancels the carousel\n // animation if you trip this while in the middle of another animation.\n\n // Set gradients for backgrounds\n &.left {\n #gradient > .horizontal(@start-color: rgba(0,0,0,.5); @end-color: rgba(0,0,0,.0001));\n }\n &.right {\n left: auto;\n right: 0;\n #gradient > .horizontal(@start-color: rgba(0,0,0,.0001); @end-color: rgba(0,0,0,.5));\n }\n\n // Hover/focus state\n &:hover,\n &:focus {\n outline: 0;\n color: @carousel-control-color;\n text-decoration: none;\n .opacity(.9);\n }\n\n // Toggles\n .icon-prev,\n .icon-next,\n .glyphicon-chevron-left,\n .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n margin-top: -10px;\n z-index: 5;\n display: inline-block;\n }\n .icon-prev,\n .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n }\n .icon-next,\n .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n }\n .icon-prev,\n .icon-next {\n width: 20px;\n height: 20px;\n line-height: 1;\n font-family: serif;\n }\n\n\n .icon-prev {\n &:before {\n content: '\\2039';// SINGLE LEFT-POINTING ANGLE QUOTATION MARK (U+2039)\n }\n }\n .icon-next {\n &:before {\n content: '\\203a';// SINGLE RIGHT-POINTING ANGLE QUOTATION MARK (U+203A)\n }\n }\n}\n\n// Optional indicator pips\n//\n// Add an unordered list with the following class and add a list item for each\n// slide your carousel holds.\n\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n\n li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid @carousel-indicator-border-color;\n border-radius: 10px;\n cursor: pointer;\n\n // IE8-9 hack for event handling\n //\n // Internet Explorer 8-9 does not support clicks on elements without a set\n // `background-color`. We cannot use `filter` since that's not viewed as a\n // background color by the browser. Thus, a hack is needed.\n // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Internet_Explorer\n //\n // For IE8, we set solid black as it doesn't support `rgba()`. For IE9, we\n // set alpha transparency for the best results possible.\n background-color: #000 \\9; // IE8\n background-color: rgba(0,0,0,0); // IE9\n }\n .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: @carousel-indicator-active-bg;\n }\n}\n\n// Optional captions\n// -----------------------------\n// Hidden by default for smaller viewports\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: @carousel-caption-color;\n text-align: center;\n text-shadow: @carousel-text-shadow;\n & .btn {\n text-shadow: none; // No shadow for button elements in carousel-caption\n }\n}\n\n\n// Scale up controls for tablets and up\n@media screen and (min-width: @screen-sm-min) {\n\n // Scale up the controls a smidge\n .carousel-control {\n .glyphicon-chevron-left,\n .glyphicon-chevron-right,\n .icon-prev,\n .icon-next {\n width: (@carousel-control-font-size * 1.5);\n height: (@carousel-control-font-size * 1.5);\n margin-top: (@carousel-control-font-size / -2);\n font-size: (@carousel-control-font-size * 1.5);\n }\n .glyphicon-chevron-left,\n .icon-prev {\n margin-left: (@carousel-control-font-size / -2);\n }\n .glyphicon-chevron-right,\n .icon-next {\n margin-right: (@carousel-control-font-size / -2);\n }\n }\n\n // Show and left align the captions\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n\n // Move up the indicators\n .carousel-indicators {\n bottom: 20px;\n }\n}\n","// Clearfix\n//\n// For modern browsers\n// 1. The space content is one way to avoid an Opera bug when the\n// contenteditable attribute is included anywhere else in the document.\n// Otherwise it causes space to appear at the top and bottom of elements\n// that are clearfixed.\n// 2. The use of `table` rather than `block` is only necessary if using\n// `:before` to contain the top-margins of child elements.\n//\n// Source: http://nicolasgallagher.com/micro-clearfix-hack/\n\n.clearfix() {\n &:before,\n &:after {\n content: \" \"; // 1\n display: table; // 2\n }\n &:after {\n clear: both;\n }\n}\n","// Center-align a block level element\n\n.center-block() {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n","// CSS image replacement\n//\n// Heads up! v3 launched with only `.hide-text()`, but per our pattern for\n// mixins being reused as classes with the same name, this doesn't hold up. As\n// of v3.0.1 we have added `.text-hide()` and deprecated `.hide-text()`.\n//\n// Source: https://github.com/h5bp/html5-boilerplate/commit/aa0396eae757\n\n// Deprecated as of v3.0.1 (has been removed in v4)\n.hide-text() {\n font: ~\"0/0\" a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n\n// New mixin to use as of v3.0.1\n.text-hide() {\n .hide-text();\n}\n","//\n// Responsive: Utility classes\n// --------------------------------------------------\n\n\n// IE10 in Windows (Phone) 8\n//\n// Support for responsive views via media queries is kind of borked in IE10, for\n// Surface/desktop in split view and for Windows Phone 8. This particular fix\n// must be accompanied by a snippet of JavaScript to sniff the user agent and\n// apply some conditional CSS to *only* the Surface/desktop Windows 8. Look at\n// our Getting Started page for more information on this bug.\n//\n// For more information, see the following:\n//\n// Issue: https://github.com/twbs/bootstrap/issues/10497\n// Docs: http://getbootstrap.com/getting-started/#support-ie10-width\n// Source: http://timkadlec.com/2013/01/windows-phone-8-and-device-width/\n// Source: http://timkadlec.com/2012/10/ie10-snap-mode-and-responsive-design/\n\n@-ms-viewport {\n width: device-width;\n}\n\n\n// Visibility utilities\n// Note: Deprecated .visible-xs, .visible-sm, .visible-md, and .visible-lg as of v3.2.0\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n .responsive-invisibility();\n}\n\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n\n.visible-xs {\n @media (max-width: @screen-xs-max) {\n .responsive-visibility();\n }\n}\n.visible-xs-block {\n @media (max-width: @screen-xs-max) {\n display: block !important;\n }\n}\n.visible-xs-inline {\n @media (max-width: @screen-xs-max) {\n display: inline !important;\n }\n}\n.visible-xs-inline-block {\n @media (max-width: @screen-xs-max) {\n display: inline-block !important;\n }\n}\n\n.visible-sm {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n .responsive-visibility();\n }\n}\n.visible-sm-block {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: block !important;\n }\n}\n.visible-sm-inline {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: inline !important;\n }\n}\n.visible-sm-inline-block {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: inline-block !important;\n }\n}\n\n.visible-md {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n .responsive-visibility();\n }\n}\n.visible-md-block {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: block !important;\n }\n}\n.visible-md-inline {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: inline !important;\n }\n}\n.visible-md-inline-block {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: inline-block !important;\n }\n}\n\n.visible-lg {\n @media (min-width: @screen-lg-min) {\n .responsive-visibility();\n }\n}\n.visible-lg-block {\n @media (min-width: @screen-lg-min) {\n display: block !important;\n }\n}\n.visible-lg-inline {\n @media (min-width: @screen-lg-min) {\n display: inline !important;\n }\n}\n.visible-lg-inline-block {\n @media (min-width: @screen-lg-min) {\n display: inline-block !important;\n }\n}\n\n.hidden-xs {\n @media (max-width: @screen-xs-max) {\n .responsive-invisibility();\n }\n}\n.hidden-sm {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n .responsive-invisibility();\n }\n}\n.hidden-md {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n .responsive-invisibility();\n }\n}\n.hidden-lg {\n @media (min-width: @screen-lg-min) {\n .responsive-invisibility();\n }\n}\n\n\n// Print utilities\n//\n// Media queries are placed on the inside to be mixin-friendly.\n\n// Note: Deprecated .visible-print as of v3.2.0\n.visible-print {\n .responsive-invisibility();\n\n @media print {\n .responsive-visibility();\n }\n}\n.visible-print-block {\n display: none !important;\n\n @media print {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n\n @media print {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n\n @media print {\n display: inline-block !important;\n }\n}\n\n.hidden-print {\n @media print {\n .responsive-invisibility();\n }\n}\n","// Responsive utilities\n\n//\n// More easily include all the states for responsive-utilities.less.\n.responsive-visibility() {\n display: block !important;\n table& { display: table !important; }\n tr& { display: table-row !important; }\n th&,\n td& { display: table-cell !important; }\n}\n\n.responsive-invisibility() {\n display: none !important;\n}\n"]} \ No newline at end of file +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_root.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_color-mode.scss","../../scss/_reboot.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_color-bg.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_focus-ring.scss","../../scss/helpers/_icon-link.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n",":root,\n[data-bs-theme=\"light\"] {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$prefix}#{$color}-rgb: #{$value};\n }\n\n @each $color, $value in $theme-colors-text {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}white-rgb: #{to-rgb($white)};\n --#{$prefix}black-rgb: #{to-rgb($black)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$prefix}gradient: #{$gradient};\n\n // Root and body\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$prefix}root-font-size: #{$font-size-root};\n }\n --#{$prefix}body-font-family: #{inspect($font-family-base)};\n @include rfs($font-size-base, --#{$prefix}body-font-size);\n --#{$prefix}body-font-weight: #{$font-weight-base};\n --#{$prefix}body-line-height: #{$line-height-base};\n @if $body-text-align != null {\n --#{$prefix}body-text-align: #{$body-text-align};\n }\n\n --#{$prefix}body-color: #{$body-color};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color)};\n --#{$prefix}body-bg: #{$body-bg};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg)};\n // scss-docs-end root-body-variables\n\n --#{$prefix}heading-color: #{$headings-color};\n\n --#{$prefix}link-color: #{$link-color};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color)};\n --#{$prefix}link-decoration: #{$link-decoration};\n\n --#{$prefix}link-hover-color: #{$link-hover-color};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color)};\n\n @if $link-hover-decoration != null {\n --#{$prefix}link-hover-decoration: #{$link-hover-decoration};\n }\n\n --#{$prefix}code-color: #{$code-color};\n --#{$prefix}highlight-color: #{$mark-color};\n --#{$prefix}highlight-bg: #{$mark-bg};\n\n // scss-docs-start root-border-var\n --#{$prefix}border-width: #{$border-width};\n --#{$prefix}border-style: #{$border-style};\n --#{$prefix}border-color: #{$border-color};\n --#{$prefix}border-color-translucent: #{$border-color-translucent};\n\n --#{$prefix}border-radius: #{$border-radius};\n --#{$prefix}border-radius-sm: #{$border-radius-sm};\n --#{$prefix}border-radius-lg: #{$border-radius-lg};\n --#{$prefix}border-radius-xl: #{$border-radius-xl};\n --#{$prefix}border-radius-xxl: #{$border-radius-xxl};\n --#{$prefix}border-radius-2xl: var(--#{$prefix}border-radius-xxl); // Deprecated in v5.3.0 for consistency\n --#{$prefix}border-radius-pill: #{$border-radius-pill};\n // scss-docs-end root-border-var\n\n --#{$prefix}box-shadow: #{$box-shadow};\n --#{$prefix}box-shadow-sm: #{$box-shadow-sm};\n --#{$prefix}box-shadow-lg: #{$box-shadow-lg};\n --#{$prefix}box-shadow-inset: #{$box-shadow-inset};\n\n // Focus styles\n // scss-docs-start root-focus-variables\n --#{$prefix}focus-ring-width: #{$focus-ring-width};\n --#{$prefix}focus-ring-opacity: #{$focus-ring-opacity};\n --#{$prefix}focus-ring-color: #{$focus-ring-color};\n // scss-docs-end root-focus-variables\n\n // scss-docs-start root-form-validation-variables\n --#{$prefix}form-valid-color: #{$form-valid-color};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color};\n --#{$prefix}form-invalid-color: #{$form-invalid-color};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color};\n // scss-docs-end root-form-validation-variables\n}\n\n@if $enable-dark-mode {\n @include color-mode(dark, true) {\n color-scheme: dark;\n\n // scss-docs-start root-dark-mode-vars\n --#{$prefix}body-color: #{$body-color-dark};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color-dark)};\n --#{$prefix}body-bg: #{$body-bg-dark};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg-dark)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color-dark};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color-dark)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color-dark};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color-dark)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg-dark};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg-dark)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color-dark};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color-dark)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg-dark};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg-dark)};\n\n @each $color, $value in $theme-colors-text-dark {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle-dark {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle-dark {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}heading-color: #{$headings-color-dark};\n\n --#{$prefix}link-color: #{$link-color-dark};\n --#{$prefix}link-hover-color: #{$link-hover-color-dark};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color-dark)};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color-dark)};\n\n --#{$prefix}code-color: #{$code-color-dark};\n --#{$prefix}highlight-color: #{$mark-color-dark};\n --#{$prefix}highlight-bg: #{$mark-bg-dark};\n\n --#{$prefix}border-color: #{$border-color-dark};\n --#{$prefix}border-color-translucent: #{$border-color-translucent-dark};\n\n --#{$prefix}form-valid-color: #{$form-valid-color-dark};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color-dark};\n --#{$prefix}form-invalid-color: #{$form-invalid-color-dark};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color-dark};\n // scss-docs-end root-dark-mode-vars\n }\n}\n","@charset \"UTF-8\";\n/*!\n * Bootstrap v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n:root,\n[data-bs-theme=light] {\n --bs-blue: #0d6efd;\n --bs-indigo: #6610f2;\n --bs-purple: #6f42c1;\n --bs-pink: #d63384;\n --bs-red: #dc3545;\n --bs-orange: #fd7e14;\n --bs-yellow: #ffc107;\n --bs-green: #198754;\n --bs-teal: #20c997;\n --bs-cyan: #0dcaf0;\n --bs-black: #000;\n --bs-white: #fff;\n --bs-gray: #6c757d;\n --bs-gray-dark: #343a40;\n --bs-gray-100: #f8f9fa;\n --bs-gray-200: #e9ecef;\n --bs-gray-300: #dee2e6;\n --bs-gray-400: #ced4da;\n --bs-gray-500: #adb5bd;\n --bs-gray-600: #6c757d;\n --bs-gray-700: #495057;\n --bs-gray-800: #343a40;\n --bs-gray-900: #212529;\n --bs-primary: #0d6efd;\n --bs-secondary: #6c757d;\n --bs-success: #198754;\n --bs-info: #0dcaf0;\n --bs-warning: #ffc107;\n --bs-danger: #dc3545;\n --bs-light: #f8f9fa;\n --bs-dark: #212529;\n --bs-primary-rgb: 13, 110, 253;\n --bs-secondary-rgb: 108, 117, 125;\n --bs-success-rgb: 25, 135, 84;\n --bs-info-rgb: 13, 202, 240;\n --bs-warning-rgb: 255, 193, 7;\n --bs-danger-rgb: 220, 53, 69;\n --bs-light-rgb: 248, 249, 250;\n --bs-dark-rgb: 33, 37, 41;\n --bs-primary-text-emphasis: #052c65;\n --bs-secondary-text-emphasis: #2b2f32;\n --bs-success-text-emphasis: #0a3622;\n --bs-info-text-emphasis: #055160;\n --bs-warning-text-emphasis: #664d03;\n --bs-danger-text-emphasis: #58151c;\n --bs-light-text-emphasis: #495057;\n --bs-dark-text-emphasis: #495057;\n --bs-primary-bg-subtle: #cfe2ff;\n --bs-secondary-bg-subtle: #e2e3e5;\n --bs-success-bg-subtle: #d1e7dd;\n --bs-info-bg-subtle: #cff4fc;\n --bs-warning-bg-subtle: #fff3cd;\n --bs-danger-bg-subtle: #f8d7da;\n --bs-light-bg-subtle: #fcfcfd;\n --bs-dark-bg-subtle: #ced4da;\n --bs-primary-border-subtle: #9ec5fe;\n --bs-secondary-border-subtle: #c4c8cb;\n --bs-success-border-subtle: #a3cfbb;\n --bs-info-border-subtle: #9eeaf9;\n --bs-warning-border-subtle: #ffe69c;\n --bs-danger-border-subtle: #f1aeb5;\n --bs-light-border-subtle: #e9ecef;\n --bs-dark-border-subtle: #adb5bd;\n --bs-white-rgb: 255, 255, 255;\n --bs-black-rgb: 0, 0, 0;\n --bs-font-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));\n --bs-body-font-family: var(--bs-font-sans-serif);\n --bs-body-font-size: 1rem;\n --bs-body-font-weight: 400;\n --bs-body-line-height: 1.5;\n --bs-body-color: #212529;\n --bs-body-color-rgb: 33, 37, 41;\n --bs-body-bg: #fff;\n --bs-body-bg-rgb: 255, 255, 255;\n --bs-emphasis-color: #000;\n --bs-emphasis-color-rgb: 0, 0, 0;\n --bs-secondary-color: rgba(33, 37, 41, 0.75);\n --bs-secondary-color-rgb: 33, 37, 41;\n --bs-secondary-bg: #e9ecef;\n --bs-secondary-bg-rgb: 233, 236, 239;\n --bs-tertiary-color: rgba(33, 37, 41, 0.5);\n --bs-tertiary-color-rgb: 33, 37, 41;\n --bs-tertiary-bg: #f8f9fa;\n --bs-tertiary-bg-rgb: 248, 249, 250;\n --bs-heading-color: inherit;\n --bs-link-color: #0d6efd;\n --bs-link-color-rgb: 13, 110, 253;\n --bs-link-decoration: underline;\n --bs-link-hover-color: #0a58ca;\n --bs-link-hover-color-rgb: 10, 88, 202;\n --bs-code-color: #d63384;\n --bs-highlight-color: #212529;\n --bs-highlight-bg: #fff3cd;\n --bs-border-width: 1px;\n --bs-border-style: solid;\n --bs-border-color: #dee2e6;\n --bs-border-color-translucent: rgba(0, 0, 0, 0.175);\n --bs-border-radius: 0.375rem;\n --bs-border-radius-sm: 0.25rem;\n --bs-border-radius-lg: 0.5rem;\n --bs-border-radius-xl: 1rem;\n --bs-border-radius-xxl: 2rem;\n --bs-border-radius-2xl: var(--bs-border-radius-xxl);\n --bs-border-radius-pill: 50rem;\n --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);\n --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);\n --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);\n --bs-focus-ring-width: 0.25rem;\n --bs-focus-ring-opacity: 0.25;\n --bs-focus-ring-color: rgba(13, 110, 253, 0.25);\n --bs-form-valid-color: #198754;\n --bs-form-valid-border-color: #198754;\n --bs-form-invalid-color: #dc3545;\n --bs-form-invalid-border-color: #dc3545;\n}\n\n[data-bs-theme=dark] {\n color-scheme: dark;\n --bs-body-color: #dee2e6;\n --bs-body-color-rgb: 222, 226, 230;\n --bs-body-bg: #212529;\n --bs-body-bg-rgb: 33, 37, 41;\n --bs-emphasis-color: #fff;\n --bs-emphasis-color-rgb: 255, 255, 255;\n --bs-secondary-color: rgba(222, 226, 230, 0.75);\n --bs-secondary-color-rgb: 222, 226, 230;\n --bs-secondary-bg: #343a40;\n --bs-secondary-bg-rgb: 52, 58, 64;\n --bs-tertiary-color: rgba(222, 226, 230, 0.5);\n --bs-tertiary-color-rgb: 222, 226, 230;\n --bs-tertiary-bg: #2b3035;\n --bs-tertiary-bg-rgb: 43, 48, 53;\n --bs-primary-text-emphasis: #6ea8fe;\n --bs-secondary-text-emphasis: #a7acb1;\n --bs-success-text-emphasis: #75b798;\n --bs-info-text-emphasis: #6edff6;\n --bs-warning-text-emphasis: #ffda6a;\n --bs-danger-text-emphasis: #ea868f;\n --bs-light-text-emphasis: #f8f9fa;\n --bs-dark-text-emphasis: #dee2e6;\n --bs-primary-bg-subtle: #031633;\n --bs-secondary-bg-subtle: #161719;\n --bs-success-bg-subtle: #051b11;\n --bs-info-bg-subtle: #032830;\n --bs-warning-bg-subtle: #332701;\n --bs-danger-bg-subtle: #2c0b0e;\n --bs-light-bg-subtle: #343a40;\n --bs-dark-bg-subtle: #1a1d20;\n --bs-primary-border-subtle: #084298;\n --bs-secondary-border-subtle: #41464b;\n --bs-success-border-subtle: #0f5132;\n --bs-info-border-subtle: #087990;\n --bs-warning-border-subtle: #997404;\n --bs-danger-border-subtle: #842029;\n --bs-light-border-subtle: #495057;\n --bs-dark-border-subtle: #343a40;\n --bs-heading-color: inherit;\n --bs-link-color: #6ea8fe;\n --bs-link-hover-color: #8bb9fe;\n --bs-link-color-rgb: 110, 168, 254;\n --bs-link-hover-color-rgb: 139, 185, 254;\n --bs-code-color: #e685b5;\n --bs-highlight-color: #dee2e6;\n --bs-highlight-bg: #664d03;\n --bs-border-color: #495057;\n --bs-border-color-translucent: rgba(255, 255, 255, 0.15);\n --bs-form-valid-color: #75b798;\n --bs-form-valid-border-color: #75b798;\n --bs-form-invalid-color: #ea868f;\n --bs-form-invalid-border-color: #ea868f;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n :root {\n scroll-behavior: smooth;\n }\n}\n\nbody {\n margin: 0;\n font-family: var(--bs-body-font-family);\n font-size: var(--bs-body-font-size);\n font-weight: var(--bs-body-font-weight);\n line-height: var(--bs-body-line-height);\n color: var(--bs-body-color);\n text-align: var(--bs-body-text-align);\n background-color: var(--bs-body-bg);\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\nhr {\n margin: 1rem 0;\n color: inherit;\n border: 0;\n border-top: var(--bs-border-width) solid;\n opacity: 0.25;\n}\n\nh6, .h6, h5, .h5, h4, .h4, h3, .h3, h2, .h2, h1, .h1 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n color: var(--bs-heading-color);\n}\n\nh1, .h1 {\n font-size: calc(1.375rem + 1.5vw);\n}\n@media (min-width: 1200px) {\n h1, .h1 {\n font-size: 2.5rem;\n }\n}\n\nh2, .h2 {\n font-size: calc(1.325rem + 0.9vw);\n}\n@media (min-width: 1200px) {\n h2, .h2 {\n font-size: 2rem;\n }\n}\n\nh3, .h3 {\n font-size: calc(1.3rem + 0.6vw);\n}\n@media (min-width: 1200px) {\n h3, .h3 {\n font-size: 1.75rem;\n }\n}\n\nh4, .h4 {\n font-size: calc(1.275rem + 0.3vw);\n}\n@media (min-width: 1200px) {\n h4, .h4 {\n font-size: 1.5rem;\n }\n}\n\nh5, .h5 {\n font-size: 1.25rem;\n}\n\nh6, .h6 {\n font-size: 1rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title] {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n cursor: help;\n -webkit-text-decoration-skip-ink: none;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: 0.5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall, .small {\n font-size: 0.875em;\n}\n\nmark, .mark {\n padding: 0.1875em;\n color: var(--bs-highlight-color);\n background-color: var(--bs-highlight-bg);\n}\n\nsub,\nsup {\n position: relative;\n font-size: 0.75em;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\na {\n color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));\n text-decoration: underline;\n}\na:hover {\n --bs-link-color-rgb: var(--bs-link-hover-color-rgb);\n}\n\na:not([href]):not([class]), a:not([href]):not([class]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: var(--bs-font-monospace);\n font-size: 1em;\n}\n\npre {\n display: block;\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n font-size: 0.875em;\n}\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\ncode {\n font-size: 0.875em;\n color: var(--bs-code-color);\n word-wrap: break-word;\n}\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.1875rem 0.375rem;\n font-size: 0.875em;\n color: var(--bs-body-bg);\n background-color: var(--bs-body-color);\n border-radius: 0.25rem;\n}\nkbd kbd {\n padding: 0;\n font-size: 1em;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: var(--bs-secondary-color);\n text-align: left;\n}\n\nth {\n text-align: inherit;\n text-align: -webkit-match-parent;\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\nlabel {\n display: inline-block;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\n[role=button] {\n cursor: pointer;\n}\n\nselect {\n word-wrap: normal;\n}\nselect:disabled {\n opacity: 1;\n}\n\n[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\nbutton,\n[type=button],\n[type=reset],\n[type=submit] {\n -webkit-appearance: button;\n}\nbutton:not(:disabled),\n[type=button]:not(:disabled),\n[type=reset]:not(:disabled),\n[type=submit]:not(:disabled) {\n cursor: pointer;\n}\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ntextarea {\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n float: left;\n width: 100%;\n padding: 0;\n margin-bottom: 0.5rem;\n font-size: calc(1.275rem + 0.3vw);\n line-height: inherit;\n}\n@media (min-width: 1200px) {\n legend {\n font-size: 1.5rem;\n }\n}\nlegend + * {\n clear: left;\n}\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n[type=search] {\n -webkit-appearance: textfield;\n outline-offset: -2px;\n}\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\n::file-selector-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\niframe {\n border: 0;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[hidden] {\n display: none !important;\n}\n\n.lead {\n font-size: 1.25rem;\n font-weight: 300;\n}\n\n.display-1 {\n font-size: calc(1.625rem + 4.5vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-1 {\n font-size: 5rem;\n }\n}\n\n.display-2 {\n font-size: calc(1.575rem + 3.9vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-2 {\n font-size: 4.5rem;\n }\n}\n\n.display-3 {\n font-size: calc(1.525rem + 3.3vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-3 {\n font-size: 4rem;\n }\n}\n\n.display-4 {\n font-size: calc(1.475rem + 2.7vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-4 {\n font-size: 3.5rem;\n }\n}\n\n.display-5 {\n font-size: calc(1.425rem + 2.1vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-5 {\n font-size: 3rem;\n }\n}\n\n.display-6 {\n font-size: calc(1.375rem + 1.5vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-6 {\n font-size: 2.5rem;\n }\n}\n\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline-item {\n display: inline-block;\n}\n.list-inline-item:not(:last-child) {\n margin-right: 0.5rem;\n}\n\n.initialism {\n font-size: 0.875em;\n text-transform: uppercase;\n}\n\n.blockquote {\n margin-bottom: 1rem;\n font-size: 1.25rem;\n}\n.blockquote > :last-child {\n margin-bottom: 0;\n}\n\n.blockquote-footer {\n margin-top: -1rem;\n margin-bottom: 1rem;\n font-size: 0.875em;\n color: #6c757d;\n}\n.blockquote-footer::before {\n content: \"— \";\n}\n\n.img-fluid {\n max-width: 100%;\n height: auto;\n}\n\n.img-thumbnail {\n padding: 0.25rem;\n background-color: var(--bs-body-bg);\n border: var(--bs-border-width) solid var(--bs-border-color);\n border-radius: var(--bs-border-radius);\n max-width: 100%;\n height: auto;\n}\n\n.figure {\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: 0.5rem;\n line-height: 1;\n}\n\n.figure-caption {\n font-size: 0.875em;\n color: var(--bs-secondary-color);\n}\n\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-left: 0;\n }\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-left: 25%;\n }\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-left: 50%;\n }\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-left: 75%;\n }\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.table {\n --bs-table-color-type: initial;\n --bs-table-bg-type: initial;\n --bs-table-color-state: initial;\n --bs-table-bg-state: initial;\n --bs-table-color: var(--bs-emphasis-color);\n --bs-table-bg: var(--bs-body-bg);\n --bs-table-border-color: var(--bs-border-color);\n --bs-table-accent-bg: transparent;\n --bs-table-striped-color: var(--bs-emphasis-color);\n --bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), 0.05);\n --bs-table-active-color: var(--bs-emphasis-color);\n --bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), 0.1);\n --bs-table-hover-color: var(--bs-emphasis-color);\n --bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075);\n width: 100%;\n margin-bottom: 1rem;\n vertical-align: top;\n border-color: var(--bs-table-border-color);\n}\n.table > :not(caption) > * > * {\n padding: 0.5rem 0.5rem;\n color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));\n background-color: var(--bs-table-bg);\n border-bottom-width: var(--bs-border-width);\n box-shadow: inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)));\n}\n.table > tbody {\n vertical-align: inherit;\n}\n.table > thead {\n vertical-align: bottom;\n}\n\n.table-group-divider {\n border-top: calc(var(--bs-border-width) * 2) solid currentcolor;\n}\n\n.caption-top {\n caption-side: top;\n}\n\n.table-sm > :not(caption) > * > * {\n padding: 0.25rem 0.25rem;\n}\n\n.table-bordered > :not(caption) > * {\n border-width: var(--bs-border-width) 0;\n}\n.table-bordered > :not(caption) > * > * {\n border-width: 0 var(--bs-border-width);\n}\n\n.table-borderless > :not(caption) > * > * {\n border-bottom-width: 0;\n}\n.table-borderless > :not(:first-child) {\n border-top-width: 0;\n}\n\n.table-striped > tbody > tr:nth-of-type(odd) > * {\n --bs-table-color-type: var(--bs-table-striped-color);\n --bs-table-bg-type: var(--bs-table-striped-bg);\n}\n\n.table-striped-columns > :not(caption) > tr > :nth-child(even) {\n --bs-table-color-type: var(--bs-table-striped-color);\n --bs-table-bg-type: var(--bs-table-striped-bg);\n}\n\n.table-active {\n --bs-table-color-state: var(--bs-table-active-color);\n --bs-table-bg-state: var(--bs-table-active-bg);\n}\n\n.table-hover > tbody > tr:hover > * {\n --bs-table-color-state: var(--bs-table-hover-color);\n --bs-table-bg-state: var(--bs-table-hover-bg);\n}\n\n.table-primary {\n --bs-table-color: #000;\n --bs-table-bg: #cfe2ff;\n --bs-table-border-color: #a6b5cc;\n --bs-table-striped-bg: #c5d7f2;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #bacbe6;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #bfd1ec;\n --bs-table-hover-color: #000;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-secondary {\n --bs-table-color: #000;\n --bs-table-bg: #e2e3e5;\n --bs-table-border-color: #b5b6b7;\n --bs-table-striped-bg: #d7d8da;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #cbccce;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #d1d2d4;\n --bs-table-hover-color: #000;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-success {\n --bs-table-color: #000;\n --bs-table-bg: #d1e7dd;\n --bs-table-border-color: #a7b9b1;\n --bs-table-striped-bg: #c7dbd2;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #bcd0c7;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #c1d6cc;\n --bs-table-hover-color: #000;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-info {\n --bs-table-color: #000;\n --bs-table-bg: #cff4fc;\n --bs-table-border-color: #a6c3ca;\n --bs-table-striped-bg: #c5e8ef;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #badce3;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #bfe2e9;\n --bs-table-hover-color: #000;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-warning {\n --bs-table-color: #000;\n --bs-table-bg: #fff3cd;\n --bs-table-border-color: #ccc2a4;\n --bs-table-striped-bg: #f2e7c3;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #e6dbb9;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #ece1be;\n --bs-table-hover-color: #000;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-danger {\n --bs-table-color: #000;\n --bs-table-bg: #f8d7da;\n --bs-table-border-color: #c6acae;\n --bs-table-striped-bg: #eccccf;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #dfc2c4;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #e5c7ca;\n --bs-table-hover-color: #000;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-light {\n --bs-table-color: #000;\n --bs-table-bg: #f8f9fa;\n --bs-table-border-color: #c6c7c8;\n --bs-table-striped-bg: #ecedee;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #dfe0e1;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #e5e6e7;\n --bs-table-hover-color: #000;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-dark {\n --bs-table-color: #fff;\n --bs-table-bg: #212529;\n --bs-table-border-color: #4d5154;\n --bs-table-striped-bg: #2c3034;\n --bs-table-striped-color: #fff;\n --bs-table-active-bg: #373b3e;\n --bs-table-active-color: #fff;\n --bs-table-hover-bg: #323539;\n --bs-table-hover-color: #fff;\n color: var(--bs-table-color);\n border-color: var(--bs-table-border-color);\n}\n\n.table-responsive {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n}\n\n@media (max-width: 575.98px) {\n .table-responsive-sm {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 767.98px) {\n .table-responsive-md {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 991.98px) {\n .table-responsive-lg {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 1199.98px) {\n .table-responsive-xl {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 1399.98px) {\n .table-responsive-xxl {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n.form-label {\n margin-bottom: 0.5rem;\n}\n\n.col-form-label {\n padding-top: calc(0.375rem + var(--bs-border-width));\n padding-bottom: calc(0.375rem + var(--bs-border-width));\n margin-bottom: 0;\n font-size: inherit;\n line-height: 1.5;\n}\n\n.col-form-label-lg {\n padding-top: calc(0.5rem + var(--bs-border-width));\n padding-bottom: calc(0.5rem + var(--bs-border-width));\n font-size: 1.25rem;\n}\n\n.col-form-label-sm {\n padding-top: calc(0.25rem + var(--bs-border-width));\n padding-bottom: calc(0.25rem + var(--bs-border-width));\n font-size: 0.875rem;\n}\n\n.form-text {\n margin-top: 0.25rem;\n font-size: 0.875em;\n color: var(--bs-secondary-color);\n}\n\n.form-control {\n display: block;\n width: 100%;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: var(--bs-body-color);\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n background-color: var(--bs-body-bg);\n background-clip: padding-box;\n border: var(--bs-border-width) solid var(--bs-border-color);\n border-radius: var(--bs-border-radius);\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-control {\n transition: none;\n }\n}\n.form-control[type=file] {\n overflow: hidden;\n}\n.form-control[type=file]:not(:disabled):not([readonly]) {\n cursor: pointer;\n}\n.form-control:focus {\n color: var(--bs-body-color);\n background-color: var(--bs-body-bg);\n border-color: #86b7fe;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-control::-webkit-date-and-time-value {\n min-width: 85px;\n height: 1.5em;\n margin: 0;\n}\n.form-control::-webkit-datetime-edit {\n display: block;\n padding: 0;\n}\n.form-control::-moz-placeholder {\n color: var(--bs-secondary-color);\n opacity: 1;\n}\n.form-control::placeholder {\n color: var(--bs-secondary-color);\n opacity: 1;\n}\n.form-control:disabled {\n background-color: var(--bs-secondary-bg);\n opacity: 1;\n}\n.form-control::-webkit-file-upload-button {\n padding: 0.375rem 0.75rem;\n margin: -0.375rem -0.75rem;\n -webkit-margin-end: 0.75rem;\n margin-inline-end: 0.75rem;\n color: var(--bs-body-color);\n background-color: var(--bs-tertiary-bg);\n pointer-events: none;\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n border-inline-end-width: var(--bs-border-width);\n border-radius: 0;\n -webkit-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n.form-control::file-selector-button {\n padding: 0.375rem 0.75rem;\n margin: -0.375rem -0.75rem;\n -webkit-margin-end: 0.75rem;\n margin-inline-end: 0.75rem;\n color: var(--bs-body-color);\n background-color: var(--bs-tertiary-bg);\n pointer-events: none;\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n border-inline-end-width: var(--bs-border-width);\n border-radius: 0;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-control::-webkit-file-upload-button {\n -webkit-transition: none;\n transition: none;\n }\n .form-control::file-selector-button {\n transition: none;\n }\n}\n.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button {\n background-color: var(--bs-secondary-bg);\n}\n.form-control:hover:not(:disabled):not([readonly])::file-selector-button {\n background-color: var(--bs-secondary-bg);\n}\n\n.form-control-plaintext {\n display: block;\n width: 100%;\n padding: 0.375rem 0;\n margin-bottom: 0;\n line-height: 1.5;\n color: var(--bs-body-color);\n background-color: transparent;\n border: solid transparent;\n border-width: var(--bs-border-width) 0;\n}\n.form-control-plaintext:focus {\n outline: 0;\n}\n.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg {\n padding-right: 0;\n padding-left: 0;\n}\n\n.form-control-sm {\n min-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2));\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n border-radius: var(--bs-border-radius-sm);\n}\n.form-control-sm::-webkit-file-upload-button {\n padding: 0.25rem 0.5rem;\n margin: -0.25rem -0.5rem;\n -webkit-margin-end: 0.5rem;\n margin-inline-end: 0.5rem;\n}\n.form-control-sm::file-selector-button {\n padding: 0.25rem 0.5rem;\n margin: -0.25rem -0.5rem;\n -webkit-margin-end: 0.5rem;\n margin-inline-end: 0.5rem;\n}\n\n.form-control-lg {\n min-height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n border-radius: var(--bs-border-radius-lg);\n}\n.form-control-lg::-webkit-file-upload-button {\n padding: 0.5rem 1rem;\n margin: -0.5rem -1rem;\n -webkit-margin-end: 1rem;\n margin-inline-end: 1rem;\n}\n.form-control-lg::file-selector-button {\n padding: 0.5rem 1rem;\n margin: -0.5rem -1rem;\n -webkit-margin-end: 1rem;\n margin-inline-end: 1rem;\n}\n\ntextarea.form-control {\n min-height: calc(1.5em + 0.75rem + calc(var(--bs-border-width) * 2));\n}\ntextarea.form-control-sm {\n min-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2));\n}\ntextarea.form-control-lg {\n min-height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));\n}\n\n.form-control-color {\n width: 3rem;\n height: calc(1.5em + 0.75rem + calc(var(--bs-border-width) * 2));\n padding: 0.375rem;\n}\n.form-control-color:not(:disabled):not([readonly]) {\n cursor: pointer;\n}\n.form-control-color::-moz-color-swatch {\n border: 0 !important;\n border-radius: var(--bs-border-radius);\n}\n.form-control-color::-webkit-color-swatch {\n border: 0 !important;\n border-radius: var(--bs-border-radius);\n}\n.form-control-color.form-control-sm {\n height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2));\n}\n.form-control-color.form-control-lg {\n height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));\n}\n\n.form-select {\n --bs-form-select-bg-img: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e\");\n display: block;\n width: 100%;\n padding: 0.375rem 2.25rem 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: var(--bs-body-color);\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n background-color: var(--bs-body-bg);\n background-image: var(--bs-form-select-bg-img), var(--bs-form-select-bg-icon, none);\n background-repeat: no-repeat;\n background-position: right 0.75rem center;\n background-size: 16px 12px;\n border: var(--bs-border-width) solid var(--bs-border-color);\n border-radius: var(--bs-border-radius);\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-select {\n transition: none;\n }\n}\n.form-select:focus {\n border-color: #86b7fe;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-select[multiple], .form-select[size]:not([size=\"1\"]) {\n padding-right: 0.75rem;\n background-image: none;\n}\n.form-select:disabled {\n background-color: var(--bs-secondary-bg);\n}\n.form-select:-moz-focusring {\n color: transparent;\n text-shadow: 0 0 0 var(--bs-body-color);\n}\n\n.form-select-sm {\n padding-top: 0.25rem;\n padding-bottom: 0.25rem;\n padding-left: 0.5rem;\n font-size: 0.875rem;\n border-radius: var(--bs-border-radius-sm);\n}\n\n.form-select-lg {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n padding-left: 1rem;\n font-size: 1.25rem;\n border-radius: var(--bs-border-radius-lg);\n}\n\n[data-bs-theme=dark] .form-select {\n --bs-form-select-bg-img: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e\");\n}\n\n.form-check {\n display: block;\n min-height: 1.5rem;\n padding-left: 1.5em;\n margin-bottom: 0.125rem;\n}\n.form-check .form-check-input {\n float: left;\n margin-left: -1.5em;\n}\n\n.form-check-reverse {\n padding-right: 1.5em;\n padding-left: 0;\n text-align: right;\n}\n.form-check-reverse .form-check-input {\n float: right;\n margin-right: -1.5em;\n margin-left: 0;\n}\n\n.form-check-input {\n --bs-form-check-bg: var(--bs-body-bg);\n flex-shrink: 0;\n width: 1em;\n height: 1em;\n margin-top: 0.25em;\n vertical-align: top;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n background-color: var(--bs-form-check-bg);\n background-image: var(--bs-form-check-bg-image);\n background-repeat: no-repeat;\n background-position: center;\n background-size: contain;\n border: var(--bs-border-width) solid var(--bs-border-color);\n -webkit-print-color-adjust: exact;\n color-adjust: exact;\n print-color-adjust: exact;\n}\n.form-check-input[type=checkbox] {\n border-radius: 0.25em;\n}\n.form-check-input[type=radio] {\n border-radius: 50%;\n}\n.form-check-input:active {\n filter: brightness(90%);\n}\n.form-check-input:focus {\n border-color: #86b7fe;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-check-input:checked {\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n.form-check-input:checked[type=checkbox] {\n --bs-form-check-bg-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e\");\n}\n.form-check-input:checked[type=radio] {\n --bs-form-check-bg-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e\");\n}\n.form-check-input[type=checkbox]:indeterminate {\n background-color: #0d6efd;\n border-color: #0d6efd;\n --bs-form-check-bg-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e\");\n}\n.form-check-input:disabled {\n pointer-events: none;\n filter: none;\n opacity: 0.5;\n}\n.form-check-input[disabled] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label {\n cursor: default;\n opacity: 0.5;\n}\n\n.form-switch {\n padding-left: 2.5em;\n}\n.form-switch .form-check-input {\n --bs-form-switch-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e\");\n width: 2em;\n margin-left: -2.5em;\n background-image: var(--bs-form-switch-bg);\n background-position: left center;\n border-radius: 2em;\n transition: background-position 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-switch .form-check-input {\n transition: none;\n }\n}\n.form-switch .form-check-input:focus {\n --bs-form-switch-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e\");\n}\n.form-switch .form-check-input:checked {\n background-position: right center;\n --bs-form-switch-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e\");\n}\n.form-switch.form-check-reverse {\n padding-right: 2.5em;\n padding-left: 0;\n}\n.form-switch.form-check-reverse .form-check-input {\n margin-right: -2.5em;\n margin-left: 0;\n}\n\n.form-check-inline {\n display: inline-block;\n margin-right: 1rem;\n}\n\n.btn-check {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.btn-check[disabled] + .btn, .btn-check:disabled + .btn {\n pointer-events: none;\n filter: none;\n opacity: 0.65;\n}\n\n[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus) {\n --bs-form-switch-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e\");\n}\n\n.form-range {\n width: 100%;\n height: 1.5rem;\n padding: 0;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n background-color: transparent;\n}\n.form-range:focus {\n outline: 0;\n}\n.form-range:focus::-webkit-slider-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-range:focus::-moz-range-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-range::-moz-focus-outer {\n border: 0;\n}\n.form-range::-webkit-slider-thumb {\n width: 1rem;\n height: 1rem;\n margin-top: -0.25rem;\n -webkit-appearance: none;\n appearance: none;\n background-color: #0d6efd;\n border: 0;\n border-radius: 1rem;\n -webkit-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-range::-webkit-slider-thumb {\n -webkit-transition: none;\n transition: none;\n }\n}\n.form-range::-webkit-slider-thumb:active {\n background-color: #b6d4fe;\n}\n.form-range::-webkit-slider-runnable-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: var(--bs-secondary-bg);\n border-color: transparent;\n border-radius: 1rem;\n}\n.form-range::-moz-range-thumb {\n width: 1rem;\n height: 1rem;\n -moz-appearance: none;\n appearance: none;\n background-color: #0d6efd;\n border: 0;\n border-radius: 1rem;\n -moz-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-range::-moz-range-thumb {\n -moz-transition: none;\n transition: none;\n }\n}\n.form-range::-moz-range-thumb:active {\n background-color: #b6d4fe;\n}\n.form-range::-moz-range-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: var(--bs-secondary-bg);\n border-color: transparent;\n border-radius: 1rem;\n}\n.form-range:disabled {\n pointer-events: none;\n}\n.form-range:disabled::-webkit-slider-thumb {\n background-color: var(--bs-secondary-color);\n}\n.form-range:disabled::-moz-range-thumb {\n background-color: var(--bs-secondary-color);\n}\n\n.form-floating {\n position: relative;\n}\n.form-floating > .form-control,\n.form-floating > .form-control-plaintext,\n.form-floating > .form-select {\n height: calc(3.5rem + calc(var(--bs-border-width) * 2));\n min-height: calc(3.5rem + calc(var(--bs-border-width) * 2));\n line-height: 1.25;\n}\n.form-floating > label {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 2;\n height: 100%;\n padding: 1rem 0.75rem;\n overflow: hidden;\n text-align: start;\n text-overflow: ellipsis;\n white-space: nowrap;\n pointer-events: none;\n border: var(--bs-border-width) solid transparent;\n transform-origin: 0 0;\n transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-floating > label {\n transition: none;\n }\n}\n.form-floating > .form-control,\n.form-floating > .form-control-plaintext {\n padding: 1rem 0.75rem;\n}\n.form-floating > .form-control::-moz-placeholder, .form-floating > .form-control-plaintext::-moz-placeholder {\n color: transparent;\n}\n.form-floating > .form-control::placeholder,\n.form-floating > .form-control-plaintext::placeholder {\n color: transparent;\n}\n.form-floating > .form-control:not(:-moz-placeholder-shown), .form-floating > .form-control-plaintext:not(:-moz-placeholder-shown) {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown),\n.form-floating > .form-control-plaintext:focus,\n.form-floating > .form-control-plaintext:not(:placeholder-shown) {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-control:-webkit-autofill,\n.form-floating > .form-control-plaintext:-webkit-autofill {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-select {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label {\n color: rgba(var(--bs-body-color-rgb), 0.65);\n transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);\n}\n.form-floating > .form-control:focus ~ label,\n.form-floating > .form-control:not(:placeholder-shown) ~ label,\n.form-floating > .form-control-plaintext ~ label,\n.form-floating > .form-select ~ label {\n color: rgba(var(--bs-body-color-rgb), 0.65);\n transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);\n}\n.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label::after {\n position: absolute;\n inset: 1rem 0.375rem;\n z-index: -1;\n height: 1.5em;\n content: \"\";\n background-color: var(--bs-body-bg);\n border-radius: var(--bs-border-radius);\n}\n.form-floating > .form-control:focus ~ label::after,\n.form-floating > .form-control:not(:placeholder-shown) ~ label::after,\n.form-floating > .form-control-plaintext ~ label::after,\n.form-floating > .form-select ~ label::after {\n position: absolute;\n inset: 1rem 0.375rem;\n z-index: -1;\n height: 1.5em;\n content: \"\";\n background-color: var(--bs-body-bg);\n border-radius: var(--bs-border-radius);\n}\n.form-floating > .form-control:-webkit-autofill ~ label {\n color: rgba(var(--bs-body-color-rgb), 0.65);\n transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);\n}\n.form-floating > .form-control-plaintext ~ label {\n border-width: var(--bs-border-width) 0;\n}\n.form-floating > :disabled ~ label,\n.form-floating > .form-control:disabled ~ label {\n color: #6c757d;\n}\n.form-floating > :disabled ~ label::after,\n.form-floating > .form-control:disabled ~ label::after {\n background-color: var(--bs-secondary-bg);\n}\n\n.input-group {\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: stretch;\n width: 100%;\n}\n.input-group > .form-control,\n.input-group > .form-select,\n.input-group > .form-floating {\n position: relative;\n flex: 1 1 auto;\n width: 1%;\n min-width: 0;\n}\n.input-group > .form-control:focus,\n.input-group > .form-select:focus,\n.input-group > .form-floating:focus-within {\n z-index: 5;\n}\n.input-group .btn {\n position: relative;\n z-index: 2;\n}\n.input-group .btn:focus {\n z-index: 5;\n}\n\n.input-group-text {\n display: flex;\n align-items: center;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: var(--bs-body-color);\n text-align: center;\n white-space: nowrap;\n background-color: var(--bs-tertiary-bg);\n border: var(--bs-border-width) solid var(--bs-border-color);\n border-radius: var(--bs-border-radius);\n}\n\n.input-group-lg > .form-control,\n.input-group-lg > .form-select,\n.input-group-lg > .input-group-text,\n.input-group-lg > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n border-radius: var(--bs-border-radius-lg);\n}\n\n.input-group-sm > .form-control,\n.input-group-sm > .form-select,\n.input-group-sm > .input-group-text,\n.input-group-sm > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n border-radius: var(--bs-border-radius-sm);\n}\n\n.input-group-lg > .form-select,\n.input-group-sm > .form-select {\n padding-right: 3rem;\n}\n\n.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),\n.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3),\n.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-control,\n.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-select {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.input-group.has-validation > :nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),\n.input-group.has-validation > .dropdown-toggle:nth-last-child(n+4),\n.input-group.has-validation > .form-floating:nth-last-child(n+3) > .form-control,\n.input-group.has-validation > .form-floating:nth-last-child(n+3) > .form-select {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) {\n margin-left: calc(var(--bs-border-width) * -1);\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.input-group > .form-floating:not(:first-child) > .form-control,\n.input-group > .form-floating:not(:first-child) > .form-select {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.valid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 0.875em;\n color: var(--bs-form-valid-color);\n}\n\n.valid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: 0.1rem;\n font-size: 0.875rem;\n color: #fff;\n background-color: var(--bs-success);\n border-radius: var(--bs-border-radius);\n}\n\n.was-validated :valid ~ .valid-feedback,\n.was-validated :valid ~ .valid-tooltip,\n.is-valid ~ .valid-feedback,\n.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-control:valid, .form-control.is-valid {\n border-color: var(--bs-form-valid-border-color);\n padding-right: calc(1.5em + 0.75rem);\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e\");\n background-repeat: no-repeat;\n background-position: right calc(0.375em + 0.1875rem) center;\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-control:valid:focus, .form-control.is-valid:focus {\n border-color: var(--bs-form-valid-border-color);\n box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);\n}\n\n.was-validated textarea.form-control:valid, textarea.form-control.is-valid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .form-select:valid, .form-select.is-valid {\n border-color: var(--bs-form-valid-border-color);\n}\n.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size=\"1\"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size=\"1\"] {\n --bs-form-select-bg-icon: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e\");\n padding-right: 4.125rem;\n background-position: right 0.75rem center, center right 2.25rem;\n background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-select:valid:focus, .form-select.is-valid:focus {\n border-color: var(--bs-form-valid-border-color);\n box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);\n}\n\n.was-validated .form-control-color:valid, .form-control-color.is-valid {\n width: calc(3rem + calc(1.5em + 0.75rem));\n}\n\n.was-validated .form-check-input:valid, .form-check-input.is-valid {\n border-color: var(--bs-form-valid-border-color);\n}\n.was-validated .form-check-input:valid:checked, .form-check-input.is-valid:checked {\n background-color: var(--bs-form-valid-color);\n}\n.was-validated .form-check-input:valid:focus, .form-check-input.is-valid:focus {\n box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);\n}\n.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label {\n color: var(--bs-form-valid-color);\n}\n\n.form-check-inline .form-check-input ~ .valid-feedback {\n margin-left: 0.5em;\n}\n\n.was-validated .input-group > .form-control:not(:focus):valid, .input-group > .form-control:not(:focus).is-valid,\n.was-validated .input-group > .form-select:not(:focus):valid,\n.input-group > .form-select:not(:focus).is-valid,\n.was-validated .input-group > .form-floating:not(:focus-within):valid,\n.input-group > .form-floating:not(:focus-within).is-valid {\n z-index: 3;\n}\n\n.invalid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 0.875em;\n color: var(--bs-form-invalid-color);\n}\n\n.invalid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: 0.1rem;\n font-size: 0.875rem;\n color: #fff;\n background-color: var(--bs-danger);\n border-radius: var(--bs-border-radius);\n}\n\n.was-validated :invalid ~ .invalid-feedback,\n.was-validated :invalid ~ .invalid-tooltip,\n.is-invalid ~ .invalid-feedback,\n.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-control:invalid, .form-control.is-invalid {\n border-color: var(--bs-form-invalid-border-color);\n padding-right: calc(1.5em + 0.75rem);\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e\");\n background-repeat: no-repeat;\n background-position: right calc(0.375em + 0.1875rem) center;\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus {\n border-color: var(--bs-form-invalid-border-color);\n box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25);\n}\n\n.was-validated textarea.form-control:invalid, textarea.form-control.is-invalid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .form-select:invalid, .form-select.is-invalid {\n border-color: var(--bs-form-invalid-border-color);\n}\n.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size=\"1\"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size=\"1\"] {\n --bs-form-select-bg-icon: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e\");\n padding-right: 4.125rem;\n background-position: right 0.75rem center, center right 2.25rem;\n background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-select:invalid:focus, .form-select.is-invalid:focus {\n border-color: var(--bs-form-invalid-border-color);\n box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25);\n}\n\n.was-validated .form-control-color:invalid, .form-control-color.is-invalid {\n width: calc(3rem + calc(1.5em + 0.75rem));\n}\n\n.was-validated .form-check-input:invalid, .form-check-input.is-invalid {\n border-color: var(--bs-form-invalid-border-color);\n}\n.was-validated .form-check-input:invalid:checked, .form-check-input.is-invalid:checked {\n background-color: var(--bs-form-invalid-color);\n}\n.was-validated .form-check-input:invalid:focus, .form-check-input.is-invalid:focus {\n box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25);\n}\n.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label {\n color: var(--bs-form-invalid-color);\n}\n\n.form-check-inline .form-check-input ~ .invalid-feedback {\n margin-left: 0.5em;\n}\n\n.was-validated .input-group > .form-control:not(:focus):invalid, .input-group > .form-control:not(:focus).is-invalid,\n.was-validated .input-group > .form-select:not(:focus):invalid,\n.input-group > .form-select:not(:focus).is-invalid,\n.was-validated .input-group > .form-floating:not(:focus-within):invalid,\n.input-group > .form-floating:not(:focus-within).is-invalid {\n z-index: 4;\n}\n\n.btn {\n --bs-btn-padding-x: 0.75rem;\n --bs-btn-padding-y: 0.375rem;\n --bs-btn-font-family: ;\n --bs-btn-font-size: 1rem;\n --bs-btn-font-weight: 400;\n --bs-btn-line-height: 1.5;\n --bs-btn-color: var(--bs-body-color);\n --bs-btn-bg: transparent;\n --bs-btn-border-width: var(--bs-border-width);\n --bs-btn-border-color: transparent;\n --bs-btn-border-radius: var(--bs-border-radius);\n --bs-btn-hover-border-color: transparent;\n --bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n --bs-btn-disabled-opacity: 0.65;\n --bs-btn-focus-box-shadow: 0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);\n display: inline-block;\n padding: var(--bs-btn-padding-y) var(--bs-btn-padding-x);\n font-family: var(--bs-btn-font-family);\n font-size: var(--bs-btn-font-size);\n font-weight: var(--bs-btn-font-weight);\n line-height: var(--bs-btn-line-height);\n color: var(--bs-btn-color);\n text-align: center;\n text-decoration: none;\n vertical-align: middle;\n cursor: pointer;\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n border: var(--bs-btn-border-width) solid var(--bs-btn-border-color);\n border-radius: var(--bs-btn-border-radius);\n background-color: var(--bs-btn-bg);\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .btn {\n transition: none;\n }\n}\n.btn:hover {\n color: var(--bs-btn-hover-color);\n background-color: var(--bs-btn-hover-bg);\n border-color: var(--bs-btn-hover-border-color);\n}\n.btn-check + .btn:hover {\n color: var(--bs-btn-color);\n background-color: var(--bs-btn-bg);\n border-color: var(--bs-btn-border-color);\n}\n.btn:focus-visible {\n color: var(--bs-btn-hover-color);\n background-color: var(--bs-btn-hover-bg);\n border-color: var(--bs-btn-hover-border-color);\n outline: 0;\n box-shadow: var(--bs-btn-focus-box-shadow);\n}\n.btn-check:focus-visible + .btn {\n border-color: var(--bs-btn-hover-border-color);\n outline: 0;\n box-shadow: var(--bs-btn-focus-box-shadow);\n}\n.btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show {\n color: var(--bs-btn-active-color);\n background-color: var(--bs-btn-active-bg);\n border-color: var(--bs-btn-active-border-color);\n}\n.btn-check:checked + .btn:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .btn:first-child:active:focus-visible, .btn.active:focus-visible, .btn.show:focus-visible {\n box-shadow: var(--bs-btn-focus-box-shadow);\n}\n.btn-check:checked:focus-visible + .btn {\n box-shadow: var(--bs-btn-focus-box-shadow);\n}\n.btn:disabled, .btn.disabled, fieldset:disabled .btn {\n color: var(--bs-btn-disabled-color);\n pointer-events: none;\n background-color: var(--bs-btn-disabled-bg);\n border-color: var(--bs-btn-disabled-border-color);\n opacity: var(--bs-btn-disabled-opacity);\n}\n\n.btn-primary {\n --bs-btn-color: #fff;\n --bs-btn-bg: #0d6efd;\n --bs-btn-border-color: #0d6efd;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #0b5ed7;\n --bs-btn-hover-border-color: #0a58ca;\n --bs-btn-focus-shadow-rgb: 49, 132, 253;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #0a58ca;\n --bs-btn-active-border-color: #0a53be;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #fff;\n --bs-btn-disabled-bg: #0d6efd;\n --bs-btn-disabled-border-color: #0d6efd;\n}\n\n.btn-secondary {\n --bs-btn-color: #fff;\n --bs-btn-bg: #6c757d;\n --bs-btn-border-color: #6c757d;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #5c636a;\n --bs-btn-hover-border-color: #565e64;\n --bs-btn-focus-shadow-rgb: 130, 138, 145;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #565e64;\n --bs-btn-active-border-color: #51585e;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #fff;\n --bs-btn-disabled-bg: #6c757d;\n --bs-btn-disabled-border-color: #6c757d;\n}\n\n.btn-success {\n --bs-btn-color: #fff;\n --bs-btn-bg: #198754;\n --bs-btn-border-color: #198754;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #157347;\n --bs-btn-hover-border-color: #146c43;\n --bs-btn-focus-shadow-rgb: 60, 153, 110;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #146c43;\n --bs-btn-active-border-color: #13653f;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #fff;\n --bs-btn-disabled-bg: #198754;\n --bs-btn-disabled-border-color: #198754;\n}\n\n.btn-info {\n --bs-btn-color: #000;\n --bs-btn-bg: #0dcaf0;\n --bs-btn-border-color: #0dcaf0;\n --bs-btn-hover-color: #000;\n --bs-btn-hover-bg: #31d2f2;\n --bs-btn-hover-border-color: #25cff2;\n --bs-btn-focus-shadow-rgb: 11, 172, 204;\n --bs-btn-active-color: #000;\n --bs-btn-active-bg: #3dd5f3;\n --bs-btn-active-border-color: #25cff2;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #000;\n --bs-btn-disabled-bg: #0dcaf0;\n --bs-btn-disabled-border-color: #0dcaf0;\n}\n\n.btn-warning {\n --bs-btn-color: #000;\n --bs-btn-bg: #ffc107;\n --bs-btn-border-color: #ffc107;\n --bs-btn-hover-color: #000;\n --bs-btn-hover-bg: #ffca2c;\n --bs-btn-hover-border-color: #ffc720;\n --bs-btn-focus-shadow-rgb: 217, 164, 6;\n --bs-btn-active-color: #000;\n --bs-btn-active-bg: #ffcd39;\n --bs-btn-active-border-color: #ffc720;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #000;\n --bs-btn-disabled-bg: #ffc107;\n --bs-btn-disabled-border-color: #ffc107;\n}\n\n.btn-danger {\n --bs-btn-color: #fff;\n --bs-btn-bg: #dc3545;\n --bs-btn-border-color: #dc3545;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #bb2d3b;\n --bs-btn-hover-border-color: #b02a37;\n --bs-btn-focus-shadow-rgb: 225, 83, 97;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #b02a37;\n --bs-btn-active-border-color: #a52834;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #fff;\n --bs-btn-disabled-bg: #dc3545;\n --bs-btn-disabled-border-color: #dc3545;\n}\n\n.btn-light {\n --bs-btn-color: #000;\n --bs-btn-bg: #f8f9fa;\n --bs-btn-border-color: #f8f9fa;\n --bs-btn-hover-color: #000;\n --bs-btn-hover-bg: #d3d4d5;\n --bs-btn-hover-border-color: #c6c7c8;\n --bs-btn-focus-shadow-rgb: 211, 212, 213;\n --bs-btn-active-color: #000;\n --bs-btn-active-bg: #c6c7c8;\n --bs-btn-active-border-color: #babbbc;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #000;\n --bs-btn-disabled-bg: #f8f9fa;\n --bs-btn-disabled-border-color: #f8f9fa;\n}\n\n.btn-dark {\n --bs-btn-color: #fff;\n --bs-btn-bg: #212529;\n --bs-btn-border-color: #212529;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #424649;\n --bs-btn-hover-border-color: #373b3e;\n --bs-btn-focus-shadow-rgb: 66, 70, 73;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #4d5154;\n --bs-btn-active-border-color: #373b3e;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #fff;\n --bs-btn-disabled-bg: #212529;\n --bs-btn-disabled-border-color: #212529;\n}\n\n.btn-outline-primary {\n --bs-btn-color: #0d6efd;\n --bs-btn-border-color: #0d6efd;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #0d6efd;\n --bs-btn-hover-border-color: #0d6efd;\n --bs-btn-focus-shadow-rgb: 13, 110, 253;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #0d6efd;\n --bs-btn-active-border-color: #0d6efd;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #0d6efd;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #0d6efd;\n --bs-gradient: none;\n}\n\n.btn-outline-secondary {\n --bs-btn-color: #6c757d;\n --bs-btn-border-color: #6c757d;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #6c757d;\n --bs-btn-hover-border-color: #6c757d;\n --bs-btn-focus-shadow-rgb: 108, 117, 125;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #6c757d;\n --bs-btn-active-border-color: #6c757d;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #6c757d;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #6c757d;\n --bs-gradient: none;\n}\n\n.btn-outline-success {\n --bs-btn-color: #198754;\n --bs-btn-border-color: #198754;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #198754;\n --bs-btn-hover-border-color: #198754;\n --bs-btn-focus-shadow-rgb: 25, 135, 84;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #198754;\n --bs-btn-active-border-color: #198754;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #198754;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #198754;\n --bs-gradient: none;\n}\n\n.btn-outline-info {\n --bs-btn-color: #0dcaf0;\n --bs-btn-border-color: #0dcaf0;\n --bs-btn-hover-color: #000;\n --bs-btn-hover-bg: #0dcaf0;\n --bs-btn-hover-border-color: #0dcaf0;\n --bs-btn-focus-shadow-rgb: 13, 202, 240;\n --bs-btn-active-color: #000;\n --bs-btn-active-bg: #0dcaf0;\n --bs-btn-active-border-color: #0dcaf0;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #0dcaf0;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #0dcaf0;\n --bs-gradient: none;\n}\n\n.btn-outline-warning {\n --bs-btn-color: #ffc107;\n --bs-btn-border-color: #ffc107;\n --bs-btn-hover-color: #000;\n --bs-btn-hover-bg: #ffc107;\n --bs-btn-hover-border-color: #ffc107;\n --bs-btn-focus-shadow-rgb: 255, 193, 7;\n --bs-btn-active-color: #000;\n --bs-btn-active-bg: #ffc107;\n --bs-btn-active-border-color: #ffc107;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #ffc107;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #ffc107;\n --bs-gradient: none;\n}\n\n.btn-outline-danger {\n --bs-btn-color: #dc3545;\n --bs-btn-border-color: #dc3545;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #dc3545;\n --bs-btn-hover-border-color: #dc3545;\n --bs-btn-focus-shadow-rgb: 220, 53, 69;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #dc3545;\n --bs-btn-active-border-color: #dc3545;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #dc3545;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #dc3545;\n --bs-gradient: none;\n}\n\n.btn-outline-light {\n --bs-btn-color: #f8f9fa;\n --bs-btn-border-color: #f8f9fa;\n --bs-btn-hover-color: #000;\n --bs-btn-hover-bg: #f8f9fa;\n --bs-btn-hover-border-color: #f8f9fa;\n --bs-btn-focus-shadow-rgb: 248, 249, 250;\n --bs-btn-active-color: #000;\n --bs-btn-active-bg: #f8f9fa;\n --bs-btn-active-border-color: #f8f9fa;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #f8f9fa;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #f8f9fa;\n --bs-gradient: none;\n}\n\n.btn-outline-dark {\n --bs-btn-color: #212529;\n --bs-btn-border-color: #212529;\n --bs-btn-hover-color: #fff;\n --bs-btn-hover-bg: #212529;\n --bs-btn-hover-border-color: #212529;\n --bs-btn-focus-shadow-rgb: 33, 37, 41;\n --bs-btn-active-color: #fff;\n --bs-btn-active-bg: #212529;\n --bs-btn-active-border-color: #212529;\n --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n --bs-btn-disabled-color: #212529;\n --bs-btn-disabled-bg: transparent;\n --bs-btn-disabled-border-color: #212529;\n --bs-gradient: none;\n}\n\n.btn-link {\n --bs-btn-font-weight: 400;\n --bs-btn-color: var(--bs-link-color);\n --bs-btn-bg: transparent;\n --bs-btn-border-color: transparent;\n --bs-btn-hover-color: var(--bs-link-hover-color);\n --bs-btn-hover-border-color: transparent;\n --bs-btn-active-color: var(--bs-link-hover-color);\n --bs-btn-active-border-color: transparent;\n --bs-btn-disabled-color: #6c757d;\n --bs-btn-disabled-border-color: transparent;\n --bs-btn-box-shadow: 0 0 0 #000;\n --bs-btn-focus-shadow-rgb: 49, 132, 253;\n text-decoration: underline;\n}\n.btn-link:focus-visible {\n color: var(--bs-btn-color);\n}\n.btn-link:hover {\n color: var(--bs-btn-hover-color);\n}\n\n.btn-lg, .btn-group-lg > .btn {\n --bs-btn-padding-y: 0.5rem;\n --bs-btn-padding-x: 1rem;\n --bs-btn-font-size: 1.25rem;\n --bs-btn-border-radius: var(--bs-border-radius-lg);\n}\n\n.btn-sm, .btn-group-sm > .btn {\n --bs-btn-padding-y: 0.25rem;\n --bs-btn-padding-x: 0.5rem;\n --bs-btn-font-size: 0.875rem;\n --bs-btn-border-radius: var(--bs-border-radius-sm);\n}\n\n.fade {\n transition: opacity 0.15s linear;\n}\n@media (prefers-reduced-motion: reduce) {\n .fade {\n transition: none;\n }\n}\n.fade:not(.show) {\n opacity: 0;\n}\n\n.collapse:not(.show) {\n display: none;\n}\n\n.collapsing {\n height: 0;\n overflow: hidden;\n transition: height 0.35s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .collapsing {\n transition: none;\n }\n}\n.collapsing.collapse-horizontal {\n width: 0;\n height: auto;\n transition: width 0.35s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .collapsing.collapse-horizontal {\n transition: none;\n }\n}\n\n.dropup,\n.dropend,\n.dropdown,\n.dropstart,\n.dropup-center,\n.dropdown-center {\n position: relative;\n}\n\n.dropdown-toggle {\n white-space: nowrap;\n}\n.dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid;\n border-right: 0.3em solid transparent;\n border-bottom: 0;\n border-left: 0.3em solid transparent;\n}\n.dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropdown-menu {\n --bs-dropdown-zindex: 1000;\n --bs-dropdown-min-width: 10rem;\n --bs-dropdown-padding-x: 0;\n --bs-dropdown-padding-y: 0.5rem;\n --bs-dropdown-spacer: 0.125rem;\n --bs-dropdown-font-size: 1rem;\n --bs-dropdown-color: var(--bs-body-color);\n --bs-dropdown-bg: var(--bs-body-bg);\n --bs-dropdown-border-color: var(--bs-border-color-translucent);\n --bs-dropdown-border-radius: var(--bs-border-radius);\n --bs-dropdown-border-width: var(--bs-border-width);\n --bs-dropdown-inner-border-radius: calc(var(--bs-border-radius) - var(--bs-border-width));\n --bs-dropdown-divider-bg: var(--bs-border-color-translucent);\n --bs-dropdown-divider-margin-y: 0.5rem;\n --bs-dropdown-box-shadow: var(--bs-box-shadow);\n --bs-dropdown-link-color: var(--bs-body-color);\n --bs-dropdown-link-hover-color: var(--bs-body-color);\n --bs-dropdown-link-hover-bg: var(--bs-tertiary-bg);\n --bs-dropdown-link-active-color: #fff;\n --bs-dropdown-link-active-bg: #0d6efd;\n --bs-dropdown-link-disabled-color: var(--bs-tertiary-color);\n --bs-dropdown-item-padding-x: 1rem;\n --bs-dropdown-item-padding-y: 0.25rem;\n --bs-dropdown-header-color: #6c757d;\n --bs-dropdown-header-padding-x: 1rem;\n --bs-dropdown-header-padding-y: 0.5rem;\n position: absolute;\n z-index: var(--bs-dropdown-zindex);\n display: none;\n min-width: var(--bs-dropdown-min-width);\n padding: var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);\n margin: 0;\n font-size: var(--bs-dropdown-font-size);\n color: var(--bs-dropdown-color);\n text-align: left;\n list-style: none;\n background-color: var(--bs-dropdown-bg);\n background-clip: padding-box;\n border: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);\n border-radius: var(--bs-dropdown-border-radius);\n}\n.dropdown-menu[data-bs-popper] {\n top: 100%;\n left: 0;\n margin-top: var(--bs-dropdown-spacer);\n}\n\n.dropdown-menu-start {\n --bs-position: start;\n}\n.dropdown-menu-start[data-bs-popper] {\n right: auto;\n left: 0;\n}\n\n.dropdown-menu-end {\n --bs-position: end;\n}\n.dropdown-menu-end[data-bs-popper] {\n right: 0;\n left: auto;\n}\n\n@media (min-width: 576px) {\n .dropdown-menu-sm-start {\n --bs-position: start;\n }\n .dropdown-menu-sm-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n .dropdown-menu-sm-end {\n --bs-position: end;\n }\n .dropdown-menu-sm-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 768px) {\n .dropdown-menu-md-start {\n --bs-position: start;\n }\n .dropdown-menu-md-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n .dropdown-menu-md-end {\n --bs-position: end;\n }\n .dropdown-menu-md-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 992px) {\n .dropdown-menu-lg-start {\n --bs-position: start;\n }\n .dropdown-menu-lg-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n .dropdown-menu-lg-end {\n --bs-position: end;\n }\n .dropdown-menu-lg-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 1200px) {\n .dropdown-menu-xl-start {\n --bs-position: start;\n }\n .dropdown-menu-xl-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n .dropdown-menu-xl-end {\n --bs-position: end;\n }\n .dropdown-menu-xl-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 1400px) {\n .dropdown-menu-xxl-start {\n --bs-position: start;\n }\n .dropdown-menu-xxl-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n .dropdown-menu-xxl-end {\n --bs-position: end;\n }\n .dropdown-menu-xxl-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n.dropup .dropdown-menu[data-bs-popper] {\n top: auto;\n bottom: 100%;\n margin-top: 0;\n margin-bottom: var(--bs-dropdown-spacer);\n}\n.dropup .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0;\n border-right: 0.3em solid transparent;\n border-bottom: 0.3em solid;\n border-left: 0.3em solid transparent;\n}\n.dropup .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropend .dropdown-menu[data-bs-popper] {\n top: 0;\n right: auto;\n left: 100%;\n margin-top: 0;\n margin-left: var(--bs-dropdown-spacer);\n}\n.dropend .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0;\n border-bottom: 0.3em solid transparent;\n border-left: 0.3em solid;\n}\n.dropend .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n.dropend .dropdown-toggle::after {\n vertical-align: 0;\n}\n\n.dropstart .dropdown-menu[data-bs-popper] {\n top: 0;\n right: 100%;\n left: auto;\n margin-top: 0;\n margin-right: var(--bs-dropdown-spacer);\n}\n.dropstart .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n}\n.dropstart .dropdown-toggle::after {\n display: none;\n}\n.dropstart .dropdown-toggle::before {\n display: inline-block;\n margin-right: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0.3em solid;\n border-bottom: 0.3em solid transparent;\n}\n.dropstart .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n.dropstart .dropdown-toggle::before {\n vertical-align: 0;\n}\n\n.dropdown-divider {\n height: 0;\n margin: var(--bs-dropdown-divider-margin-y) 0;\n overflow: hidden;\n border-top: 1px solid var(--bs-dropdown-divider-bg);\n opacity: 1;\n}\n\n.dropdown-item {\n display: block;\n width: 100%;\n padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);\n clear: both;\n font-weight: 400;\n color: var(--bs-dropdown-link-color);\n text-align: inherit;\n text-decoration: none;\n white-space: nowrap;\n background-color: transparent;\n border: 0;\n border-radius: var(--bs-dropdown-item-border-radius, 0);\n}\n.dropdown-item:hover, .dropdown-item:focus {\n color: var(--bs-dropdown-link-hover-color);\n background-color: var(--bs-dropdown-link-hover-bg);\n}\n.dropdown-item.active, .dropdown-item:active {\n color: var(--bs-dropdown-link-active-color);\n text-decoration: none;\n background-color: var(--bs-dropdown-link-active-bg);\n}\n.dropdown-item.disabled, .dropdown-item:disabled {\n color: var(--bs-dropdown-link-disabled-color);\n pointer-events: none;\n background-color: transparent;\n}\n\n.dropdown-menu.show {\n display: block;\n}\n\n.dropdown-header {\n display: block;\n padding: var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);\n margin-bottom: 0;\n font-size: 0.875rem;\n color: var(--bs-dropdown-header-color);\n white-space: nowrap;\n}\n\n.dropdown-item-text {\n display: block;\n padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);\n color: var(--bs-dropdown-link-color);\n}\n\n.dropdown-menu-dark {\n --bs-dropdown-color: #dee2e6;\n --bs-dropdown-bg: #343a40;\n --bs-dropdown-border-color: var(--bs-border-color-translucent);\n --bs-dropdown-box-shadow: ;\n --bs-dropdown-link-color: #dee2e6;\n --bs-dropdown-link-hover-color: #fff;\n --bs-dropdown-divider-bg: var(--bs-border-color-translucent);\n --bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.15);\n --bs-dropdown-link-active-color: #fff;\n --bs-dropdown-link-active-bg: #0d6efd;\n --bs-dropdown-link-disabled-color: #adb5bd;\n --bs-dropdown-header-color: #adb5bd;\n}\n\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-flex;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n flex: 1 1 auto;\n}\n.btn-group > .btn-check:checked + .btn,\n.btn-group > .btn-check:focus + .btn,\n.btn-group > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn-check:checked + .btn,\n.btn-group-vertical > .btn-check:focus + .btn,\n.btn-group-vertical > .btn:hover,\n.btn-group-vertical > .btn:focus,\n.btn-group-vertical > .btn:active,\n.btn-group-vertical > .btn.active {\n z-index: 1;\n}\n\n.btn-toolbar {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-start;\n}\n.btn-toolbar .input-group {\n width: auto;\n}\n\n.btn-group {\n border-radius: var(--bs-border-radius);\n}\n.btn-group > :not(.btn-check:first-child) + .btn,\n.btn-group > .btn-group:not(:first-child) {\n margin-left: calc(var(--bs-border-width) * -1);\n}\n.btn-group > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group > .btn.dropdown-toggle-split:first-child,\n.btn-group > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn:nth-child(n+3),\n.btn-group > :not(.btn-check) + .btn,\n.btn-group > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.dropdown-toggle-split {\n padding-right: 0.5625rem;\n padding-left: 0.5625rem;\n}\n.dropdown-toggle-split::after, .dropup .dropdown-toggle-split::after, .dropend .dropdown-toggle-split::after {\n margin-left: 0;\n}\n.dropstart .dropdown-toggle-split::before {\n margin-right: 0;\n}\n\n.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {\n padding-right: 0.375rem;\n padding-left: 0.375rem;\n}\n\n.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {\n padding-right: 0.75rem;\n padding-left: 0.75rem;\n}\n\n.btn-group-vertical {\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group {\n width: 100%;\n}\n.btn-group-vertical > .btn:not(:first-child),\n.btn-group-vertical > .btn-group:not(:first-child) {\n margin-top: calc(var(--bs-border-width) * -1);\n}\n.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group-vertical > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn ~ .btn,\n.btn-group-vertical > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.nav {\n --bs-nav-link-padding-x: 1rem;\n --bs-nav-link-padding-y: 0.5rem;\n --bs-nav-link-font-weight: ;\n --bs-nav-link-color: var(--bs-link-color);\n --bs-nav-link-hover-color: var(--bs-link-hover-color);\n --bs-nav-link-disabled-color: var(--bs-secondary-color);\n display: flex;\n flex-wrap: wrap;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.nav-link {\n display: block;\n padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);\n font-size: var(--bs-nav-link-font-size);\n font-weight: var(--bs-nav-link-font-weight);\n color: var(--bs-nav-link-color);\n text-decoration: none;\n background: none;\n border: 0;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .nav-link {\n transition: none;\n }\n}\n.nav-link:hover, .nav-link:focus {\n color: var(--bs-nav-link-hover-color);\n}\n.nav-link:focus-visible {\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.nav-link.disabled, .nav-link:disabled {\n color: var(--bs-nav-link-disabled-color);\n pointer-events: none;\n cursor: default;\n}\n\n.nav-tabs {\n --bs-nav-tabs-border-width: var(--bs-border-width);\n --bs-nav-tabs-border-color: var(--bs-border-color);\n --bs-nav-tabs-border-radius: var(--bs-border-radius);\n --bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);\n --bs-nav-tabs-link-active-color: var(--bs-emphasis-color);\n --bs-nav-tabs-link-active-bg: var(--bs-body-bg);\n --bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);\n border-bottom: var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color);\n}\n.nav-tabs .nav-link {\n margin-bottom: calc(-1 * var(--bs-nav-tabs-border-width));\n border: var(--bs-nav-tabs-border-width) solid transparent;\n border-top-left-radius: var(--bs-nav-tabs-border-radius);\n border-top-right-radius: var(--bs-nav-tabs-border-radius);\n}\n.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {\n isolation: isolate;\n border-color: var(--bs-nav-tabs-link-hover-border-color);\n}\n.nav-tabs .nav-link.active,\n.nav-tabs .nav-item.show .nav-link {\n color: var(--bs-nav-tabs-link-active-color);\n background-color: var(--bs-nav-tabs-link-active-bg);\n border-color: var(--bs-nav-tabs-link-active-border-color);\n}\n.nav-tabs .dropdown-menu {\n margin-top: calc(-1 * var(--bs-nav-tabs-border-width));\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.nav-pills {\n --bs-nav-pills-border-radius: var(--bs-border-radius);\n --bs-nav-pills-link-active-color: #fff;\n --bs-nav-pills-link-active-bg: #0d6efd;\n}\n.nav-pills .nav-link {\n border-radius: var(--bs-nav-pills-border-radius);\n}\n.nav-pills .nav-link.active,\n.nav-pills .show > .nav-link {\n color: var(--bs-nav-pills-link-active-color);\n background-color: var(--bs-nav-pills-link-active-bg);\n}\n\n.nav-underline {\n --bs-nav-underline-gap: 1rem;\n --bs-nav-underline-border-width: 0.125rem;\n --bs-nav-underline-link-active-color: var(--bs-emphasis-color);\n gap: var(--bs-nav-underline-gap);\n}\n.nav-underline .nav-link {\n padding-right: 0;\n padding-left: 0;\n border-bottom: var(--bs-nav-underline-border-width) solid transparent;\n}\n.nav-underline .nav-link:hover, .nav-underline .nav-link:focus {\n border-bottom-color: currentcolor;\n}\n.nav-underline .nav-link.active,\n.nav-underline .show > .nav-link {\n font-weight: 700;\n color: var(--bs-nav-underline-link-active-color);\n border-bottom-color: currentcolor;\n}\n\n.nav-fill > .nav-link,\n.nav-fill .nav-item {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.nav-justified > .nav-link,\n.nav-justified .nav-item {\n flex-basis: 0;\n flex-grow: 1;\n text-align: center;\n}\n\n.nav-fill .nav-item .nav-link,\n.nav-justified .nav-item .nav-link {\n width: 100%;\n}\n\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n\n.navbar {\n --bs-navbar-padding-x: 0;\n --bs-navbar-padding-y: 0.5rem;\n --bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65);\n --bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8);\n --bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3);\n --bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1);\n --bs-navbar-brand-padding-y: 0.3125rem;\n --bs-navbar-brand-margin-end: 1rem;\n --bs-navbar-brand-font-size: 1.25rem;\n --bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1);\n --bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1);\n --bs-navbar-nav-link-padding-x: 0.5rem;\n --bs-navbar-toggler-padding-y: 0.25rem;\n --bs-navbar-toggler-padding-x: 0.75rem;\n --bs-navbar-toggler-font-size: 1.25rem;\n --bs-navbar-toggler-icon-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e\");\n --bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15);\n --bs-navbar-toggler-border-radius: var(--bs-border-radius);\n --bs-navbar-toggler-focus-width: 0.25rem;\n --bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n padding: var(--bs-navbar-padding-y) var(--bs-navbar-padding-x);\n}\n.navbar > .container,\n.navbar > .container-fluid,\n.navbar > .container-sm,\n.navbar > .container-md,\n.navbar > .container-lg,\n.navbar > .container-xl,\n.navbar > .container-xxl {\n display: flex;\n flex-wrap: inherit;\n align-items: center;\n justify-content: space-between;\n}\n.navbar-brand {\n padding-top: var(--bs-navbar-brand-padding-y);\n padding-bottom: var(--bs-navbar-brand-padding-y);\n margin-right: var(--bs-navbar-brand-margin-end);\n font-size: var(--bs-navbar-brand-font-size);\n color: var(--bs-navbar-brand-color);\n text-decoration: none;\n white-space: nowrap;\n}\n.navbar-brand:hover, .navbar-brand:focus {\n color: var(--bs-navbar-brand-hover-color);\n}\n\n.navbar-nav {\n --bs-nav-link-padding-x: 0;\n --bs-nav-link-padding-y: 0.5rem;\n --bs-nav-link-font-weight: ;\n --bs-nav-link-color: var(--bs-navbar-color);\n --bs-nav-link-hover-color: var(--bs-navbar-hover-color);\n --bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n.navbar-nav .nav-link.active, .navbar-nav .nav-link.show {\n color: var(--bs-navbar-active-color);\n}\n.navbar-nav .dropdown-menu {\n position: static;\n}\n\n.navbar-text {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: var(--bs-navbar-color);\n}\n.navbar-text a,\n.navbar-text a:hover,\n.navbar-text a:focus {\n color: var(--bs-navbar-active-color);\n}\n\n.navbar-collapse {\n flex-basis: 100%;\n flex-grow: 1;\n align-items: center;\n}\n\n.navbar-toggler {\n padding: var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);\n font-size: var(--bs-navbar-toggler-font-size);\n line-height: 1;\n color: var(--bs-navbar-color);\n background-color: transparent;\n border: var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);\n border-radius: var(--bs-navbar-toggler-border-radius);\n transition: var(--bs-navbar-toggler-transition);\n}\n@media (prefers-reduced-motion: reduce) {\n .navbar-toggler {\n transition: none;\n }\n}\n.navbar-toggler:hover {\n text-decoration: none;\n}\n.navbar-toggler:focus {\n text-decoration: none;\n outline: 0;\n box-shadow: 0 0 0 var(--bs-navbar-toggler-focus-width);\n}\n\n.navbar-toggler-icon {\n display: inline-block;\n width: 1.5em;\n height: 1.5em;\n vertical-align: middle;\n background-image: var(--bs-navbar-toggler-icon-bg);\n background-repeat: no-repeat;\n background-position: center;\n background-size: 100%;\n}\n\n.navbar-nav-scroll {\n max-height: var(--bs-scroll-height, 75vh);\n overflow-y: auto;\n}\n\n@media (min-width: 576px) {\n .navbar-expand-sm {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-sm .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-sm .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-sm .navbar-nav .nav-link {\n padding-right: var(--bs-navbar-nav-link-padding-x);\n padding-left: var(--bs-navbar-nav-link-padding-x);\n }\n .navbar-expand-sm .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-sm .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-sm .navbar-toggler {\n display: none;\n }\n .navbar-expand-sm .offcanvas {\n position: static;\n z-index: auto;\n flex-grow: 1;\n width: auto !important;\n height: auto !important;\n visibility: visible !important;\n background-color: transparent !important;\n border: 0 !important;\n transform: none !important;\n transition: none;\n }\n .navbar-expand-sm .offcanvas .offcanvas-header {\n display: none;\n }\n .navbar-expand-sm .offcanvas .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 768px) {\n .navbar-expand-md {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-md .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-md .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-md .navbar-nav .nav-link {\n padding-right: var(--bs-navbar-nav-link-padding-x);\n padding-left: var(--bs-navbar-nav-link-padding-x);\n }\n .navbar-expand-md .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-md .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-md .navbar-toggler {\n display: none;\n }\n .navbar-expand-md .offcanvas {\n position: static;\n z-index: auto;\n flex-grow: 1;\n width: auto !important;\n height: auto !important;\n visibility: visible !important;\n background-color: transparent !important;\n border: 0 !important;\n transform: none !important;\n transition: none;\n }\n .navbar-expand-md .offcanvas .offcanvas-header {\n display: none;\n }\n .navbar-expand-md .offcanvas .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 992px) {\n .navbar-expand-lg {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-lg .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-lg .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-lg .navbar-nav .nav-link {\n padding-right: var(--bs-navbar-nav-link-padding-x);\n padding-left: var(--bs-navbar-nav-link-padding-x);\n }\n .navbar-expand-lg .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-lg .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-lg .navbar-toggler {\n display: none;\n }\n .navbar-expand-lg .offcanvas {\n position: static;\n z-index: auto;\n flex-grow: 1;\n width: auto !important;\n height: auto !important;\n visibility: visible !important;\n background-color: transparent !important;\n border: 0 !important;\n transform: none !important;\n transition: none;\n }\n .navbar-expand-lg .offcanvas .offcanvas-header {\n display: none;\n }\n .navbar-expand-lg .offcanvas .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 1200px) {\n .navbar-expand-xl {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-xl .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-xl .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-xl .navbar-nav .nav-link {\n padding-right: var(--bs-navbar-nav-link-padding-x);\n padding-left: var(--bs-navbar-nav-link-padding-x);\n }\n .navbar-expand-xl .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-xl .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-xl .navbar-toggler {\n display: none;\n }\n .navbar-expand-xl .offcanvas {\n position: static;\n z-index: auto;\n flex-grow: 1;\n width: auto !important;\n height: auto !important;\n visibility: visible !important;\n background-color: transparent !important;\n border: 0 !important;\n transform: none !important;\n transition: none;\n }\n .navbar-expand-xl .offcanvas .offcanvas-header {\n display: none;\n }\n .navbar-expand-xl .offcanvas .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 1400px) {\n .navbar-expand-xxl {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-xxl .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-xxl .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-xxl .navbar-nav .nav-link {\n padding-right: var(--bs-navbar-nav-link-padding-x);\n padding-left: var(--bs-navbar-nav-link-padding-x);\n }\n .navbar-expand-xxl .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-xxl .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-xxl .navbar-toggler {\n display: none;\n }\n .navbar-expand-xxl .offcanvas {\n position: static;\n z-index: auto;\n flex-grow: 1;\n width: auto !important;\n height: auto !important;\n visibility: visible !important;\n background-color: transparent !important;\n border: 0 !important;\n transform: none !important;\n transition: none;\n }\n .navbar-expand-xxl .offcanvas .offcanvas-header {\n display: none;\n }\n .navbar-expand-xxl .offcanvas .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n.navbar-expand {\n flex-wrap: nowrap;\n justify-content: flex-start;\n}\n.navbar-expand .navbar-nav {\n flex-direction: row;\n}\n.navbar-expand .navbar-nav .dropdown-menu {\n position: absolute;\n}\n.navbar-expand .navbar-nav .nav-link {\n padding-right: var(--bs-navbar-nav-link-padding-x);\n padding-left: var(--bs-navbar-nav-link-padding-x);\n}\n.navbar-expand .navbar-nav-scroll {\n overflow: visible;\n}\n.navbar-expand .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n}\n.navbar-expand .navbar-toggler {\n display: none;\n}\n.navbar-expand .offcanvas {\n position: static;\n z-index: auto;\n flex-grow: 1;\n width: auto !important;\n height: auto !important;\n visibility: visible !important;\n background-color: transparent !important;\n border: 0 !important;\n transform: none !important;\n transition: none;\n}\n.navbar-expand .offcanvas .offcanvas-header {\n display: none;\n}\n.navbar-expand .offcanvas .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n}\n\n.navbar-dark,\n.navbar[data-bs-theme=dark] {\n --bs-navbar-color: rgba(255, 255, 255, 0.55);\n --bs-navbar-hover-color: rgba(255, 255, 255, 0.75);\n --bs-navbar-disabled-color: rgba(255, 255, 255, 0.25);\n --bs-navbar-active-color: #fff;\n --bs-navbar-brand-color: #fff;\n --bs-navbar-brand-hover-color: #fff;\n --bs-navbar-toggler-border-color: rgba(255, 255, 255, 0.1);\n --bs-navbar-toggler-icon-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e\");\n}\n\n[data-bs-theme=dark] .navbar-toggler-icon {\n --bs-navbar-toggler-icon-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e\");\n}\n\n.card {\n --bs-card-spacer-y: 1rem;\n --bs-card-spacer-x: 1rem;\n --bs-card-title-spacer-y: 0.5rem;\n --bs-card-title-color: ;\n --bs-card-subtitle-color: ;\n --bs-card-border-width: var(--bs-border-width);\n --bs-card-border-color: var(--bs-border-color-translucent);\n --bs-card-border-radius: var(--bs-border-radius);\n --bs-card-box-shadow: ;\n --bs-card-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width)));\n --bs-card-cap-padding-y: 0.5rem;\n --bs-card-cap-padding-x: 1rem;\n --bs-card-cap-bg: rgba(var(--bs-body-color-rgb), 0.03);\n --bs-card-cap-color: ;\n --bs-card-height: ;\n --bs-card-color: ;\n --bs-card-bg: var(--bs-body-bg);\n --bs-card-img-overlay-padding: 1rem;\n --bs-card-group-margin: 0.75rem;\n position: relative;\n display: flex;\n flex-direction: column;\n min-width: 0;\n height: var(--bs-card-height);\n color: var(--bs-body-color);\n word-wrap: break-word;\n background-color: var(--bs-card-bg);\n background-clip: border-box;\n border: var(--bs-card-border-width) solid var(--bs-card-border-color);\n border-radius: var(--bs-card-border-radius);\n}\n.card > hr {\n margin-right: 0;\n margin-left: 0;\n}\n.card > .list-group {\n border-top: inherit;\n border-bottom: inherit;\n}\n.card > .list-group:first-child {\n border-top-width: 0;\n border-top-left-radius: var(--bs-card-inner-border-radius);\n border-top-right-radius: var(--bs-card-inner-border-radius);\n}\n.card > .list-group:last-child {\n border-bottom-width: 0;\n border-bottom-right-radius: var(--bs-card-inner-border-radius);\n border-bottom-left-radius: var(--bs-card-inner-border-radius);\n}\n.card > .card-header + .list-group,\n.card > .list-group + .card-footer {\n border-top: 0;\n}\n\n.card-body {\n flex: 1 1 auto;\n padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x);\n color: var(--bs-card-color);\n}\n\n.card-title {\n margin-bottom: var(--bs-card-title-spacer-y);\n color: var(--bs-card-title-color);\n}\n\n.card-subtitle {\n margin-top: calc(-0.5 * var(--bs-card-title-spacer-y));\n margin-bottom: 0;\n color: var(--bs-card-subtitle-color);\n}\n\n.card-text:last-child {\n margin-bottom: 0;\n}\n\n.card-link + .card-link {\n margin-left: var(--bs-card-spacer-x);\n}\n\n.card-header {\n padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);\n margin-bottom: 0;\n color: var(--bs-card-cap-color);\n background-color: var(--bs-card-cap-bg);\n border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color);\n}\n.card-header:first-child {\n border-radius: var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0;\n}\n\n.card-footer {\n padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);\n color: var(--bs-card-cap-color);\n background-color: var(--bs-card-cap-bg);\n border-top: var(--bs-card-border-width) solid var(--bs-card-border-color);\n}\n.card-footer:last-child {\n border-radius: 0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius);\n}\n\n.card-header-tabs {\n margin-right: calc(-0.5 * var(--bs-card-cap-padding-x));\n margin-bottom: calc(-1 * var(--bs-card-cap-padding-y));\n margin-left: calc(-0.5 * var(--bs-card-cap-padding-x));\n border-bottom: 0;\n}\n.card-header-tabs .nav-link.active {\n background-color: var(--bs-card-bg);\n border-bottom-color: var(--bs-card-bg);\n}\n\n.card-header-pills {\n margin-right: calc(-0.5 * var(--bs-card-cap-padding-x));\n margin-left: calc(-0.5 * var(--bs-card-cap-padding-x));\n}\n\n.card-img-overlay {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n padding: var(--bs-card-img-overlay-padding);\n border-radius: var(--bs-card-inner-border-radius);\n}\n\n.card-img,\n.card-img-top,\n.card-img-bottom {\n width: 100%;\n}\n\n.card-img,\n.card-img-top {\n border-top-left-radius: var(--bs-card-inner-border-radius);\n border-top-right-radius: var(--bs-card-inner-border-radius);\n}\n\n.card-img,\n.card-img-bottom {\n border-bottom-right-radius: var(--bs-card-inner-border-radius);\n border-bottom-left-radius: var(--bs-card-inner-border-radius);\n}\n\n.card-group > .card {\n margin-bottom: var(--bs-card-group-margin);\n}\n@media (min-width: 576px) {\n .card-group {\n display: flex;\n flex-flow: row wrap;\n }\n .card-group > .card {\n flex: 1 0 0%;\n margin-bottom: 0;\n }\n .card-group > .card + .card {\n margin-left: 0;\n border-left: 0;\n }\n .card-group > .card:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-top,\n .card-group > .card:not(:last-child) .card-header {\n border-top-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-bottom,\n .card-group > .card:not(:last-child) .card-footer {\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-top,\n .card-group > .card:not(:first-child) .card-header {\n border-top-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-bottom,\n .card-group > .card:not(:first-child) .card-footer {\n border-bottom-left-radius: 0;\n }\n}\n\n.accordion {\n --bs-accordion-color: var(--bs-body-color);\n --bs-accordion-bg: var(--bs-body-bg);\n --bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease;\n --bs-accordion-border-color: var(--bs-border-color);\n --bs-accordion-border-width: var(--bs-border-width);\n --bs-accordion-border-radius: var(--bs-border-radius);\n --bs-accordion-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width)));\n --bs-accordion-btn-padding-x: 1.25rem;\n --bs-accordion-btn-padding-y: 1rem;\n --bs-accordion-btn-color: var(--bs-body-color);\n --bs-accordion-btn-bg: var(--bs-accordion-bg);\n --bs-accordion-btn-icon: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e\");\n --bs-accordion-btn-icon-width: 1.25rem;\n --bs-accordion-btn-icon-transform: rotate(-180deg);\n --bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;\n --bs-accordion-btn-active-icon: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e\");\n --bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n --bs-accordion-body-padding-x: 1.25rem;\n --bs-accordion-body-padding-y: 1rem;\n --bs-accordion-active-color: var(--bs-primary-text-emphasis);\n --bs-accordion-active-bg: var(--bs-primary-bg-subtle);\n}\n\n.accordion-button {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n padding: var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);\n font-size: 1rem;\n color: var(--bs-accordion-btn-color);\n text-align: left;\n background-color: var(--bs-accordion-btn-bg);\n border: 0;\n border-radius: 0;\n overflow-anchor: none;\n transition: var(--bs-accordion-transition);\n}\n@media (prefers-reduced-motion: reduce) {\n .accordion-button {\n transition: none;\n }\n}\n.accordion-button:not(.collapsed) {\n color: var(--bs-accordion-active-color);\n background-color: var(--bs-accordion-active-bg);\n box-shadow: inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color);\n}\n.accordion-button:not(.collapsed)::after {\n background-image: var(--bs-accordion-btn-active-icon);\n transform: var(--bs-accordion-btn-icon-transform);\n}\n.accordion-button::after {\n flex-shrink: 0;\n width: var(--bs-accordion-btn-icon-width);\n height: var(--bs-accordion-btn-icon-width);\n margin-left: auto;\n content: \"\";\n background-image: var(--bs-accordion-btn-icon);\n background-repeat: no-repeat;\n background-size: var(--bs-accordion-btn-icon-width);\n transition: var(--bs-accordion-btn-icon-transition);\n}\n@media (prefers-reduced-motion: reduce) {\n .accordion-button::after {\n transition: none;\n }\n}\n.accordion-button:hover {\n z-index: 2;\n}\n.accordion-button:focus {\n z-index: 3;\n outline: 0;\n box-shadow: var(--bs-accordion-btn-focus-box-shadow);\n}\n\n.accordion-header {\n margin-bottom: 0;\n}\n\n.accordion-item {\n color: var(--bs-accordion-color);\n background-color: var(--bs-accordion-bg);\n border: var(--bs-accordion-border-width) solid var(--bs-accordion-border-color);\n}\n.accordion-item:first-of-type {\n border-top-left-radius: var(--bs-accordion-border-radius);\n border-top-right-radius: var(--bs-accordion-border-radius);\n}\n.accordion-item:first-of-type > .accordion-header .accordion-button {\n border-top-left-radius: var(--bs-accordion-inner-border-radius);\n border-top-right-radius: var(--bs-accordion-inner-border-radius);\n}\n.accordion-item:not(:first-of-type) {\n border-top: 0;\n}\n.accordion-item:last-of-type {\n border-bottom-right-radius: var(--bs-accordion-border-radius);\n border-bottom-left-radius: var(--bs-accordion-border-radius);\n}\n.accordion-item:last-of-type > .accordion-header .accordion-button.collapsed {\n border-bottom-right-radius: var(--bs-accordion-inner-border-radius);\n border-bottom-left-radius: var(--bs-accordion-inner-border-radius);\n}\n.accordion-item:last-of-type > .accordion-collapse {\n border-bottom-right-radius: var(--bs-accordion-border-radius);\n border-bottom-left-radius: var(--bs-accordion-border-radius);\n}\n\n.accordion-body {\n padding: var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x);\n}\n\n.accordion-flush > .accordion-item {\n border-right: 0;\n border-left: 0;\n border-radius: 0;\n}\n.accordion-flush > .accordion-item:first-child {\n border-top: 0;\n}\n.accordion-flush > .accordion-item:last-child {\n border-bottom: 0;\n}\n.accordion-flush > .accordion-item > .accordion-header .accordion-button, .accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed {\n border-radius: 0;\n}\n.accordion-flush > .accordion-item > .accordion-collapse {\n border-radius: 0;\n}\n\n[data-bs-theme=dark] .accordion-button::after {\n --bs-accordion-btn-icon: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e\");\n --bs-accordion-btn-active-icon: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e\");\n}\n\n.breadcrumb {\n --bs-breadcrumb-padding-x: 0;\n --bs-breadcrumb-padding-y: 0;\n --bs-breadcrumb-margin-bottom: 1rem;\n --bs-breadcrumb-bg: ;\n --bs-breadcrumb-border-radius: ;\n --bs-breadcrumb-divider-color: var(--bs-secondary-color);\n --bs-breadcrumb-item-padding-x: 0.5rem;\n --bs-breadcrumb-item-active-color: var(--bs-secondary-color);\n display: flex;\n flex-wrap: wrap;\n padding: var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);\n margin-bottom: var(--bs-breadcrumb-margin-bottom);\n font-size: var(--bs-breadcrumb-font-size);\n list-style: none;\n background-color: var(--bs-breadcrumb-bg);\n border-radius: var(--bs-breadcrumb-border-radius);\n}\n\n.breadcrumb-item + .breadcrumb-item {\n padding-left: var(--bs-breadcrumb-item-padding-x);\n}\n.breadcrumb-item + .breadcrumb-item::before {\n float: left;\n padding-right: var(--bs-breadcrumb-item-padding-x);\n color: var(--bs-breadcrumb-divider-color);\n content: var(--bs-breadcrumb-divider, \"/\") /* rtl: var(--bs-breadcrumb-divider, \"/\") */;\n}\n.breadcrumb-item.active {\n color: var(--bs-breadcrumb-item-active-color);\n}\n\n.pagination {\n --bs-pagination-padding-x: 0.75rem;\n --bs-pagination-padding-y: 0.375rem;\n --bs-pagination-font-size: 1rem;\n --bs-pagination-color: var(--bs-link-color);\n --bs-pagination-bg: var(--bs-body-bg);\n --bs-pagination-border-width: var(--bs-border-width);\n --bs-pagination-border-color: var(--bs-border-color);\n --bs-pagination-border-radius: var(--bs-border-radius);\n --bs-pagination-hover-color: var(--bs-link-hover-color);\n --bs-pagination-hover-bg: var(--bs-tertiary-bg);\n --bs-pagination-hover-border-color: var(--bs-border-color);\n --bs-pagination-focus-color: var(--bs-link-hover-color);\n --bs-pagination-focus-bg: var(--bs-secondary-bg);\n --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n --bs-pagination-active-color: #fff;\n --bs-pagination-active-bg: #0d6efd;\n --bs-pagination-active-border-color: #0d6efd;\n --bs-pagination-disabled-color: var(--bs-secondary-color);\n --bs-pagination-disabled-bg: var(--bs-secondary-bg);\n --bs-pagination-disabled-border-color: var(--bs-border-color);\n display: flex;\n padding-left: 0;\n list-style: none;\n}\n\n.page-link {\n position: relative;\n display: block;\n padding: var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);\n font-size: var(--bs-pagination-font-size);\n color: var(--bs-pagination-color);\n text-decoration: none;\n background-color: var(--bs-pagination-bg);\n border: var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .page-link {\n transition: none;\n }\n}\n.page-link:hover {\n z-index: 2;\n color: var(--bs-pagination-hover-color);\n background-color: var(--bs-pagination-hover-bg);\n border-color: var(--bs-pagination-hover-border-color);\n}\n.page-link:focus {\n z-index: 3;\n color: var(--bs-pagination-focus-color);\n background-color: var(--bs-pagination-focus-bg);\n outline: 0;\n box-shadow: var(--bs-pagination-focus-box-shadow);\n}\n.page-link.active, .active > .page-link {\n z-index: 3;\n color: var(--bs-pagination-active-color);\n background-color: var(--bs-pagination-active-bg);\n border-color: var(--bs-pagination-active-border-color);\n}\n.page-link.disabled, .disabled > .page-link {\n color: var(--bs-pagination-disabled-color);\n pointer-events: none;\n background-color: var(--bs-pagination-disabled-bg);\n border-color: var(--bs-pagination-disabled-border-color);\n}\n\n.page-item:not(:first-child) .page-link {\n margin-left: calc(var(--bs-border-width) * -1);\n}\n.page-item:first-child .page-link {\n border-top-left-radius: var(--bs-pagination-border-radius);\n border-bottom-left-radius: var(--bs-pagination-border-radius);\n}\n.page-item:last-child .page-link {\n border-top-right-radius: var(--bs-pagination-border-radius);\n border-bottom-right-radius: var(--bs-pagination-border-radius);\n}\n\n.pagination-lg {\n --bs-pagination-padding-x: 1.5rem;\n --bs-pagination-padding-y: 0.75rem;\n --bs-pagination-font-size: 1.25rem;\n --bs-pagination-border-radius: var(--bs-border-radius-lg);\n}\n\n.pagination-sm {\n --bs-pagination-padding-x: 0.5rem;\n --bs-pagination-padding-y: 0.25rem;\n --bs-pagination-font-size: 0.875rem;\n --bs-pagination-border-radius: var(--bs-border-radius-sm);\n}\n\n.badge {\n --bs-badge-padding-x: 0.65em;\n --bs-badge-padding-y: 0.35em;\n --bs-badge-font-size: 0.75em;\n --bs-badge-font-weight: 700;\n --bs-badge-color: #fff;\n --bs-badge-border-radius: var(--bs-border-radius);\n display: inline-block;\n padding: var(--bs-badge-padding-y) var(--bs-badge-padding-x);\n font-size: var(--bs-badge-font-size);\n font-weight: var(--bs-badge-font-weight);\n line-height: 1;\n color: var(--bs-badge-color);\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: var(--bs-badge-border-radius);\n}\n.badge:empty {\n display: none;\n}\n\n.btn .badge {\n position: relative;\n top: -1px;\n}\n\n.alert {\n --bs-alert-bg: transparent;\n --bs-alert-padding-x: 1rem;\n --bs-alert-padding-y: 1rem;\n --bs-alert-margin-bottom: 1rem;\n --bs-alert-color: inherit;\n --bs-alert-border-color: transparent;\n --bs-alert-border: var(--bs-border-width) solid var(--bs-alert-border-color);\n --bs-alert-border-radius: var(--bs-border-radius);\n --bs-alert-link-color: inherit;\n position: relative;\n padding: var(--bs-alert-padding-y) var(--bs-alert-padding-x);\n margin-bottom: var(--bs-alert-margin-bottom);\n color: var(--bs-alert-color);\n background-color: var(--bs-alert-bg);\n border: var(--bs-alert-border);\n border-radius: var(--bs-alert-border-radius);\n}\n\n.alert-heading {\n color: inherit;\n}\n\n.alert-link {\n font-weight: 700;\n color: var(--bs-alert-link-color);\n}\n\n.alert-dismissible {\n padding-right: 3rem;\n}\n.alert-dismissible .btn-close {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n padding: 1.25rem 1rem;\n}\n\n.alert-primary {\n --bs-alert-color: var(--bs-primary-text-emphasis);\n --bs-alert-bg: var(--bs-primary-bg-subtle);\n --bs-alert-border-color: var(--bs-primary-border-subtle);\n --bs-alert-link-color: var(--bs-primary-text-emphasis);\n}\n\n.alert-secondary {\n --bs-alert-color: var(--bs-secondary-text-emphasis);\n --bs-alert-bg: var(--bs-secondary-bg-subtle);\n --bs-alert-border-color: var(--bs-secondary-border-subtle);\n --bs-alert-link-color: var(--bs-secondary-text-emphasis);\n}\n\n.alert-success {\n --bs-alert-color: var(--bs-success-text-emphasis);\n --bs-alert-bg: var(--bs-success-bg-subtle);\n --bs-alert-border-color: var(--bs-success-border-subtle);\n --bs-alert-link-color: var(--bs-success-text-emphasis);\n}\n\n.alert-info {\n --bs-alert-color: var(--bs-info-text-emphasis);\n --bs-alert-bg: var(--bs-info-bg-subtle);\n --bs-alert-border-color: var(--bs-info-border-subtle);\n --bs-alert-link-color: var(--bs-info-text-emphasis);\n}\n\n.alert-warning {\n --bs-alert-color: var(--bs-warning-text-emphasis);\n --bs-alert-bg: var(--bs-warning-bg-subtle);\n --bs-alert-border-color: var(--bs-warning-border-subtle);\n --bs-alert-link-color: var(--bs-warning-text-emphasis);\n}\n\n.alert-danger {\n --bs-alert-color: var(--bs-danger-text-emphasis);\n --bs-alert-bg: var(--bs-danger-bg-subtle);\n --bs-alert-border-color: var(--bs-danger-border-subtle);\n --bs-alert-link-color: var(--bs-danger-text-emphasis);\n}\n\n.alert-light {\n --bs-alert-color: var(--bs-light-text-emphasis);\n --bs-alert-bg: var(--bs-light-bg-subtle);\n --bs-alert-border-color: var(--bs-light-border-subtle);\n --bs-alert-link-color: var(--bs-light-text-emphasis);\n}\n\n.alert-dark {\n --bs-alert-color: var(--bs-dark-text-emphasis);\n --bs-alert-bg: var(--bs-dark-bg-subtle);\n --bs-alert-border-color: var(--bs-dark-border-subtle);\n --bs-alert-link-color: var(--bs-dark-text-emphasis);\n}\n\n@keyframes progress-bar-stripes {\n 0% {\n background-position-x: 1rem;\n }\n}\n.progress,\n.progress-stacked {\n --bs-progress-height: 1rem;\n --bs-progress-font-size: 0.75rem;\n --bs-progress-bg: var(--bs-secondary-bg);\n --bs-progress-border-radius: var(--bs-border-radius);\n --bs-progress-box-shadow: var(--bs-box-shadow-inset);\n --bs-progress-bar-color: #fff;\n --bs-progress-bar-bg: #0d6efd;\n --bs-progress-bar-transition: width 0.6s ease;\n display: flex;\n height: var(--bs-progress-height);\n overflow: hidden;\n font-size: var(--bs-progress-font-size);\n background-color: var(--bs-progress-bg);\n border-radius: var(--bs-progress-border-radius);\n}\n\n.progress-bar {\n display: flex;\n flex-direction: column;\n justify-content: center;\n overflow: hidden;\n color: var(--bs-progress-bar-color);\n text-align: center;\n white-space: nowrap;\n background-color: var(--bs-progress-bar-bg);\n transition: var(--bs-progress-bar-transition);\n}\n@media (prefers-reduced-motion: reduce) {\n .progress-bar {\n transition: none;\n }\n}\n\n.progress-bar-striped {\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: var(--bs-progress-height) var(--bs-progress-height);\n}\n\n.progress-stacked > .progress {\n overflow: visible;\n}\n\n.progress-stacked > .progress > .progress-bar {\n width: 100%;\n}\n\n.progress-bar-animated {\n animation: 1s linear infinite progress-bar-stripes;\n}\n@media (prefers-reduced-motion: reduce) {\n .progress-bar-animated {\n animation: none;\n }\n}\n\n.list-group {\n --bs-list-group-color: var(--bs-body-color);\n --bs-list-group-bg: var(--bs-body-bg);\n --bs-list-group-border-color: var(--bs-border-color);\n --bs-list-group-border-width: var(--bs-border-width);\n --bs-list-group-border-radius: var(--bs-border-radius);\n --bs-list-group-item-padding-x: 1rem;\n --bs-list-group-item-padding-y: 0.5rem;\n --bs-list-group-action-color: var(--bs-secondary-color);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-tertiary-bg);\n --bs-list-group-action-active-color: var(--bs-body-color);\n --bs-list-group-action-active-bg: var(--bs-secondary-bg);\n --bs-list-group-disabled-color: var(--bs-secondary-color);\n --bs-list-group-disabled-bg: var(--bs-body-bg);\n --bs-list-group-active-color: #fff;\n --bs-list-group-active-bg: #0d6efd;\n --bs-list-group-active-border-color: #0d6efd;\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n border-radius: var(--bs-list-group-border-radius);\n}\n\n.list-group-numbered {\n list-style-type: none;\n counter-reset: section;\n}\n.list-group-numbered > .list-group-item::before {\n content: counters(section, \".\") \". \";\n counter-increment: section;\n}\n\n.list-group-item-action {\n width: 100%;\n color: var(--bs-list-group-action-color);\n text-align: inherit;\n}\n.list-group-item-action:hover, .list-group-item-action:focus {\n z-index: 1;\n color: var(--bs-list-group-action-hover-color);\n text-decoration: none;\n background-color: var(--bs-list-group-action-hover-bg);\n}\n.list-group-item-action:active {\n color: var(--bs-list-group-action-active-color);\n background-color: var(--bs-list-group-action-active-bg);\n}\n\n.list-group-item {\n position: relative;\n display: block;\n padding: var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);\n color: var(--bs-list-group-color);\n text-decoration: none;\n background-color: var(--bs-list-group-bg);\n border: var(--bs-list-group-border-width) solid var(--bs-list-group-border-color);\n}\n.list-group-item:first-child {\n border-top-left-radius: inherit;\n border-top-right-radius: inherit;\n}\n.list-group-item:last-child {\n border-bottom-right-radius: inherit;\n border-bottom-left-radius: inherit;\n}\n.list-group-item.disabled, .list-group-item:disabled {\n color: var(--bs-list-group-disabled-color);\n pointer-events: none;\n background-color: var(--bs-list-group-disabled-bg);\n}\n.list-group-item.active {\n z-index: 2;\n color: var(--bs-list-group-active-color);\n background-color: var(--bs-list-group-active-bg);\n border-color: var(--bs-list-group-active-border-color);\n}\n.list-group-item + .list-group-item {\n border-top-width: 0;\n}\n.list-group-item + .list-group-item.active {\n margin-top: calc(-1 * var(--bs-list-group-border-width));\n border-top-width: var(--bs-list-group-border-width);\n}\n\n.list-group-horizontal {\n flex-direction: row;\n}\n.list-group-horizontal > .list-group-item:first-child:not(:last-child) {\n border-bottom-left-radius: var(--bs-list-group-border-radius);\n border-top-right-radius: 0;\n}\n.list-group-horizontal > .list-group-item:last-child:not(:first-child) {\n border-top-right-radius: var(--bs-list-group-border-radius);\n border-bottom-left-radius: 0;\n}\n.list-group-horizontal > .list-group-item.active {\n margin-top: 0;\n}\n.list-group-horizontal > .list-group-item + .list-group-item {\n border-top-width: var(--bs-list-group-border-width);\n border-left-width: 0;\n}\n.list-group-horizontal > .list-group-item + .list-group-item.active {\n margin-left: calc(-1 * var(--bs-list-group-border-width));\n border-left-width: var(--bs-list-group-border-width);\n}\n\n@media (min-width: 576px) {\n .list-group-horizontal-sm {\n flex-direction: row;\n }\n .list-group-horizontal-sm > .list-group-item:first-child:not(:last-child) {\n border-bottom-left-radius: var(--bs-list-group-border-radius);\n border-top-right-radius: 0;\n }\n .list-group-horizontal-sm > .list-group-item:last-child:not(:first-child) {\n border-top-right-radius: var(--bs-list-group-border-radius);\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-sm > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-sm > .list-group-item + .list-group-item {\n border-top-width: var(--bs-list-group-border-width);\n border-left-width: 0;\n }\n .list-group-horizontal-sm > .list-group-item + .list-group-item.active {\n margin-left: calc(-1 * var(--bs-list-group-border-width));\n border-left-width: var(--bs-list-group-border-width);\n }\n}\n@media (min-width: 768px) {\n .list-group-horizontal-md {\n flex-direction: row;\n }\n .list-group-horizontal-md > .list-group-item:first-child:not(:last-child) {\n border-bottom-left-radius: var(--bs-list-group-border-radius);\n border-top-right-radius: 0;\n }\n .list-group-horizontal-md > .list-group-item:last-child:not(:first-child) {\n border-top-right-radius: var(--bs-list-group-border-radius);\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-md > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-md > .list-group-item + .list-group-item {\n border-top-width: var(--bs-list-group-border-width);\n border-left-width: 0;\n }\n .list-group-horizontal-md > .list-group-item + .list-group-item.active {\n margin-left: calc(-1 * var(--bs-list-group-border-width));\n border-left-width: var(--bs-list-group-border-width);\n }\n}\n@media (min-width: 992px) {\n .list-group-horizontal-lg {\n flex-direction: row;\n }\n .list-group-horizontal-lg > .list-group-item:first-child:not(:last-child) {\n border-bottom-left-radius: var(--bs-list-group-border-radius);\n border-top-right-radius: 0;\n }\n .list-group-horizontal-lg > .list-group-item:last-child:not(:first-child) {\n border-top-right-radius: var(--bs-list-group-border-radius);\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-lg > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-lg > .list-group-item + .list-group-item {\n border-top-width: var(--bs-list-group-border-width);\n border-left-width: 0;\n }\n .list-group-horizontal-lg > .list-group-item + .list-group-item.active {\n margin-left: calc(-1 * var(--bs-list-group-border-width));\n border-left-width: var(--bs-list-group-border-width);\n }\n}\n@media (min-width: 1200px) {\n .list-group-horizontal-xl {\n flex-direction: row;\n }\n .list-group-horizontal-xl > .list-group-item:first-child:not(:last-child) {\n border-bottom-left-radius: var(--bs-list-group-border-radius);\n border-top-right-radius: 0;\n }\n .list-group-horizontal-xl > .list-group-item:last-child:not(:first-child) {\n border-top-right-radius: var(--bs-list-group-border-radius);\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-xl > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-xl > .list-group-item + .list-group-item {\n border-top-width: var(--bs-list-group-border-width);\n border-left-width: 0;\n }\n .list-group-horizontal-xl > .list-group-item + .list-group-item.active {\n margin-left: calc(-1 * var(--bs-list-group-border-width));\n border-left-width: var(--bs-list-group-border-width);\n }\n}\n@media (min-width: 1400px) {\n .list-group-horizontal-xxl {\n flex-direction: row;\n }\n .list-group-horizontal-xxl > .list-group-item:first-child:not(:last-child) {\n border-bottom-left-radius: var(--bs-list-group-border-radius);\n border-top-right-radius: 0;\n }\n .list-group-horizontal-xxl > .list-group-item:last-child:not(:first-child) {\n border-top-right-radius: var(--bs-list-group-border-radius);\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-xxl > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-xxl > .list-group-item + .list-group-item {\n border-top-width: var(--bs-list-group-border-width);\n border-left-width: 0;\n }\n .list-group-horizontal-xxl > .list-group-item + .list-group-item.active {\n margin-left: calc(-1 * var(--bs-list-group-border-width));\n border-left-width: var(--bs-list-group-border-width);\n }\n}\n.list-group-flush {\n border-radius: 0;\n}\n.list-group-flush > .list-group-item {\n border-width: 0 0 var(--bs-list-group-border-width);\n}\n.list-group-flush > .list-group-item:last-child {\n border-bottom-width: 0;\n}\n\n.list-group-item-primary {\n --bs-list-group-color: var(--bs-primary-text-emphasis);\n --bs-list-group-bg: var(--bs-primary-bg-subtle);\n --bs-list-group-border-color: var(--bs-primary-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-primary-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-primary-border-subtle);\n --bs-list-group-active-color: var(--bs-primary-bg-subtle);\n --bs-list-group-active-bg: var(--bs-primary-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-primary-text-emphasis);\n}\n\n.list-group-item-secondary {\n --bs-list-group-color: var(--bs-secondary-text-emphasis);\n --bs-list-group-bg: var(--bs-secondary-bg-subtle);\n --bs-list-group-border-color: var(--bs-secondary-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-secondary-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-secondary-border-subtle);\n --bs-list-group-active-color: var(--bs-secondary-bg-subtle);\n --bs-list-group-active-bg: var(--bs-secondary-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-secondary-text-emphasis);\n}\n\n.list-group-item-success {\n --bs-list-group-color: var(--bs-success-text-emphasis);\n --bs-list-group-bg: var(--bs-success-bg-subtle);\n --bs-list-group-border-color: var(--bs-success-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-success-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-success-border-subtle);\n --bs-list-group-active-color: var(--bs-success-bg-subtle);\n --bs-list-group-active-bg: var(--bs-success-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-success-text-emphasis);\n}\n\n.list-group-item-info {\n --bs-list-group-color: var(--bs-info-text-emphasis);\n --bs-list-group-bg: var(--bs-info-bg-subtle);\n --bs-list-group-border-color: var(--bs-info-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-info-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-info-border-subtle);\n --bs-list-group-active-color: var(--bs-info-bg-subtle);\n --bs-list-group-active-bg: var(--bs-info-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-info-text-emphasis);\n}\n\n.list-group-item-warning {\n --bs-list-group-color: var(--bs-warning-text-emphasis);\n --bs-list-group-bg: var(--bs-warning-bg-subtle);\n --bs-list-group-border-color: var(--bs-warning-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-warning-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-warning-border-subtle);\n --bs-list-group-active-color: var(--bs-warning-bg-subtle);\n --bs-list-group-active-bg: var(--bs-warning-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-warning-text-emphasis);\n}\n\n.list-group-item-danger {\n --bs-list-group-color: var(--bs-danger-text-emphasis);\n --bs-list-group-bg: var(--bs-danger-bg-subtle);\n --bs-list-group-border-color: var(--bs-danger-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-danger-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-danger-border-subtle);\n --bs-list-group-active-color: var(--bs-danger-bg-subtle);\n --bs-list-group-active-bg: var(--bs-danger-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-danger-text-emphasis);\n}\n\n.list-group-item-light {\n --bs-list-group-color: var(--bs-light-text-emphasis);\n --bs-list-group-bg: var(--bs-light-bg-subtle);\n --bs-list-group-border-color: var(--bs-light-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-light-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-light-border-subtle);\n --bs-list-group-active-color: var(--bs-light-bg-subtle);\n --bs-list-group-active-bg: var(--bs-light-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-light-text-emphasis);\n}\n\n.list-group-item-dark {\n --bs-list-group-color: var(--bs-dark-text-emphasis);\n --bs-list-group-bg: var(--bs-dark-bg-subtle);\n --bs-list-group-border-color: var(--bs-dark-border-subtle);\n --bs-list-group-action-hover-color: var(--bs-emphasis-color);\n --bs-list-group-action-hover-bg: var(--bs-dark-border-subtle);\n --bs-list-group-action-active-color: var(--bs-emphasis-color);\n --bs-list-group-action-active-bg: var(--bs-dark-border-subtle);\n --bs-list-group-active-color: var(--bs-dark-bg-subtle);\n --bs-list-group-active-bg: var(--bs-dark-text-emphasis);\n --bs-list-group-active-border-color: var(--bs-dark-text-emphasis);\n}\n\n.btn-close {\n --bs-btn-close-color: #000;\n --bs-btn-close-bg: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e\");\n --bs-btn-close-opacity: 0.5;\n --bs-btn-close-hover-opacity: 0.75;\n --bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n --bs-btn-close-focus-opacity: 1;\n --bs-btn-close-disabled-opacity: 0.25;\n --bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);\n box-sizing: content-box;\n width: 1em;\n height: 1em;\n padding: 0.25em 0.25em;\n color: var(--bs-btn-close-color);\n background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat;\n border: 0;\n border-radius: 0.375rem;\n opacity: var(--bs-btn-close-opacity);\n}\n.btn-close:hover {\n color: var(--bs-btn-close-color);\n text-decoration: none;\n opacity: var(--bs-btn-close-hover-opacity);\n}\n.btn-close:focus {\n outline: 0;\n box-shadow: var(--bs-btn-close-focus-shadow);\n opacity: var(--bs-btn-close-focus-opacity);\n}\n.btn-close:disabled, .btn-close.disabled {\n pointer-events: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n opacity: var(--bs-btn-close-disabled-opacity);\n}\n\n.btn-close-white {\n filter: var(--bs-btn-close-white-filter);\n}\n\n[data-bs-theme=dark] .btn-close {\n filter: var(--bs-btn-close-white-filter);\n}\n\n.toast {\n --bs-toast-zindex: 1090;\n --bs-toast-padding-x: 0.75rem;\n --bs-toast-padding-y: 0.5rem;\n --bs-toast-spacing: 1.5rem;\n --bs-toast-max-width: 350px;\n --bs-toast-font-size: 0.875rem;\n --bs-toast-color: ;\n --bs-toast-bg: rgba(var(--bs-body-bg-rgb), 0.85);\n --bs-toast-border-width: var(--bs-border-width);\n --bs-toast-border-color: var(--bs-border-color-translucent);\n --bs-toast-border-radius: var(--bs-border-radius);\n --bs-toast-box-shadow: var(--bs-box-shadow);\n --bs-toast-header-color: var(--bs-secondary-color);\n --bs-toast-header-bg: rgba(var(--bs-body-bg-rgb), 0.85);\n --bs-toast-header-border-color: var(--bs-border-color-translucent);\n width: var(--bs-toast-max-width);\n max-width: 100%;\n font-size: var(--bs-toast-font-size);\n color: var(--bs-toast-color);\n pointer-events: auto;\n background-color: var(--bs-toast-bg);\n background-clip: padding-box;\n border: var(--bs-toast-border-width) solid var(--bs-toast-border-color);\n box-shadow: var(--bs-toast-box-shadow);\n border-radius: var(--bs-toast-border-radius);\n}\n.toast.showing {\n opacity: 0;\n}\n.toast:not(.show) {\n display: none;\n}\n\n.toast-container {\n --bs-toast-zindex: 1090;\n position: absolute;\n z-index: var(--bs-toast-zindex);\n width: -webkit-max-content;\n width: -moz-max-content;\n width: max-content;\n max-width: 100%;\n pointer-events: none;\n}\n.toast-container > :not(:last-child) {\n margin-bottom: var(--bs-toast-spacing);\n}\n\n.toast-header {\n display: flex;\n align-items: center;\n padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);\n color: var(--bs-toast-header-color);\n background-color: var(--bs-toast-header-bg);\n background-clip: padding-box;\n border-bottom: var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);\n border-top-left-radius: calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));\n border-top-right-radius: calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));\n}\n.toast-header .btn-close {\n margin-right: calc(-0.5 * var(--bs-toast-padding-x));\n margin-left: var(--bs-toast-padding-x);\n}\n\n.toast-body {\n padding: var(--bs-toast-padding-x);\n word-wrap: break-word;\n}\n\n.modal {\n --bs-modal-zindex: 1055;\n --bs-modal-width: 500px;\n --bs-modal-padding: 1rem;\n --bs-modal-margin: 0.5rem;\n --bs-modal-color: ;\n --bs-modal-bg: var(--bs-body-bg);\n --bs-modal-border-color: var(--bs-border-color-translucent);\n --bs-modal-border-width: var(--bs-border-width);\n --bs-modal-border-radius: var(--bs-border-radius-lg);\n --bs-modal-box-shadow: var(--bs-box-shadow-sm);\n --bs-modal-inner-border-radius: calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));\n --bs-modal-header-padding-x: 1rem;\n --bs-modal-header-padding-y: 1rem;\n --bs-modal-header-padding: 1rem 1rem;\n --bs-modal-header-border-color: var(--bs-border-color);\n --bs-modal-header-border-width: var(--bs-border-width);\n --bs-modal-title-line-height: 1.5;\n --bs-modal-footer-gap: 0.5rem;\n --bs-modal-footer-bg: ;\n --bs-modal-footer-border-color: var(--bs-border-color);\n --bs-modal-footer-border-width: var(--bs-border-width);\n position: fixed;\n top: 0;\n left: 0;\n z-index: var(--bs-modal-zindex);\n display: none;\n width: 100%;\n height: 100%;\n overflow-x: hidden;\n overflow-y: auto;\n outline: 0;\n}\n\n.modal-dialog {\n position: relative;\n width: auto;\n margin: var(--bs-modal-margin);\n pointer-events: none;\n}\n.modal.fade .modal-dialog {\n transition: transform 0.3s ease-out;\n transform: translate(0, -50px);\n}\n@media (prefers-reduced-motion: reduce) {\n .modal.fade .modal-dialog {\n transition: none;\n }\n}\n.modal.show .modal-dialog {\n transform: none;\n}\n.modal.modal-static .modal-dialog {\n transform: scale(1.02);\n}\n\n.modal-dialog-scrollable {\n height: calc(100% - var(--bs-modal-margin) * 2);\n}\n.modal-dialog-scrollable .modal-content {\n max-height: 100%;\n overflow: hidden;\n}\n.modal-dialog-scrollable .modal-body {\n overflow-y: auto;\n}\n\n.modal-dialog-centered {\n display: flex;\n align-items: center;\n min-height: calc(100% - var(--bs-modal-margin) * 2);\n}\n\n.modal-content {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n color: var(--bs-modal-color);\n pointer-events: auto;\n background-color: var(--bs-modal-bg);\n background-clip: padding-box;\n border: var(--bs-modal-border-width) solid var(--bs-modal-border-color);\n border-radius: var(--bs-modal-border-radius);\n outline: 0;\n}\n\n.modal-backdrop {\n --bs-backdrop-zindex: 1050;\n --bs-backdrop-bg: #000;\n --bs-backdrop-opacity: 0.5;\n position: fixed;\n top: 0;\n left: 0;\n z-index: var(--bs-backdrop-zindex);\n width: 100vw;\n height: 100vh;\n background-color: var(--bs-backdrop-bg);\n}\n.modal-backdrop.fade {\n opacity: 0;\n}\n.modal-backdrop.show {\n opacity: var(--bs-backdrop-opacity);\n}\n\n.modal-header {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n padding: var(--bs-modal-header-padding);\n border-bottom: var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);\n border-top-left-radius: var(--bs-modal-inner-border-radius);\n border-top-right-radius: var(--bs-modal-inner-border-radius);\n}\n.modal-header .btn-close {\n padding: calc(var(--bs-modal-header-padding-y) * 0.5) calc(var(--bs-modal-header-padding-x) * 0.5);\n margin: calc(-0.5 * var(--bs-modal-header-padding-y)) calc(-0.5 * var(--bs-modal-header-padding-x)) calc(-0.5 * var(--bs-modal-header-padding-y)) auto;\n}\n\n.modal-title {\n margin-bottom: 0;\n line-height: var(--bs-modal-title-line-height);\n}\n\n.modal-body {\n position: relative;\n flex: 1 1 auto;\n padding: var(--bs-modal-padding);\n}\n\n.modal-footer {\n display: flex;\n flex-shrink: 0;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-end;\n padding: calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * 0.5);\n background-color: var(--bs-modal-footer-bg);\n border-top: var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);\n border-bottom-right-radius: var(--bs-modal-inner-border-radius);\n border-bottom-left-radius: var(--bs-modal-inner-border-radius);\n}\n.modal-footer > * {\n margin: calc(var(--bs-modal-footer-gap) * 0.5);\n}\n\n@media (min-width: 576px) {\n .modal {\n --bs-modal-margin: 1.75rem;\n --bs-modal-box-shadow: var(--bs-box-shadow);\n }\n .modal-dialog {\n max-width: var(--bs-modal-width);\n margin-right: auto;\n margin-left: auto;\n }\n .modal-sm {\n --bs-modal-width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg,\n .modal-xl {\n --bs-modal-width: 800px;\n }\n}\n@media (min-width: 1200px) {\n .modal-xl {\n --bs-modal-width: 1140px;\n }\n}\n.modal-fullscreen {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n}\n.modal-fullscreen .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n}\n.modal-fullscreen .modal-header,\n.modal-fullscreen .modal-footer {\n border-radius: 0;\n}\n.modal-fullscreen .modal-body {\n overflow-y: auto;\n}\n\n@media (max-width: 575.98px) {\n .modal-fullscreen-sm-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-sm-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-sm-down .modal-header,\n .modal-fullscreen-sm-down .modal-footer {\n border-radius: 0;\n }\n .modal-fullscreen-sm-down .modal-body {\n overflow-y: auto;\n }\n}\n@media (max-width: 767.98px) {\n .modal-fullscreen-md-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-md-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-md-down .modal-header,\n .modal-fullscreen-md-down .modal-footer {\n border-radius: 0;\n }\n .modal-fullscreen-md-down .modal-body {\n overflow-y: auto;\n }\n}\n@media (max-width: 991.98px) {\n .modal-fullscreen-lg-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-lg-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-lg-down .modal-header,\n .modal-fullscreen-lg-down .modal-footer {\n border-radius: 0;\n }\n .modal-fullscreen-lg-down .modal-body {\n overflow-y: auto;\n }\n}\n@media (max-width: 1199.98px) {\n .modal-fullscreen-xl-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-xl-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-xl-down .modal-header,\n .modal-fullscreen-xl-down .modal-footer {\n border-radius: 0;\n }\n .modal-fullscreen-xl-down .modal-body {\n overflow-y: auto;\n }\n}\n@media (max-width: 1399.98px) {\n .modal-fullscreen-xxl-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-xxl-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-xxl-down .modal-header,\n .modal-fullscreen-xxl-down .modal-footer {\n border-radius: 0;\n }\n .modal-fullscreen-xxl-down .modal-body {\n overflow-y: auto;\n }\n}\n.tooltip {\n --bs-tooltip-zindex: 1080;\n --bs-tooltip-max-width: 200px;\n --bs-tooltip-padding-x: 0.5rem;\n --bs-tooltip-padding-y: 0.25rem;\n --bs-tooltip-margin: ;\n --bs-tooltip-font-size: 0.875rem;\n --bs-tooltip-color: var(--bs-body-bg);\n --bs-tooltip-bg: var(--bs-emphasis-color);\n --bs-tooltip-border-radius: var(--bs-border-radius);\n --bs-tooltip-opacity: 0.9;\n --bs-tooltip-arrow-width: 0.8rem;\n --bs-tooltip-arrow-height: 0.4rem;\n z-index: var(--bs-tooltip-zindex);\n display: block;\n margin: var(--bs-tooltip-margin);\n font-family: var(--bs-font-sans-serif);\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n white-space: normal;\n word-spacing: normal;\n line-break: auto;\n font-size: var(--bs-tooltip-font-size);\n word-wrap: break-word;\n opacity: 0;\n}\n.tooltip.show {\n opacity: var(--bs-tooltip-opacity);\n}\n.tooltip .tooltip-arrow {\n display: block;\n width: var(--bs-tooltip-arrow-width);\n height: var(--bs-tooltip-arrow-height);\n}\n.tooltip .tooltip-arrow::before {\n position: absolute;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-tooltip-top .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow {\n bottom: calc(-1 * var(--bs-tooltip-arrow-height));\n}\n.bs-tooltip-top .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before {\n top: -1px;\n border-width: var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * 0.5) 0;\n border-top-color: var(--bs-tooltip-bg);\n}\n\n/* rtl:begin:ignore */\n.bs-tooltip-end .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow {\n left: calc(-1 * var(--bs-tooltip-arrow-height));\n width: var(--bs-tooltip-arrow-height);\n height: var(--bs-tooltip-arrow-width);\n}\n.bs-tooltip-end .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before {\n right: -1px;\n border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * 0.5) 0;\n border-right-color: var(--bs-tooltip-bg);\n}\n\n/* rtl:end:ignore */\n.bs-tooltip-bottom .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow {\n top: calc(-1 * var(--bs-tooltip-arrow-height));\n}\n.bs-tooltip-bottom .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before {\n bottom: -1px;\n border-width: 0 calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height);\n border-bottom-color: var(--bs-tooltip-bg);\n}\n\n/* rtl:begin:ignore */\n.bs-tooltip-start .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow {\n right: calc(-1 * var(--bs-tooltip-arrow-height));\n width: var(--bs-tooltip-arrow-height);\n height: var(--bs-tooltip-arrow-width);\n}\n.bs-tooltip-start .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before {\n left: -1px;\n border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) 0 calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height);\n border-left-color: var(--bs-tooltip-bg);\n}\n\n/* rtl:end:ignore */\n.tooltip-inner {\n max-width: var(--bs-tooltip-max-width);\n padding: var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);\n color: var(--bs-tooltip-color);\n text-align: center;\n background-color: var(--bs-tooltip-bg);\n border-radius: var(--bs-tooltip-border-radius);\n}\n\n.popover {\n --bs-popover-zindex: 1070;\n --bs-popover-max-width: 276px;\n --bs-popover-font-size: 0.875rem;\n --bs-popover-bg: var(--bs-body-bg);\n --bs-popover-border-width: var(--bs-border-width);\n --bs-popover-border-color: var(--bs-border-color-translucent);\n --bs-popover-border-radius: var(--bs-border-radius-lg);\n --bs-popover-inner-border-radius: calc(var(--bs-border-radius-lg) - var(--bs-border-width));\n --bs-popover-box-shadow: var(--bs-box-shadow);\n --bs-popover-header-padding-x: 1rem;\n --bs-popover-header-padding-y: 0.5rem;\n --bs-popover-header-font-size: 1rem;\n --bs-popover-header-color: inherit;\n --bs-popover-header-bg: var(--bs-secondary-bg);\n --bs-popover-body-padding-x: 1rem;\n --bs-popover-body-padding-y: 1rem;\n --bs-popover-body-color: var(--bs-body-color);\n --bs-popover-arrow-width: 1rem;\n --bs-popover-arrow-height: 0.5rem;\n --bs-popover-arrow-border: var(--bs-popover-border-color);\n z-index: var(--bs-popover-zindex);\n display: block;\n max-width: var(--bs-popover-max-width);\n font-family: var(--bs-font-sans-serif);\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n white-space: normal;\n word-spacing: normal;\n line-break: auto;\n font-size: var(--bs-popover-font-size);\n word-wrap: break-word;\n background-color: var(--bs-popover-bg);\n background-clip: padding-box;\n border: var(--bs-popover-border-width) solid var(--bs-popover-border-color);\n border-radius: var(--bs-popover-border-radius);\n}\n.popover .popover-arrow {\n display: block;\n width: var(--bs-popover-arrow-width);\n height: var(--bs-popover-arrow-height);\n}\n.popover .popover-arrow::before, .popover .popover-arrow::after {\n position: absolute;\n display: block;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n border-width: 0;\n}\n\n.bs-popover-top > .popover-arrow, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow {\n bottom: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));\n}\n.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before, .bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after {\n border-width: var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * 0.5) 0;\n}\n.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before {\n bottom: 0;\n border-top-color: var(--bs-popover-arrow-border);\n}\n.bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after {\n bottom: var(--bs-popover-border-width);\n border-top-color: var(--bs-popover-bg);\n}\n\n/* rtl:begin:ignore */\n.bs-popover-end > .popover-arrow, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow {\n left: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));\n width: var(--bs-popover-arrow-height);\n height: var(--bs-popover-arrow-width);\n}\n.bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before, .bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after {\n border-width: calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * 0.5) 0;\n}\n.bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before {\n left: 0;\n border-right-color: var(--bs-popover-arrow-border);\n}\n.bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after {\n left: var(--bs-popover-border-width);\n border-right-color: var(--bs-popover-bg);\n}\n\n/* rtl:end:ignore */\n.bs-popover-bottom > .popover-arrow, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow {\n top: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));\n}\n.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before, .bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after {\n border-width: 0 calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height);\n}\n.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before {\n top: 0;\n border-bottom-color: var(--bs-popover-arrow-border);\n}\n.bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after {\n top: var(--bs-popover-border-width);\n border-bottom-color: var(--bs-popover-bg);\n}\n.bs-popover-bottom .popover-header::before, .bs-popover-auto[data-popper-placement^=bottom] .popover-header::before {\n position: absolute;\n top: 0;\n left: 50%;\n display: block;\n width: var(--bs-popover-arrow-width);\n margin-left: calc(-0.5 * var(--bs-popover-arrow-width));\n content: \"\";\n border-bottom: var(--bs-popover-border-width) solid var(--bs-popover-header-bg);\n}\n\n/* rtl:begin:ignore */\n.bs-popover-start > .popover-arrow, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow {\n right: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));\n width: var(--bs-popover-arrow-height);\n height: var(--bs-popover-arrow-width);\n}\n.bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before, .bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after {\n border-width: calc(var(--bs-popover-arrow-width) * 0.5) 0 calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height);\n}\n.bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before {\n right: 0;\n border-left-color: var(--bs-popover-arrow-border);\n}\n.bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after {\n right: var(--bs-popover-border-width);\n border-left-color: var(--bs-popover-bg);\n}\n\n/* rtl:end:ignore */\n.popover-header {\n padding: var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);\n margin-bottom: 0;\n font-size: var(--bs-popover-header-font-size);\n color: var(--bs-popover-header-color);\n background-color: var(--bs-popover-header-bg);\n border-bottom: var(--bs-popover-border-width) solid var(--bs-popover-border-color);\n border-top-left-radius: var(--bs-popover-inner-border-radius);\n border-top-right-radius: var(--bs-popover-inner-border-radius);\n}\n.popover-header:empty {\n display: none;\n}\n\n.popover-body {\n padding: var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);\n color: var(--bs-popover-body-color);\n}\n\n.carousel {\n position: relative;\n}\n\n.carousel.pointer-event {\n touch-action: pan-y;\n}\n\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n.carousel-inner::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.carousel-item {\n position: relative;\n display: none;\n float: left;\n width: 100%;\n margin-right: -100%;\n -webkit-backface-visibility: hidden;\n backface-visibility: hidden;\n transition: transform 0.6s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-item {\n transition: none;\n }\n}\n\n.carousel-item.active,\n.carousel-item-next,\n.carousel-item-prev {\n display: block;\n}\n\n.carousel-item-next:not(.carousel-item-start),\n.active.carousel-item-end {\n transform: translateX(100%);\n}\n\n.carousel-item-prev:not(.carousel-item-end),\n.active.carousel-item-start {\n transform: translateX(-100%);\n}\n\n.carousel-fade .carousel-item {\n opacity: 0;\n transition-property: opacity;\n transform: none;\n}\n.carousel-fade .carousel-item.active,\n.carousel-fade .carousel-item-next.carousel-item-start,\n.carousel-fade .carousel-item-prev.carousel-item-end {\n z-index: 1;\n opacity: 1;\n}\n.carousel-fade .active.carousel-item-start,\n.carousel-fade .active.carousel-item-end {\n z-index: 0;\n opacity: 0;\n transition: opacity 0s 0.6s;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-fade .active.carousel-item-start,\n .carousel-fade .active.carousel-item-end {\n transition: none;\n }\n}\n\n.carousel-control-prev,\n.carousel-control-next {\n position: absolute;\n top: 0;\n bottom: 0;\n z-index: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 15%;\n padding: 0;\n color: #fff;\n text-align: center;\n background: none;\n border: 0;\n opacity: 0.5;\n transition: opacity 0.15s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-control-prev,\n .carousel-control-next {\n transition: none;\n }\n}\n.carousel-control-prev:hover, .carousel-control-prev:focus,\n.carousel-control-next:hover,\n.carousel-control-next:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n opacity: 0.9;\n}\n\n.carousel-control-prev {\n left: 0;\n}\n\n.carousel-control-next {\n right: 0;\n}\n\n.carousel-control-prev-icon,\n.carousel-control-next-icon {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n background-repeat: no-repeat;\n background-position: 50%;\n background-size: 100% 100%;\n}\n\n.carousel-control-prev-icon {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e\") /*rtl:url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e\")*/;\n}\n\n.carousel-control-next-icon {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e\") /*rtl:url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e\")*/;\n}\n\n.carousel-indicators {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 2;\n display: flex;\n justify-content: center;\n padding: 0;\n margin-right: 15%;\n margin-bottom: 1rem;\n margin-left: 15%;\n}\n.carousel-indicators [data-bs-target] {\n box-sizing: content-box;\n flex: 0 1 auto;\n width: 30px;\n height: 3px;\n padding: 0;\n margin-right: 3px;\n margin-left: 3px;\n text-indent: -999px;\n cursor: pointer;\n background-color: #fff;\n background-clip: padding-box;\n border: 0;\n border-top: 10px solid transparent;\n border-bottom: 10px solid transparent;\n opacity: 0.5;\n transition: opacity 0.6s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-indicators [data-bs-target] {\n transition: none;\n }\n}\n.carousel-indicators .active {\n opacity: 1;\n}\n\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 1.25rem;\n left: 15%;\n padding-top: 1.25rem;\n padding-bottom: 1.25rem;\n color: #fff;\n text-align: center;\n}\n\n.carousel-dark .carousel-control-prev-icon,\n.carousel-dark .carousel-control-next-icon {\n filter: invert(1) grayscale(100);\n}\n.carousel-dark .carousel-indicators [data-bs-target] {\n background-color: #000;\n}\n.carousel-dark .carousel-caption {\n color: #000;\n}\n\n[data-bs-theme=dark] .carousel .carousel-control-prev-icon,\n[data-bs-theme=dark] .carousel .carousel-control-next-icon, [data-bs-theme=dark].carousel .carousel-control-prev-icon,\n[data-bs-theme=dark].carousel .carousel-control-next-icon {\n filter: invert(1) grayscale(100);\n}\n[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target], [data-bs-theme=dark].carousel .carousel-indicators [data-bs-target] {\n background-color: #000;\n}\n[data-bs-theme=dark] .carousel .carousel-caption, [data-bs-theme=dark].carousel .carousel-caption {\n color: #000;\n}\n\n.spinner-grow,\n.spinner-border {\n display: inline-block;\n width: var(--bs-spinner-width);\n height: var(--bs-spinner-height);\n vertical-align: var(--bs-spinner-vertical-align);\n border-radius: 50%;\n animation: var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name);\n}\n\n@keyframes spinner-border {\n to {\n transform: rotate(360deg) /* rtl:ignore */;\n }\n}\n.spinner-border {\n --bs-spinner-width: 2rem;\n --bs-spinner-height: 2rem;\n --bs-spinner-vertical-align: -0.125em;\n --bs-spinner-border-width: 0.25em;\n --bs-spinner-animation-speed: 0.75s;\n --bs-spinner-animation-name: spinner-border;\n border: var(--bs-spinner-border-width) solid currentcolor;\n border-right-color: transparent;\n}\n\n.spinner-border-sm {\n --bs-spinner-width: 1rem;\n --bs-spinner-height: 1rem;\n --bs-spinner-border-width: 0.2em;\n}\n\n@keyframes spinner-grow {\n 0% {\n transform: scale(0);\n }\n 50% {\n opacity: 1;\n transform: none;\n }\n}\n.spinner-grow {\n --bs-spinner-width: 2rem;\n --bs-spinner-height: 2rem;\n --bs-spinner-vertical-align: -0.125em;\n --bs-spinner-animation-speed: 0.75s;\n --bs-spinner-animation-name: spinner-grow;\n background-color: currentcolor;\n opacity: 0;\n}\n\n.spinner-grow-sm {\n --bs-spinner-width: 1rem;\n --bs-spinner-height: 1rem;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .spinner-border,\n .spinner-grow {\n --bs-spinner-animation-speed: 1.5s;\n }\n}\n.offcanvas, .offcanvas-xxl, .offcanvas-xl, .offcanvas-lg, .offcanvas-md, .offcanvas-sm {\n --bs-offcanvas-zindex: 1045;\n --bs-offcanvas-width: 400px;\n --bs-offcanvas-height: 30vh;\n --bs-offcanvas-padding-x: 1rem;\n --bs-offcanvas-padding-y: 1rem;\n --bs-offcanvas-color: var(--bs-body-color);\n --bs-offcanvas-bg: var(--bs-body-bg);\n --bs-offcanvas-border-width: var(--bs-border-width);\n --bs-offcanvas-border-color: var(--bs-border-color-translucent);\n --bs-offcanvas-box-shadow: var(--bs-box-shadow-sm);\n --bs-offcanvas-transition: transform 0.3s ease-in-out;\n --bs-offcanvas-title-line-height: 1.5;\n}\n\n@media (max-width: 575.98px) {\n .offcanvas-sm {\n position: fixed;\n bottom: 0;\n z-index: var(--bs-offcanvas-zindex);\n display: flex;\n flex-direction: column;\n max-width: 100%;\n color: var(--bs-offcanvas-color);\n visibility: hidden;\n background-color: var(--bs-offcanvas-bg);\n background-clip: padding-box;\n outline: 0;\n transition: var(--bs-offcanvas-transition);\n }\n}\n@media (max-width: 575.98px) and (prefers-reduced-motion: reduce) {\n .offcanvas-sm {\n transition: none;\n }\n}\n@media (max-width: 575.98px) {\n .offcanvas-sm.offcanvas-start {\n top: 0;\n left: 0;\n width: var(--bs-offcanvas-width);\n border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(-100%);\n }\n .offcanvas-sm.offcanvas-end {\n top: 0;\n right: 0;\n width: var(--bs-offcanvas-width);\n border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(100%);\n }\n .offcanvas-sm.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(-100%);\n }\n .offcanvas-sm.offcanvas-bottom {\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(100%);\n }\n .offcanvas-sm.showing, .offcanvas-sm.show:not(.hiding) {\n transform: none;\n }\n .offcanvas-sm.showing, .offcanvas-sm.hiding, .offcanvas-sm.show {\n visibility: visible;\n }\n}\n@media (min-width: 576px) {\n .offcanvas-sm {\n --bs-offcanvas-height: auto;\n --bs-offcanvas-border-width: 0;\n background-color: transparent !important;\n }\n .offcanvas-sm .offcanvas-header {\n display: none;\n }\n .offcanvas-sm .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n background-color: transparent !important;\n }\n}\n\n@media (max-width: 767.98px) {\n .offcanvas-md {\n position: fixed;\n bottom: 0;\n z-index: var(--bs-offcanvas-zindex);\n display: flex;\n flex-direction: column;\n max-width: 100%;\n color: var(--bs-offcanvas-color);\n visibility: hidden;\n background-color: var(--bs-offcanvas-bg);\n background-clip: padding-box;\n outline: 0;\n transition: var(--bs-offcanvas-transition);\n }\n}\n@media (max-width: 767.98px) and (prefers-reduced-motion: reduce) {\n .offcanvas-md {\n transition: none;\n }\n}\n@media (max-width: 767.98px) {\n .offcanvas-md.offcanvas-start {\n top: 0;\n left: 0;\n width: var(--bs-offcanvas-width);\n border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(-100%);\n }\n .offcanvas-md.offcanvas-end {\n top: 0;\n right: 0;\n width: var(--bs-offcanvas-width);\n border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(100%);\n }\n .offcanvas-md.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(-100%);\n }\n .offcanvas-md.offcanvas-bottom {\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(100%);\n }\n .offcanvas-md.showing, .offcanvas-md.show:not(.hiding) {\n transform: none;\n }\n .offcanvas-md.showing, .offcanvas-md.hiding, .offcanvas-md.show {\n visibility: visible;\n }\n}\n@media (min-width: 768px) {\n .offcanvas-md {\n --bs-offcanvas-height: auto;\n --bs-offcanvas-border-width: 0;\n background-color: transparent !important;\n }\n .offcanvas-md .offcanvas-header {\n display: none;\n }\n .offcanvas-md .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n background-color: transparent !important;\n }\n}\n\n@media (max-width: 991.98px) {\n .offcanvas-lg {\n position: fixed;\n bottom: 0;\n z-index: var(--bs-offcanvas-zindex);\n display: flex;\n flex-direction: column;\n max-width: 100%;\n color: var(--bs-offcanvas-color);\n visibility: hidden;\n background-color: var(--bs-offcanvas-bg);\n background-clip: padding-box;\n outline: 0;\n transition: var(--bs-offcanvas-transition);\n }\n}\n@media (max-width: 991.98px) and (prefers-reduced-motion: reduce) {\n .offcanvas-lg {\n transition: none;\n }\n}\n@media (max-width: 991.98px) {\n .offcanvas-lg.offcanvas-start {\n top: 0;\n left: 0;\n width: var(--bs-offcanvas-width);\n border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(-100%);\n }\n .offcanvas-lg.offcanvas-end {\n top: 0;\n right: 0;\n width: var(--bs-offcanvas-width);\n border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(100%);\n }\n .offcanvas-lg.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(-100%);\n }\n .offcanvas-lg.offcanvas-bottom {\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(100%);\n }\n .offcanvas-lg.showing, .offcanvas-lg.show:not(.hiding) {\n transform: none;\n }\n .offcanvas-lg.showing, .offcanvas-lg.hiding, .offcanvas-lg.show {\n visibility: visible;\n }\n}\n@media (min-width: 992px) {\n .offcanvas-lg {\n --bs-offcanvas-height: auto;\n --bs-offcanvas-border-width: 0;\n background-color: transparent !important;\n }\n .offcanvas-lg .offcanvas-header {\n display: none;\n }\n .offcanvas-lg .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n background-color: transparent !important;\n }\n}\n\n@media (max-width: 1199.98px) {\n .offcanvas-xl {\n position: fixed;\n bottom: 0;\n z-index: var(--bs-offcanvas-zindex);\n display: flex;\n flex-direction: column;\n max-width: 100%;\n color: var(--bs-offcanvas-color);\n visibility: hidden;\n background-color: var(--bs-offcanvas-bg);\n background-clip: padding-box;\n outline: 0;\n transition: var(--bs-offcanvas-transition);\n }\n}\n@media (max-width: 1199.98px) and (prefers-reduced-motion: reduce) {\n .offcanvas-xl {\n transition: none;\n }\n}\n@media (max-width: 1199.98px) {\n .offcanvas-xl.offcanvas-start {\n top: 0;\n left: 0;\n width: var(--bs-offcanvas-width);\n border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(-100%);\n }\n .offcanvas-xl.offcanvas-end {\n top: 0;\n right: 0;\n width: var(--bs-offcanvas-width);\n border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(100%);\n }\n .offcanvas-xl.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(-100%);\n }\n .offcanvas-xl.offcanvas-bottom {\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(100%);\n }\n .offcanvas-xl.showing, .offcanvas-xl.show:not(.hiding) {\n transform: none;\n }\n .offcanvas-xl.showing, .offcanvas-xl.hiding, .offcanvas-xl.show {\n visibility: visible;\n }\n}\n@media (min-width: 1200px) {\n .offcanvas-xl {\n --bs-offcanvas-height: auto;\n --bs-offcanvas-border-width: 0;\n background-color: transparent !important;\n }\n .offcanvas-xl .offcanvas-header {\n display: none;\n }\n .offcanvas-xl .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n background-color: transparent !important;\n }\n}\n\n@media (max-width: 1399.98px) {\n .offcanvas-xxl {\n position: fixed;\n bottom: 0;\n z-index: var(--bs-offcanvas-zindex);\n display: flex;\n flex-direction: column;\n max-width: 100%;\n color: var(--bs-offcanvas-color);\n visibility: hidden;\n background-color: var(--bs-offcanvas-bg);\n background-clip: padding-box;\n outline: 0;\n transition: var(--bs-offcanvas-transition);\n }\n}\n@media (max-width: 1399.98px) and (prefers-reduced-motion: reduce) {\n .offcanvas-xxl {\n transition: none;\n }\n}\n@media (max-width: 1399.98px) {\n .offcanvas-xxl.offcanvas-start {\n top: 0;\n left: 0;\n width: var(--bs-offcanvas-width);\n border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(-100%);\n }\n .offcanvas-xxl.offcanvas-end {\n top: 0;\n right: 0;\n width: var(--bs-offcanvas-width);\n border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(100%);\n }\n .offcanvas-xxl.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(-100%);\n }\n .offcanvas-xxl.offcanvas-bottom {\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(100%);\n }\n .offcanvas-xxl.showing, .offcanvas-xxl.show:not(.hiding) {\n transform: none;\n }\n .offcanvas-xxl.showing, .offcanvas-xxl.hiding, .offcanvas-xxl.show {\n visibility: visible;\n }\n}\n@media (min-width: 1400px) {\n .offcanvas-xxl {\n --bs-offcanvas-height: auto;\n --bs-offcanvas-border-width: 0;\n background-color: transparent !important;\n }\n .offcanvas-xxl .offcanvas-header {\n display: none;\n }\n .offcanvas-xxl .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n background-color: transparent !important;\n }\n}\n\n.offcanvas {\n position: fixed;\n bottom: 0;\n z-index: var(--bs-offcanvas-zindex);\n display: flex;\n flex-direction: column;\n max-width: 100%;\n color: var(--bs-offcanvas-color);\n visibility: hidden;\n background-color: var(--bs-offcanvas-bg);\n background-clip: padding-box;\n outline: 0;\n transition: var(--bs-offcanvas-transition);\n}\n@media (prefers-reduced-motion: reduce) {\n .offcanvas {\n transition: none;\n }\n}\n.offcanvas.offcanvas-start {\n top: 0;\n left: 0;\n width: var(--bs-offcanvas-width);\n border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(-100%);\n}\n.offcanvas.offcanvas-end {\n top: 0;\n right: 0;\n width: var(--bs-offcanvas-width);\n border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateX(100%);\n}\n.offcanvas.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(-100%);\n}\n.offcanvas.offcanvas-bottom {\n right: 0;\n left: 0;\n height: var(--bs-offcanvas-height);\n max-height: 100%;\n border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);\n transform: translateY(100%);\n}\n.offcanvas.showing, .offcanvas.show:not(.hiding) {\n transform: none;\n}\n.offcanvas.showing, .offcanvas.hiding, .offcanvas.show {\n visibility: visible;\n}\n\n.offcanvas-backdrop {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1040;\n width: 100vw;\n height: 100vh;\n background-color: #000;\n}\n.offcanvas-backdrop.fade {\n opacity: 0;\n}\n.offcanvas-backdrop.show {\n opacity: 0.5;\n}\n\n.offcanvas-header {\n display: flex;\n align-items: center;\n padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);\n}\n.offcanvas-header .btn-close {\n padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5);\n margin: calc(-0.5 * var(--bs-offcanvas-padding-y)) calc(-0.5 * var(--bs-offcanvas-padding-x)) calc(-0.5 * var(--bs-offcanvas-padding-y)) auto;\n}\n\n.offcanvas-title {\n margin-bottom: 0;\n line-height: var(--bs-offcanvas-title-line-height);\n}\n\n.offcanvas-body {\n flex-grow: 1;\n padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);\n overflow-y: auto;\n}\n\n.placeholder {\n display: inline-block;\n min-height: 1em;\n vertical-align: middle;\n cursor: wait;\n background-color: currentcolor;\n opacity: 0.5;\n}\n.placeholder.btn::before {\n display: inline-block;\n content: \"\";\n}\n\n.placeholder-xs {\n min-height: 0.6em;\n}\n\n.placeholder-sm {\n min-height: 0.8em;\n}\n\n.placeholder-lg {\n min-height: 1.2em;\n}\n\n.placeholder-glow .placeholder {\n animation: placeholder-glow 2s ease-in-out infinite;\n}\n\n@keyframes placeholder-glow {\n 50% {\n opacity: 0.2;\n }\n}\n.placeholder-wave {\n -webkit-mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);\n mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);\n -webkit-mask-size: 200% 100%;\n mask-size: 200% 100%;\n animation: placeholder-wave 2s linear infinite;\n}\n\n@keyframes placeholder-wave {\n 100% {\n -webkit-mask-position: -200% 0%;\n mask-position: -200% 0%;\n }\n}\n.clearfix::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.text-bg-primary {\n color: #fff !important;\n background-color: RGBA(var(--bs-primary-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.text-bg-secondary {\n color: #fff !important;\n background-color: RGBA(var(--bs-secondary-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.text-bg-success {\n color: #fff !important;\n background-color: RGBA(var(--bs-success-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.text-bg-info {\n color: #000 !important;\n background-color: RGBA(var(--bs-info-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.text-bg-warning {\n color: #000 !important;\n background-color: RGBA(var(--bs-warning-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.text-bg-danger {\n color: #fff !important;\n background-color: RGBA(var(--bs-danger-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.text-bg-light {\n color: #000 !important;\n background-color: RGBA(var(--bs-light-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.text-bg-dark {\n color: #fff !important;\n background-color: RGBA(var(--bs-dark-rgb), var(--bs-bg-opacity, 1)) !important;\n}\n\n.link-primary {\n color: RGBA(var(--bs-primary-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-primary:hover, .link-primary:focus {\n color: RGBA(10, 88, 202, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(10, 88, 202, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(10, 88, 202, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-secondary {\n color: RGBA(var(--bs-secondary-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-secondary:hover, .link-secondary:focus {\n color: RGBA(86, 94, 100, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(86, 94, 100, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(86, 94, 100, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-success {\n color: RGBA(var(--bs-success-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-success:hover, .link-success:focus {\n color: RGBA(20, 108, 67, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(20, 108, 67, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(20, 108, 67, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-info {\n color: RGBA(var(--bs-info-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-info:hover, .link-info:focus {\n color: RGBA(61, 213, 243, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(61, 213, 243, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(61, 213, 243, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-warning {\n color: RGBA(var(--bs-warning-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-warning:hover, .link-warning:focus {\n color: RGBA(255, 205, 57, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(255, 205, 57, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(255, 205, 57, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-danger {\n color: RGBA(var(--bs-danger-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-danger:hover, .link-danger:focus {\n color: RGBA(176, 42, 55, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(176, 42, 55, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(176, 42, 55, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-light {\n color: RGBA(var(--bs-light-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-light:hover, .link-light:focus {\n color: RGBA(249, 250, 251, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(249, 250, 251, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(249, 250, 251, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-dark {\n color: RGBA(var(--bs-dark-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-dark:hover, .link-dark:focus {\n color: RGBA(26, 30, 33, var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(26, 30, 33, var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(26, 30, 33, var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-body-emphasis {\n color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 1)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n.link-body-emphasis:hover, .link-body-emphasis:focus {\n color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 0.75)) !important;\n -webkit-text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 0.75)) !important;\n text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 0.75)) !important;\n}\n\n.focus-ring:focus {\n outline: 0;\n box-shadow: var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) var(--bs-focus-ring-color);\n}\n\n.icon-link {\n display: inline-flex;\n gap: 0.375rem;\n align-items: center;\n -webkit-text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5));\n text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5));\n text-underline-offset: 0.25em;\n -webkit-backface-visibility: hidden;\n backface-visibility: hidden;\n}\n.icon-link > .bi {\n flex-shrink: 0;\n width: 1em;\n height: 1em;\n fill: currentcolor;\n transition: 0.2s ease-in-out transform;\n}\n@media (prefers-reduced-motion: reduce) {\n .icon-link > .bi {\n transition: none;\n }\n}\n\n.icon-link-hover:hover > .bi, .icon-link-hover:focus-visible > .bi {\n transform: var(--bs-icon-link-transform, translate3d(0.25em, 0, 0));\n}\n\n.ratio {\n position: relative;\n width: 100%;\n}\n.ratio::before {\n display: block;\n padding-top: var(--bs-aspect-ratio);\n content: \"\";\n}\n.ratio > * {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n}\n\n.ratio-1x1 {\n --bs-aspect-ratio: 100%;\n}\n\n.ratio-4x3 {\n --bs-aspect-ratio: 75%;\n}\n\n.ratio-16x9 {\n --bs-aspect-ratio: 56.25%;\n}\n\n.ratio-21x9 {\n --bs-aspect-ratio: 42.8571428571%;\n}\n\n.fixed-top {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n\n.fixed-bottom {\n position: fixed;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1030;\n}\n\n.sticky-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n}\n\n.sticky-bottom {\n position: -webkit-sticky;\n position: sticky;\n bottom: 0;\n z-index: 1020;\n}\n\n@media (min-width: 576px) {\n .sticky-sm-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n .sticky-sm-bottom {\n position: -webkit-sticky;\n position: sticky;\n bottom: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 768px) {\n .sticky-md-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n .sticky-md-bottom {\n position: -webkit-sticky;\n position: sticky;\n bottom: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 992px) {\n .sticky-lg-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n .sticky-lg-bottom {\n position: -webkit-sticky;\n position: sticky;\n bottom: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 1200px) {\n .sticky-xl-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n .sticky-xl-bottom {\n position: -webkit-sticky;\n position: sticky;\n bottom: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 1400px) {\n .sticky-xxl-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n .sticky-xxl-bottom {\n position: -webkit-sticky;\n position: sticky;\n bottom: 0;\n z-index: 1020;\n }\n}\n.hstack {\n display: flex;\n flex-direction: row;\n align-items: center;\n align-self: stretch;\n}\n\n.vstack {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-self: stretch;\n}\n\n.visually-hidden,\n.visually-hidden-focusable:not(:focus):not(:focus-within) {\n width: 1px !important;\n height: 1px !important;\n padding: 0 !important;\n margin: -1px !important;\n overflow: hidden !important;\n clip: rect(0, 0, 0, 0) !important;\n white-space: nowrap !important;\n border: 0 !important;\n}\n.visually-hidden:not(caption),\n.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) {\n position: absolute !important;\n}\n\n.stretched-link::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1;\n content: \"\";\n}\n\n.text-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.vr {\n display: inline-block;\n align-self: stretch;\n width: var(--bs-border-width);\n min-height: 1em;\n background-color: currentcolor;\n opacity: 0.25;\n}\n\n.align-baseline {\n vertical-align: baseline !important;\n}\n\n.align-top {\n vertical-align: top !important;\n}\n\n.align-middle {\n vertical-align: middle !important;\n}\n\n.align-bottom {\n vertical-align: bottom !important;\n}\n\n.align-text-bottom {\n vertical-align: text-bottom !important;\n}\n\n.align-text-top {\n vertical-align: text-top !important;\n}\n\n.float-start {\n float: left !important;\n}\n\n.float-end {\n float: right !important;\n}\n\n.float-none {\n float: none !important;\n}\n\n.object-fit-contain {\n -o-object-fit: contain !important;\n object-fit: contain !important;\n}\n\n.object-fit-cover {\n -o-object-fit: cover !important;\n object-fit: cover !important;\n}\n\n.object-fit-fill {\n -o-object-fit: fill !important;\n object-fit: fill !important;\n}\n\n.object-fit-scale {\n -o-object-fit: scale-down !important;\n object-fit: scale-down !important;\n}\n\n.object-fit-none {\n -o-object-fit: none !important;\n object-fit: none !important;\n}\n\n.opacity-0 {\n opacity: 0 !important;\n}\n\n.opacity-25 {\n opacity: 0.25 !important;\n}\n\n.opacity-50 {\n opacity: 0.5 !important;\n}\n\n.opacity-75 {\n opacity: 0.75 !important;\n}\n\n.opacity-100 {\n opacity: 1 !important;\n}\n\n.overflow-auto {\n overflow: auto !important;\n}\n\n.overflow-hidden {\n overflow: hidden !important;\n}\n\n.overflow-visible {\n overflow: visible !important;\n}\n\n.overflow-scroll {\n overflow: scroll !important;\n}\n\n.overflow-x-auto {\n overflow-x: auto !important;\n}\n\n.overflow-x-hidden {\n overflow-x: hidden !important;\n}\n\n.overflow-x-visible {\n overflow-x: visible !important;\n}\n\n.overflow-x-scroll {\n overflow-x: scroll !important;\n}\n\n.overflow-y-auto {\n overflow-y: auto !important;\n}\n\n.overflow-y-hidden {\n overflow-y: hidden !important;\n}\n\n.overflow-y-visible {\n overflow-y: visible !important;\n}\n\n.overflow-y-scroll {\n overflow-y: scroll !important;\n}\n\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.shadow {\n box-shadow: var(--bs-box-shadow) !important;\n}\n\n.shadow-sm {\n box-shadow: var(--bs-box-shadow-sm) !important;\n}\n\n.shadow-lg {\n box-shadow: var(--bs-box-shadow-lg) !important;\n}\n\n.shadow-none {\n box-shadow: none !important;\n}\n\n.focus-ring-primary {\n --bs-focus-ring-color: rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity));\n}\n\n.focus-ring-secondary {\n --bs-focus-ring-color: rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity));\n}\n\n.focus-ring-success {\n --bs-focus-ring-color: rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity));\n}\n\n.focus-ring-info {\n --bs-focus-ring-color: rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity));\n}\n\n.focus-ring-warning {\n --bs-focus-ring-color: rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity));\n}\n\n.focus-ring-danger {\n --bs-focus-ring-color: rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity));\n}\n\n.focus-ring-light {\n --bs-focus-ring-color: rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity));\n}\n\n.focus-ring-dark {\n --bs-focus-ring-color: rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity));\n}\n\n.position-static {\n position: static !important;\n}\n\n.position-relative {\n position: relative !important;\n}\n\n.position-absolute {\n position: absolute !important;\n}\n\n.position-fixed {\n position: fixed !important;\n}\n\n.position-sticky {\n position: -webkit-sticky !important;\n position: sticky !important;\n}\n\n.top-0 {\n top: 0 !important;\n}\n\n.top-50 {\n top: 50% !important;\n}\n\n.top-100 {\n top: 100% !important;\n}\n\n.bottom-0 {\n bottom: 0 !important;\n}\n\n.bottom-50 {\n bottom: 50% !important;\n}\n\n.bottom-100 {\n bottom: 100% !important;\n}\n\n.start-0 {\n left: 0 !important;\n}\n\n.start-50 {\n left: 50% !important;\n}\n\n.start-100 {\n left: 100% !important;\n}\n\n.end-0 {\n right: 0 !important;\n}\n\n.end-50 {\n right: 50% !important;\n}\n\n.end-100 {\n right: 100% !important;\n}\n\n.translate-middle {\n transform: translate(-50%, -50%) !important;\n}\n\n.translate-middle-x {\n transform: translateX(-50%) !important;\n}\n\n.translate-middle-y {\n transform: translateY(-50%) !important;\n}\n\n.border {\n border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;\n}\n\n.border-0 {\n border: 0 !important;\n}\n\n.border-top {\n border-top: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;\n}\n\n.border-top-0 {\n border-top: 0 !important;\n}\n\n.border-end {\n border-right: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;\n}\n\n.border-end-0 {\n border-right: 0 !important;\n}\n\n.border-bottom {\n border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;\n}\n\n.border-bottom-0 {\n border-bottom: 0 !important;\n}\n\n.border-start {\n border-left: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;\n}\n\n.border-start-0 {\n border-left: 0 !important;\n}\n\n.border-primary {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-primary-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-secondary {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-secondary-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-success {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-success-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-info {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-info-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-warning {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-warning-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-danger {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-danger-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-light {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-dark {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-dark-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-black {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-black-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-white {\n --bs-border-opacity: 1;\n border-color: rgba(var(--bs-white-rgb), var(--bs-border-opacity)) !important;\n}\n\n.border-primary-subtle {\n border-color: var(--bs-primary-border-subtle) !important;\n}\n\n.border-secondary-subtle {\n border-color: var(--bs-secondary-border-subtle) !important;\n}\n\n.border-success-subtle {\n border-color: var(--bs-success-border-subtle) !important;\n}\n\n.border-info-subtle {\n border-color: var(--bs-info-border-subtle) !important;\n}\n\n.border-warning-subtle {\n border-color: var(--bs-warning-border-subtle) !important;\n}\n\n.border-danger-subtle {\n border-color: var(--bs-danger-border-subtle) !important;\n}\n\n.border-light-subtle {\n border-color: var(--bs-light-border-subtle) !important;\n}\n\n.border-dark-subtle {\n border-color: var(--bs-dark-border-subtle) !important;\n}\n\n.border-1 {\n border-width: 1px !important;\n}\n\n.border-2 {\n border-width: 2px !important;\n}\n\n.border-3 {\n border-width: 3px !important;\n}\n\n.border-4 {\n border-width: 4px !important;\n}\n\n.border-5 {\n border-width: 5px !important;\n}\n\n.border-opacity-10 {\n --bs-border-opacity: 0.1;\n}\n\n.border-opacity-25 {\n --bs-border-opacity: 0.25;\n}\n\n.border-opacity-50 {\n --bs-border-opacity: 0.5;\n}\n\n.border-opacity-75 {\n --bs-border-opacity: 0.75;\n}\n\n.border-opacity-100 {\n --bs-border-opacity: 1;\n}\n\n.w-25 {\n width: 25% !important;\n}\n\n.w-50 {\n width: 50% !important;\n}\n\n.w-75 {\n width: 75% !important;\n}\n\n.w-100 {\n width: 100% !important;\n}\n\n.w-auto {\n width: auto !important;\n}\n\n.mw-100 {\n max-width: 100% !important;\n}\n\n.vw-100 {\n width: 100vw !important;\n}\n\n.min-vw-100 {\n min-width: 100vw !important;\n}\n\n.h-25 {\n height: 25% !important;\n}\n\n.h-50 {\n height: 50% !important;\n}\n\n.h-75 {\n height: 75% !important;\n}\n\n.h-100 {\n height: 100% !important;\n}\n\n.h-auto {\n height: auto !important;\n}\n\n.mh-100 {\n max-height: 100% !important;\n}\n\n.vh-100 {\n height: 100vh !important;\n}\n\n.min-vh-100 {\n min-height: 100vh !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n.gap-0 {\n gap: 0 !important;\n}\n\n.gap-1 {\n gap: 0.25rem !important;\n}\n\n.gap-2 {\n gap: 0.5rem !important;\n}\n\n.gap-3 {\n gap: 1rem !important;\n}\n\n.gap-4 {\n gap: 1.5rem !important;\n}\n\n.gap-5 {\n gap: 3rem !important;\n}\n\n.row-gap-0 {\n row-gap: 0 !important;\n}\n\n.row-gap-1 {\n row-gap: 0.25rem !important;\n}\n\n.row-gap-2 {\n row-gap: 0.5rem !important;\n}\n\n.row-gap-3 {\n row-gap: 1rem !important;\n}\n\n.row-gap-4 {\n row-gap: 1.5rem !important;\n}\n\n.row-gap-5 {\n row-gap: 3rem !important;\n}\n\n.column-gap-0 {\n -moz-column-gap: 0 !important;\n column-gap: 0 !important;\n}\n\n.column-gap-1 {\n -moz-column-gap: 0.25rem !important;\n column-gap: 0.25rem !important;\n}\n\n.column-gap-2 {\n -moz-column-gap: 0.5rem !important;\n column-gap: 0.5rem !important;\n}\n\n.column-gap-3 {\n -moz-column-gap: 1rem !important;\n column-gap: 1rem !important;\n}\n\n.column-gap-4 {\n -moz-column-gap: 1.5rem !important;\n column-gap: 1.5rem !important;\n}\n\n.column-gap-5 {\n -moz-column-gap: 3rem !important;\n column-gap: 3rem !important;\n}\n\n.font-monospace {\n font-family: var(--bs-font-monospace) !important;\n}\n\n.fs-1 {\n font-size: calc(1.375rem + 1.5vw) !important;\n}\n\n.fs-2 {\n font-size: calc(1.325rem + 0.9vw) !important;\n}\n\n.fs-3 {\n font-size: calc(1.3rem + 0.6vw) !important;\n}\n\n.fs-4 {\n font-size: calc(1.275rem + 0.3vw) !important;\n}\n\n.fs-5 {\n font-size: 1.25rem !important;\n}\n\n.fs-6 {\n font-size: 1rem !important;\n}\n\n.fst-italic {\n font-style: italic !important;\n}\n\n.fst-normal {\n font-style: normal !important;\n}\n\n.fw-lighter {\n font-weight: lighter !important;\n}\n\n.fw-light {\n font-weight: 300 !important;\n}\n\n.fw-normal {\n font-weight: 400 !important;\n}\n\n.fw-medium {\n font-weight: 500 !important;\n}\n\n.fw-semibold {\n font-weight: 600 !important;\n}\n\n.fw-bold {\n font-weight: 700 !important;\n}\n\n.fw-bolder {\n font-weight: bolder !important;\n}\n\n.lh-1 {\n line-height: 1 !important;\n}\n\n.lh-sm {\n line-height: 1.25 !important;\n}\n\n.lh-base {\n line-height: 1.5 !important;\n}\n\n.lh-lg {\n line-height: 2 !important;\n}\n\n.text-start {\n text-align: left !important;\n}\n\n.text-end {\n text-align: right !important;\n}\n\n.text-center {\n text-align: center !important;\n}\n\n.text-decoration-none {\n text-decoration: none !important;\n}\n\n.text-decoration-underline {\n text-decoration: underline !important;\n}\n\n.text-decoration-line-through {\n text-decoration: line-through !important;\n}\n\n.text-lowercase {\n text-transform: lowercase !important;\n}\n\n.text-uppercase {\n text-transform: uppercase !important;\n}\n\n.text-capitalize {\n text-transform: capitalize !important;\n}\n\n.text-wrap {\n white-space: normal !important;\n}\n\n.text-nowrap {\n white-space: nowrap !important;\n}\n\n/* rtl:begin:remove */\n.text-break {\n word-wrap: break-word !important;\n word-break: break-word !important;\n}\n\n/* rtl:end:remove */\n.text-primary {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-secondary {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-success {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-info {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-warning {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-danger {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-light {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-dark {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-black {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-white {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-body {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-body-color-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-muted {\n --bs-text-opacity: 1;\n color: var(--bs-secondary-color) !important;\n}\n\n.text-black-50 {\n --bs-text-opacity: 1;\n color: rgba(0, 0, 0, 0.5) !important;\n}\n\n.text-white-50 {\n --bs-text-opacity: 1;\n color: rgba(255, 255, 255, 0.5) !important;\n}\n\n.text-body-secondary {\n --bs-text-opacity: 1;\n color: var(--bs-secondary-color) !important;\n}\n\n.text-body-tertiary {\n --bs-text-opacity: 1;\n color: var(--bs-tertiary-color) !important;\n}\n\n.text-body-emphasis {\n --bs-text-opacity: 1;\n color: var(--bs-emphasis-color) !important;\n}\n\n.text-reset {\n --bs-text-opacity: 1;\n color: inherit !important;\n}\n\n.text-opacity-25 {\n --bs-text-opacity: 0.25;\n}\n\n.text-opacity-50 {\n --bs-text-opacity: 0.5;\n}\n\n.text-opacity-75 {\n --bs-text-opacity: 0.75;\n}\n\n.text-opacity-100 {\n --bs-text-opacity: 1;\n}\n\n.text-primary-emphasis {\n color: var(--bs-primary-text-emphasis) !important;\n}\n\n.text-secondary-emphasis {\n color: var(--bs-secondary-text-emphasis) !important;\n}\n\n.text-success-emphasis {\n color: var(--bs-success-text-emphasis) !important;\n}\n\n.text-info-emphasis {\n color: var(--bs-info-text-emphasis) !important;\n}\n\n.text-warning-emphasis {\n color: var(--bs-warning-text-emphasis) !important;\n}\n\n.text-danger-emphasis {\n color: var(--bs-danger-text-emphasis) !important;\n}\n\n.text-light-emphasis {\n color: var(--bs-light-text-emphasis) !important;\n}\n\n.text-dark-emphasis {\n color: var(--bs-dark-text-emphasis) !important;\n}\n\n.link-opacity-10 {\n --bs-link-opacity: 0.1;\n}\n\n.link-opacity-10-hover:hover {\n --bs-link-opacity: 0.1;\n}\n\n.link-opacity-25 {\n --bs-link-opacity: 0.25;\n}\n\n.link-opacity-25-hover:hover {\n --bs-link-opacity: 0.25;\n}\n\n.link-opacity-50 {\n --bs-link-opacity: 0.5;\n}\n\n.link-opacity-50-hover:hover {\n --bs-link-opacity: 0.5;\n}\n\n.link-opacity-75 {\n --bs-link-opacity: 0.75;\n}\n\n.link-opacity-75-hover:hover {\n --bs-link-opacity: 0.75;\n}\n\n.link-opacity-100 {\n --bs-link-opacity: 1;\n}\n\n.link-opacity-100-hover:hover {\n --bs-link-opacity: 1;\n}\n\n.link-offset-1 {\n text-underline-offset: 0.125em !important;\n}\n\n.link-offset-1-hover:hover {\n text-underline-offset: 0.125em !important;\n}\n\n.link-offset-2 {\n text-underline-offset: 0.25em !important;\n}\n\n.link-offset-2-hover:hover {\n text-underline-offset: 0.25em !important;\n}\n\n.link-offset-3 {\n text-underline-offset: 0.375em !important;\n}\n\n.link-offset-3-hover:hover {\n text-underline-offset: 0.375em !important;\n}\n\n.link-underline-primary {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline-secondary {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline-success {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline-info {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline-warning {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline-danger {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline-light {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline-dark {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important;\n text-decoration-color: rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important;\n}\n\n.link-underline {\n --bs-link-underline-opacity: 1;\n -webkit-text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-underline-opacity, 1)) !important;\n text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-underline-opacity, 1)) !important;\n}\n\n.link-underline-opacity-0 {\n --bs-link-underline-opacity: 0;\n}\n\n.link-underline-opacity-0-hover:hover {\n --bs-link-underline-opacity: 0;\n}\n\n.link-underline-opacity-10 {\n --bs-link-underline-opacity: 0.1;\n}\n\n.link-underline-opacity-10-hover:hover {\n --bs-link-underline-opacity: 0.1;\n}\n\n.link-underline-opacity-25 {\n --bs-link-underline-opacity: 0.25;\n}\n\n.link-underline-opacity-25-hover:hover {\n --bs-link-underline-opacity: 0.25;\n}\n\n.link-underline-opacity-50 {\n --bs-link-underline-opacity: 0.5;\n}\n\n.link-underline-opacity-50-hover:hover {\n --bs-link-underline-opacity: 0.5;\n}\n\n.link-underline-opacity-75 {\n --bs-link-underline-opacity: 0.75;\n}\n\n.link-underline-opacity-75-hover:hover {\n --bs-link-underline-opacity: 0.75;\n}\n\n.link-underline-opacity-100 {\n --bs-link-underline-opacity: 1;\n}\n\n.link-underline-opacity-100-hover:hover {\n --bs-link-underline-opacity: 1;\n}\n\n.bg-primary {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-secondary {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-secondary-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-success {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-success-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-info {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-warning {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-warning-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-danger {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-light {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-dark {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-black {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-white {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-body {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-transparent {\n --bs-bg-opacity: 1;\n background-color: transparent !important;\n}\n\n.bg-body-secondary {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-secondary-bg-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-body-tertiary {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-tertiary-bg-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-opacity-10 {\n --bs-bg-opacity: 0.1;\n}\n\n.bg-opacity-25 {\n --bs-bg-opacity: 0.25;\n}\n\n.bg-opacity-50 {\n --bs-bg-opacity: 0.5;\n}\n\n.bg-opacity-75 {\n --bs-bg-opacity: 0.75;\n}\n\n.bg-opacity-100 {\n --bs-bg-opacity: 1;\n}\n\n.bg-primary-subtle {\n background-color: var(--bs-primary-bg-subtle) !important;\n}\n\n.bg-secondary-subtle {\n background-color: var(--bs-secondary-bg-subtle) !important;\n}\n\n.bg-success-subtle {\n background-color: var(--bs-success-bg-subtle) !important;\n}\n\n.bg-info-subtle {\n background-color: var(--bs-info-bg-subtle) !important;\n}\n\n.bg-warning-subtle {\n background-color: var(--bs-warning-bg-subtle) !important;\n}\n\n.bg-danger-subtle {\n background-color: var(--bs-danger-bg-subtle) !important;\n}\n\n.bg-light-subtle {\n background-color: var(--bs-light-bg-subtle) !important;\n}\n\n.bg-dark-subtle {\n background-color: var(--bs-dark-bg-subtle) !important;\n}\n\n.bg-gradient {\n background-image: var(--bs-gradient) !important;\n}\n\n.user-select-all {\n -webkit-user-select: all !important;\n -moz-user-select: all !important;\n user-select: all !important;\n}\n\n.user-select-auto {\n -webkit-user-select: auto !important;\n -moz-user-select: auto !important;\n user-select: auto !important;\n}\n\n.user-select-none {\n -webkit-user-select: none !important;\n -moz-user-select: none !important;\n user-select: none !important;\n}\n\n.pe-none {\n pointer-events: none !important;\n}\n\n.pe-auto {\n pointer-events: auto !important;\n}\n\n.rounded {\n border-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-0 {\n border-radius: 0 !important;\n}\n\n.rounded-1 {\n border-radius: var(--bs-border-radius-sm) !important;\n}\n\n.rounded-2 {\n border-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-3 {\n border-radius: var(--bs-border-radius-lg) !important;\n}\n\n.rounded-4 {\n border-radius: var(--bs-border-radius-xl) !important;\n}\n\n.rounded-5 {\n border-radius: var(--bs-border-radius-xxl) !important;\n}\n\n.rounded-circle {\n border-radius: 50% !important;\n}\n\n.rounded-pill {\n border-radius: var(--bs-border-radius-pill) !important;\n}\n\n.rounded-top {\n border-top-left-radius: var(--bs-border-radius) !important;\n border-top-right-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-top-0 {\n border-top-left-radius: 0 !important;\n border-top-right-radius: 0 !important;\n}\n\n.rounded-top-1 {\n border-top-left-radius: var(--bs-border-radius-sm) !important;\n border-top-right-radius: var(--bs-border-radius-sm) !important;\n}\n\n.rounded-top-2 {\n border-top-left-radius: var(--bs-border-radius) !important;\n border-top-right-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-top-3 {\n border-top-left-radius: var(--bs-border-radius-lg) !important;\n border-top-right-radius: var(--bs-border-radius-lg) !important;\n}\n\n.rounded-top-4 {\n border-top-left-radius: var(--bs-border-radius-xl) !important;\n border-top-right-radius: var(--bs-border-radius-xl) !important;\n}\n\n.rounded-top-5 {\n border-top-left-radius: var(--bs-border-radius-xxl) !important;\n border-top-right-radius: var(--bs-border-radius-xxl) !important;\n}\n\n.rounded-top-circle {\n border-top-left-radius: 50% !important;\n border-top-right-radius: 50% !important;\n}\n\n.rounded-top-pill {\n border-top-left-radius: var(--bs-border-radius-pill) !important;\n border-top-right-radius: var(--bs-border-radius-pill) !important;\n}\n\n.rounded-end {\n border-top-right-radius: var(--bs-border-radius) !important;\n border-bottom-right-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-end-0 {\n border-top-right-radius: 0 !important;\n border-bottom-right-radius: 0 !important;\n}\n\n.rounded-end-1 {\n border-top-right-radius: var(--bs-border-radius-sm) !important;\n border-bottom-right-radius: var(--bs-border-radius-sm) !important;\n}\n\n.rounded-end-2 {\n border-top-right-radius: var(--bs-border-radius) !important;\n border-bottom-right-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-end-3 {\n border-top-right-radius: var(--bs-border-radius-lg) !important;\n border-bottom-right-radius: var(--bs-border-radius-lg) !important;\n}\n\n.rounded-end-4 {\n border-top-right-radius: var(--bs-border-radius-xl) !important;\n border-bottom-right-radius: var(--bs-border-radius-xl) !important;\n}\n\n.rounded-end-5 {\n border-top-right-radius: var(--bs-border-radius-xxl) !important;\n border-bottom-right-radius: var(--bs-border-radius-xxl) !important;\n}\n\n.rounded-end-circle {\n border-top-right-radius: 50% !important;\n border-bottom-right-radius: 50% !important;\n}\n\n.rounded-end-pill {\n border-top-right-radius: var(--bs-border-radius-pill) !important;\n border-bottom-right-radius: var(--bs-border-radius-pill) !important;\n}\n\n.rounded-bottom {\n border-bottom-right-radius: var(--bs-border-radius) !important;\n border-bottom-left-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-bottom-0 {\n border-bottom-right-radius: 0 !important;\n border-bottom-left-radius: 0 !important;\n}\n\n.rounded-bottom-1 {\n border-bottom-right-radius: var(--bs-border-radius-sm) !important;\n border-bottom-left-radius: var(--bs-border-radius-sm) !important;\n}\n\n.rounded-bottom-2 {\n border-bottom-right-radius: var(--bs-border-radius) !important;\n border-bottom-left-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-bottom-3 {\n border-bottom-right-radius: var(--bs-border-radius-lg) !important;\n border-bottom-left-radius: var(--bs-border-radius-lg) !important;\n}\n\n.rounded-bottom-4 {\n border-bottom-right-radius: var(--bs-border-radius-xl) !important;\n border-bottom-left-radius: var(--bs-border-radius-xl) !important;\n}\n\n.rounded-bottom-5 {\n border-bottom-right-radius: var(--bs-border-radius-xxl) !important;\n border-bottom-left-radius: var(--bs-border-radius-xxl) !important;\n}\n\n.rounded-bottom-circle {\n border-bottom-right-radius: 50% !important;\n border-bottom-left-radius: 50% !important;\n}\n\n.rounded-bottom-pill {\n border-bottom-right-radius: var(--bs-border-radius-pill) !important;\n border-bottom-left-radius: var(--bs-border-radius-pill) !important;\n}\n\n.rounded-start {\n border-bottom-left-radius: var(--bs-border-radius) !important;\n border-top-left-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-start-0 {\n border-bottom-left-radius: 0 !important;\n border-top-left-radius: 0 !important;\n}\n\n.rounded-start-1 {\n border-bottom-left-radius: var(--bs-border-radius-sm) !important;\n border-top-left-radius: var(--bs-border-radius-sm) !important;\n}\n\n.rounded-start-2 {\n border-bottom-left-radius: var(--bs-border-radius) !important;\n border-top-left-radius: var(--bs-border-radius) !important;\n}\n\n.rounded-start-3 {\n border-bottom-left-radius: var(--bs-border-radius-lg) !important;\n border-top-left-radius: var(--bs-border-radius-lg) !important;\n}\n\n.rounded-start-4 {\n border-bottom-left-radius: var(--bs-border-radius-xl) !important;\n border-top-left-radius: var(--bs-border-radius-xl) !important;\n}\n\n.rounded-start-5 {\n border-bottom-left-radius: var(--bs-border-radius-xxl) !important;\n border-top-left-radius: var(--bs-border-radius-xxl) !important;\n}\n\n.rounded-start-circle {\n border-bottom-left-radius: 50% !important;\n border-top-left-radius: 50% !important;\n}\n\n.rounded-start-pill {\n border-bottom-left-radius: var(--bs-border-radius-pill) !important;\n border-top-left-radius: var(--bs-border-radius-pill) !important;\n}\n\n.visible {\n visibility: visible !important;\n}\n\n.invisible {\n visibility: hidden !important;\n}\n\n.z-n1 {\n z-index: -1 !important;\n}\n\n.z-0 {\n z-index: 0 !important;\n}\n\n.z-1 {\n z-index: 1 !important;\n}\n\n.z-2 {\n z-index: 2 !important;\n}\n\n.z-3 {\n z-index: 3 !important;\n}\n\n@media (min-width: 576px) {\n .float-sm-start {\n float: left !important;\n }\n .float-sm-end {\n float: right !important;\n }\n .float-sm-none {\n float: none !important;\n }\n .object-fit-sm-contain {\n -o-object-fit: contain !important;\n object-fit: contain !important;\n }\n .object-fit-sm-cover {\n -o-object-fit: cover !important;\n object-fit: cover !important;\n }\n .object-fit-sm-fill {\n -o-object-fit: fill !important;\n object-fit: fill !important;\n }\n .object-fit-sm-scale {\n -o-object-fit: scale-down !important;\n object-fit: scale-down !important;\n }\n .object-fit-sm-none {\n -o-object-fit: none !important;\n object-fit: none !important;\n }\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-right: 0 !important;\n }\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n .me-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n .ms-sm-auto {\n margin-left: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n .gap-sm-0 {\n gap: 0 !important;\n }\n .gap-sm-1 {\n gap: 0.25rem !important;\n }\n .gap-sm-2 {\n gap: 0.5rem !important;\n }\n .gap-sm-3 {\n gap: 1rem !important;\n }\n .gap-sm-4 {\n gap: 1.5rem !important;\n }\n .gap-sm-5 {\n gap: 3rem !important;\n }\n .row-gap-sm-0 {\n row-gap: 0 !important;\n }\n .row-gap-sm-1 {\n row-gap: 0.25rem !important;\n }\n .row-gap-sm-2 {\n row-gap: 0.5rem !important;\n }\n .row-gap-sm-3 {\n row-gap: 1rem !important;\n }\n .row-gap-sm-4 {\n row-gap: 1.5rem !important;\n }\n .row-gap-sm-5 {\n row-gap: 3rem !important;\n }\n .column-gap-sm-0 {\n -moz-column-gap: 0 !important;\n column-gap: 0 !important;\n }\n .column-gap-sm-1 {\n -moz-column-gap: 0.25rem !important;\n column-gap: 0.25rem !important;\n }\n .column-gap-sm-2 {\n -moz-column-gap: 0.5rem !important;\n column-gap: 0.5rem !important;\n }\n .column-gap-sm-3 {\n -moz-column-gap: 1rem !important;\n column-gap: 1rem !important;\n }\n .column-gap-sm-4 {\n -moz-column-gap: 1.5rem !important;\n column-gap: 1.5rem !important;\n }\n .column-gap-sm-5 {\n -moz-column-gap: 3rem !important;\n column-gap: 3rem !important;\n }\n .text-sm-start {\n text-align: left !important;\n }\n .text-sm-end {\n text-align: right !important;\n }\n .text-sm-center {\n text-align: center !important;\n }\n}\n@media (min-width: 768px) {\n .float-md-start {\n float: left !important;\n }\n .float-md-end {\n float: right !important;\n }\n .float-md-none {\n float: none !important;\n }\n .object-fit-md-contain {\n -o-object-fit: contain !important;\n object-fit: contain !important;\n }\n .object-fit-md-cover {\n -o-object-fit: cover !important;\n object-fit: cover !important;\n }\n .object-fit-md-fill {\n -o-object-fit: fill !important;\n object-fit: fill !important;\n }\n .object-fit-md-scale {\n -o-object-fit: scale-down !important;\n object-fit: scale-down !important;\n }\n .object-fit-md-none {\n -o-object-fit: none !important;\n object-fit: none !important;\n }\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-right: 0 !important;\n }\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n .me-md-3 {\n margin-right: 1rem !important;\n }\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n .me-md-5 {\n margin-right: 3rem !important;\n }\n .me-md-auto {\n margin-right: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-left: 0 !important;\n }\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n .ms-md-auto {\n margin-left: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-right: 0 !important;\n }\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-left: 0 !important;\n }\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n .gap-md-0 {\n gap: 0 !important;\n }\n .gap-md-1 {\n gap: 0.25rem !important;\n }\n .gap-md-2 {\n gap: 0.5rem !important;\n }\n .gap-md-3 {\n gap: 1rem !important;\n }\n .gap-md-4 {\n gap: 1.5rem !important;\n }\n .gap-md-5 {\n gap: 3rem !important;\n }\n .row-gap-md-0 {\n row-gap: 0 !important;\n }\n .row-gap-md-1 {\n row-gap: 0.25rem !important;\n }\n .row-gap-md-2 {\n row-gap: 0.5rem !important;\n }\n .row-gap-md-3 {\n row-gap: 1rem !important;\n }\n .row-gap-md-4 {\n row-gap: 1.5rem !important;\n }\n .row-gap-md-5 {\n row-gap: 3rem !important;\n }\n .column-gap-md-0 {\n -moz-column-gap: 0 !important;\n column-gap: 0 !important;\n }\n .column-gap-md-1 {\n -moz-column-gap: 0.25rem !important;\n column-gap: 0.25rem !important;\n }\n .column-gap-md-2 {\n -moz-column-gap: 0.5rem !important;\n column-gap: 0.5rem !important;\n }\n .column-gap-md-3 {\n -moz-column-gap: 1rem !important;\n column-gap: 1rem !important;\n }\n .column-gap-md-4 {\n -moz-column-gap: 1.5rem !important;\n column-gap: 1.5rem !important;\n }\n .column-gap-md-5 {\n -moz-column-gap: 3rem !important;\n column-gap: 3rem !important;\n }\n .text-md-start {\n text-align: left !important;\n }\n .text-md-end {\n text-align: right !important;\n }\n .text-md-center {\n text-align: center !important;\n }\n}\n@media (min-width: 992px) {\n .float-lg-start {\n float: left !important;\n }\n .float-lg-end {\n float: right !important;\n }\n .float-lg-none {\n float: none !important;\n }\n .object-fit-lg-contain {\n -o-object-fit: contain !important;\n object-fit: contain !important;\n }\n .object-fit-lg-cover {\n -o-object-fit: cover !important;\n object-fit: cover !important;\n }\n .object-fit-lg-fill {\n -o-object-fit: fill !important;\n object-fit: fill !important;\n }\n .object-fit-lg-scale {\n -o-object-fit: scale-down !important;\n object-fit: scale-down !important;\n }\n .object-fit-lg-none {\n -o-object-fit: none !important;\n object-fit: none !important;\n }\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-right: 0 !important;\n }\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n .me-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n .ms-lg-auto {\n margin-left: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n .gap-lg-0 {\n gap: 0 !important;\n }\n .gap-lg-1 {\n gap: 0.25rem !important;\n }\n .gap-lg-2 {\n gap: 0.5rem !important;\n }\n .gap-lg-3 {\n gap: 1rem !important;\n }\n .gap-lg-4 {\n gap: 1.5rem !important;\n }\n .gap-lg-5 {\n gap: 3rem !important;\n }\n .row-gap-lg-0 {\n row-gap: 0 !important;\n }\n .row-gap-lg-1 {\n row-gap: 0.25rem !important;\n }\n .row-gap-lg-2 {\n row-gap: 0.5rem !important;\n }\n .row-gap-lg-3 {\n row-gap: 1rem !important;\n }\n .row-gap-lg-4 {\n row-gap: 1.5rem !important;\n }\n .row-gap-lg-5 {\n row-gap: 3rem !important;\n }\n .column-gap-lg-0 {\n -moz-column-gap: 0 !important;\n column-gap: 0 !important;\n }\n .column-gap-lg-1 {\n -moz-column-gap: 0.25rem !important;\n column-gap: 0.25rem !important;\n }\n .column-gap-lg-2 {\n -moz-column-gap: 0.5rem !important;\n column-gap: 0.5rem !important;\n }\n .column-gap-lg-3 {\n -moz-column-gap: 1rem !important;\n column-gap: 1rem !important;\n }\n .column-gap-lg-4 {\n -moz-column-gap: 1.5rem !important;\n column-gap: 1.5rem !important;\n }\n .column-gap-lg-5 {\n -moz-column-gap: 3rem !important;\n column-gap: 3rem !important;\n }\n .text-lg-start {\n text-align: left !important;\n }\n .text-lg-end {\n text-align: right !important;\n }\n .text-lg-center {\n text-align: center !important;\n }\n}\n@media (min-width: 1200px) {\n .float-xl-start {\n float: left !important;\n }\n .float-xl-end {\n float: right !important;\n }\n .float-xl-none {\n float: none !important;\n }\n .object-fit-xl-contain {\n -o-object-fit: contain !important;\n object-fit: contain !important;\n }\n .object-fit-xl-cover {\n -o-object-fit: cover !important;\n object-fit: cover !important;\n }\n .object-fit-xl-fill {\n -o-object-fit: fill !important;\n object-fit: fill !important;\n }\n .object-fit-xl-scale {\n -o-object-fit: scale-down !important;\n object-fit: scale-down !important;\n }\n .object-fit-xl-none {\n -o-object-fit: none !important;\n object-fit: none !important;\n }\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-right: 0 !important;\n }\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n .me-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n .ms-xl-auto {\n margin-left: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n .gap-xl-0 {\n gap: 0 !important;\n }\n .gap-xl-1 {\n gap: 0.25rem !important;\n }\n .gap-xl-2 {\n gap: 0.5rem !important;\n }\n .gap-xl-3 {\n gap: 1rem !important;\n }\n .gap-xl-4 {\n gap: 1.5rem !important;\n }\n .gap-xl-5 {\n gap: 3rem !important;\n }\n .row-gap-xl-0 {\n row-gap: 0 !important;\n }\n .row-gap-xl-1 {\n row-gap: 0.25rem !important;\n }\n .row-gap-xl-2 {\n row-gap: 0.5rem !important;\n }\n .row-gap-xl-3 {\n row-gap: 1rem !important;\n }\n .row-gap-xl-4 {\n row-gap: 1.5rem !important;\n }\n .row-gap-xl-5 {\n row-gap: 3rem !important;\n }\n .column-gap-xl-0 {\n -moz-column-gap: 0 !important;\n column-gap: 0 !important;\n }\n .column-gap-xl-1 {\n -moz-column-gap: 0.25rem !important;\n column-gap: 0.25rem !important;\n }\n .column-gap-xl-2 {\n -moz-column-gap: 0.5rem !important;\n column-gap: 0.5rem !important;\n }\n .column-gap-xl-3 {\n -moz-column-gap: 1rem !important;\n column-gap: 1rem !important;\n }\n .column-gap-xl-4 {\n -moz-column-gap: 1.5rem !important;\n column-gap: 1.5rem !important;\n }\n .column-gap-xl-5 {\n -moz-column-gap: 3rem !important;\n column-gap: 3rem !important;\n }\n .text-xl-start {\n text-align: left !important;\n }\n .text-xl-end {\n text-align: right !important;\n }\n .text-xl-center {\n text-align: center !important;\n }\n}\n@media (min-width: 1400px) {\n .float-xxl-start {\n float: left !important;\n }\n .float-xxl-end {\n float: right !important;\n }\n .float-xxl-none {\n float: none !important;\n }\n .object-fit-xxl-contain {\n -o-object-fit: contain !important;\n object-fit: contain !important;\n }\n .object-fit-xxl-cover {\n -o-object-fit: cover !important;\n object-fit: cover !important;\n }\n .object-fit-xxl-fill {\n -o-object-fit: fill !important;\n object-fit: fill !important;\n }\n .object-fit-xxl-scale {\n -o-object-fit: scale-down !important;\n object-fit: scale-down !important;\n }\n .object-fit-xxl-none {\n -o-object-fit: none !important;\n object-fit: none !important;\n }\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n .me-xxl-auto {\n margin-right: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n .gap-xxl-0 {\n gap: 0 !important;\n }\n .gap-xxl-1 {\n gap: 0.25rem !important;\n }\n .gap-xxl-2 {\n gap: 0.5rem !important;\n }\n .gap-xxl-3 {\n gap: 1rem !important;\n }\n .gap-xxl-4 {\n gap: 1.5rem !important;\n }\n .gap-xxl-5 {\n gap: 3rem !important;\n }\n .row-gap-xxl-0 {\n row-gap: 0 !important;\n }\n .row-gap-xxl-1 {\n row-gap: 0.25rem !important;\n }\n .row-gap-xxl-2 {\n row-gap: 0.5rem !important;\n }\n .row-gap-xxl-3 {\n row-gap: 1rem !important;\n }\n .row-gap-xxl-4 {\n row-gap: 1.5rem !important;\n }\n .row-gap-xxl-5 {\n row-gap: 3rem !important;\n }\n .column-gap-xxl-0 {\n -moz-column-gap: 0 !important;\n column-gap: 0 !important;\n }\n .column-gap-xxl-1 {\n -moz-column-gap: 0.25rem !important;\n column-gap: 0.25rem !important;\n }\n .column-gap-xxl-2 {\n -moz-column-gap: 0.5rem !important;\n column-gap: 0.5rem !important;\n }\n .column-gap-xxl-3 {\n -moz-column-gap: 1rem !important;\n column-gap: 1rem !important;\n }\n .column-gap-xxl-4 {\n -moz-column-gap: 1.5rem !important;\n column-gap: 1.5rem !important;\n }\n .column-gap-xxl-5 {\n -moz-column-gap: 3rem !important;\n column-gap: 3rem !important;\n }\n .text-xxl-start {\n text-align: left !important;\n }\n .text-xxl-end {\n text-align: right !important;\n }\n .text-xxl-center {\n text-align: center !important;\n }\n}\n@media (min-width: 1200px) {\n .fs-1 {\n font-size: 2.5rem !important;\n }\n .fs-2 {\n font-size: 2rem !important;\n }\n .fs-3 {\n font-size: 1.75rem !important;\n }\n .fs-4 {\n font-size: 1.5rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap.css.map */","// stylelint-disable scss/dimension-no-non-numeric-values\n\n// SCSS RFS mixin\n//\n// Automated responsive values for font sizes, paddings, margins and much more\n//\n// Licensed under MIT (https://github.com/twbs/rfs/blob/main/LICENSE)\n\n// Configuration\n\n// Base value\n$rfs-base-value: 1.25rem !default;\n$rfs-unit: rem !default;\n\n@if $rfs-unit != rem and $rfs-unit != px {\n @error \"`#{$rfs-unit}` is not a valid unit for $rfs-unit. Use `px` or `rem`.\";\n}\n\n// Breakpoint at where values start decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n@if $rfs-breakpoint-unit != px and $rfs-breakpoint-unit != em and $rfs-breakpoint-unit != rem {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n}\n\n// Resize values based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != number or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Mode. Possibilities: \"min-media-query\", \"max-media-query\"\n$rfs-mode: min-media-query !default;\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-rfs to false\n$enable-rfs: true !default;\n\n// Cache $rfs-base-value unit\n$rfs-base-value-unit: unit($rfs-base-value);\n\n@function divide($dividend, $divisor, $precision: 10) {\n $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);\n $dividend: abs($dividend);\n $divisor: abs($divisor);\n @if $dividend == 0 {\n @return 0;\n }\n @if $divisor == 0 {\n @error \"Cannot divide by 0\";\n }\n $remainder: $dividend;\n $result: 0;\n $factor: 10;\n @while ($remainder > 0 and $precision >= 0) {\n $quotient: 0;\n @while ($remainder >= $divisor) {\n $remainder: $remainder - $divisor;\n $quotient: $quotient + 1;\n }\n $result: $result * 10 + $quotient;\n $factor: $factor * .1;\n $remainder: $remainder * 10;\n $precision: $precision - 1;\n @if ($precision < 0 and $remainder >= $divisor * 5) {\n $result: $result + 1;\n }\n }\n $result: $result * $factor * $sign;\n $dividend-unit: unit($dividend);\n $divisor-unit: unit($divisor);\n $unit-map: (\n \"px\": 1px,\n \"rem\": 1rem,\n \"em\": 1em,\n \"%\": 1%\n );\n @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {\n $result: $result * map-get($unit-map, $dividend-unit);\n }\n @return $result;\n}\n\n// Remove px-unit from $rfs-base-value for calculations\n@if $rfs-base-value-unit == px {\n $rfs-base-value: divide($rfs-base-value, $rfs-base-value * 0 + 1);\n}\n@else if $rfs-base-value-unit == rem {\n $rfs-base-value: divide($rfs-base-value, divide($rfs-base-value * 0 + 1, $rfs-rem-value));\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == px {\n $rfs-breakpoint: divide($rfs-breakpoint, $rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == rem or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: divide($rfs-breakpoint, divide($rfs-breakpoint * 0 + 1, $rfs-rem-value));\n}\n\n// Calculate the media query value\n$rfs-mq-value: if($rfs-breakpoint-unit == px, #{$rfs-breakpoint}px, #{divide($rfs-breakpoint, $rfs-rem-value)}#{$rfs-breakpoint-unit});\n$rfs-mq-property-width: if($rfs-mode == max-media-query, max-width, min-width);\n$rfs-mq-property-height: if($rfs-mode == max-media-query, max-height, min-height);\n\n// Internal mixin used to determine which media query needs to be used\n@mixin _rfs-media-query {\n @if $rfs-two-dimensional {\n @if $rfs-mode == max-media-query {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}), (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) and (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) {\n @content;\n }\n }\n}\n\n// Internal mixin that adds disable classes to the selector if needed.\n@mixin _rfs-rule {\n @if $rfs-class == disable and $rfs-mode == max-media-query {\n // Adding an extra class increases specificity, which prevents the media query to override the property\n &,\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @else if $rfs-class == enable and $rfs-mode == min-media-query {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Internal mixin that adds enable classes to the selector if needed.\n@mixin _rfs-media-query-rule {\n\n @if $rfs-class == enable {\n @if $rfs-mode == min-media-query {\n @content;\n }\n\n @include _rfs-media-query () {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n }\n }\n @else {\n @if $rfs-class == disable and $rfs-mode == min-media-query {\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @include _rfs-media-query () {\n @content;\n }\n }\n}\n\n// Helper function to get the formatted non-responsive value\n@function rfs-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n }\n @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n @if $unit == px {\n // Convert to rem if needed\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $value * 0 + $rfs-rem-value)}rem, $value);\n }\n @else if $unit == rem {\n // Convert to px if needed\n $val: $val + \" \" + if($rfs-unit == px, #{divide($value, $value * 0 + 1) * $rfs-rem-value}px, $value);\n } @else {\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n $val: $val + \" \" + $value;\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// Helper function to get the responsive value calculated by RFS\n@function rfs-fluid-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n } @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $unit or $unit != px and $unit != rem {\n $val: $val + \" \" + $value;\n } @else {\n // Remove unit from $value for calculations\n $value: divide($value, $value * 0 + if($unit == px, 1, divide(1, $rfs-rem-value)));\n\n // Only add the media query if the value is greater than the minimum value\n @if abs($value) <= $rfs-base-value or not $enable-rfs {\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $rfs-rem-value)}rem, #{$value}px);\n }\n @else {\n // Calculate the minimum value\n $value-min: $rfs-base-value + divide(abs($value) - $rfs-base-value, $rfs-factor);\n\n // Calculate difference between $value and the minimum value\n $value-diff: abs($value) - $value-min;\n\n // Base value formatting\n $min-width: if($rfs-unit == rem, #{divide($value-min, $rfs-rem-value)}rem, #{$value-min}px);\n\n // Use negative value if needed\n $min-width: if($value < 0, -$min-width, $min-width);\n\n // Use `vmin` if two-dimensional is enabled\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{divide($value-diff * 100, $rfs-breakpoint)}#{$variable-unit};\n\n // Return the calculated value\n $val: $val + \" calc(\" + $min-width + if($value < 0, \" - \", \" + \") + $variable-width + \")\";\n }\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// RFS mixin\n@mixin rfs($values, $property: font-size) {\n @if $values != null {\n $val: rfs-value($values);\n $fluid-val: rfs-fluid-value($values);\n\n // Do not print the media query if responsive & non-responsive values are the same\n @if $val == $fluid-val {\n #{$property}: $val;\n }\n @else {\n @include _rfs-rule () {\n #{$property}: if($rfs-mode == max-media-query, $val, $fluid-val);\n\n // Include safari iframe resize fix if needed\n min-width: if($rfs-safari-iframe-resize-bug-fix, (0 * 1vw), null);\n }\n\n @include _rfs-media-query-rule () {\n #{$property}: if($rfs-mode == max-media-query, $fluid-val, $val);\n }\n }\n }\n}\n\n// Shorthand helper mixins\n@mixin font-size($value) {\n @include rfs($value);\n}\n\n@mixin padding($value) {\n @include rfs($value, padding);\n}\n\n@mixin padding-top($value) {\n @include rfs($value, padding-top);\n}\n\n@mixin padding-right($value) {\n @include rfs($value, padding-right);\n}\n\n@mixin padding-bottom($value) {\n @include rfs($value, padding-bottom);\n}\n\n@mixin padding-left($value) {\n @include rfs($value, padding-left);\n}\n\n@mixin margin($value) {\n @include rfs($value, margin);\n}\n\n@mixin margin-top($value) {\n @include rfs($value, margin-top);\n}\n\n@mixin margin-right($value) {\n @include rfs($value, margin-right);\n}\n\n@mixin margin-bottom($value) {\n @include rfs($value, margin-bottom);\n}\n\n@mixin margin-left($value) {\n @include rfs($value, margin-left);\n}\n","// scss-docs-start color-mode-mixin\n@mixin color-mode($mode: light, $root: false) {\n @if $color-mode-type == \"media-query\" {\n @if $root == true {\n @media (prefers-color-scheme: $mode) {\n :root {\n @content;\n }\n }\n } @else {\n @media (prefers-color-scheme: $mode) {\n @content;\n }\n }\n } @else {\n [data-bs-theme=\"#{$mode}\"] {\n @content;\n }\n }\n}\n// scss-docs-end color-mode-mixin\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n @include font-size(var(--#{$prefix}root-font-size));\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$prefix}body-font-family);\n @include font-size(var(--#{$prefix}body-font-size));\n font-weight: var(--#{$prefix}body-font-weight);\n line-height: var(--#{$prefix}body-line-height);\n color: var(--#{$prefix}body-color);\n text-align: var(--#{$prefix}body-text-align);\n background-color: var(--#{$prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n border: 0;\n border-top: $hr-border-width solid $hr-border-color;\n opacity: $hr-opacity;\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

      `-`

      ` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: var(--#{$prefix}heading-color);\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

      `s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 2. Add explicit cursor to indicate changed behavior.\n// 3. Prevent the text-decoration to be skipped.\n\nabbr[title] {\n text-decoration: underline dotted; // 1\n cursor: help; // 2\n text-decoration-skip-ink: none; // 3\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n color: var(--#{$prefix}highlight-color);\n background-color: var(--#{$prefix}highlight-bg);\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, 1));\n text-decoration: $link-decoration;\n\n &:hover {\n --#{$prefix}link-color-rgb: var(--#{$prefix}link-hover-color-rgb);\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: var(--#{$prefix}code-color);\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `

- {%if allow_scan %}{% endif %} + {% endif %} {% endfor %}
PlaylistTracksPublic
PlaylistTracksPublicExportDelete
+
-
` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-` {% endblock %} diff --git a/supysonic/templates/adduser.html b/supysonic/templates/adduser.html index 07d4448a..dbd9954d 100644 --- a/supysonic/templates/adduser.html +++ b/supysonic/templates/adduser.html @@ -3,52 +3,53 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_users %} -
  • Users (current)
  • +Users {% endblock %} {% block body %} - -
    -
    - -
    -
    - - - - - -
    +

    Add User

    + +
    + + + + + + + + +
    +
    -
    - -
    -
    - -
    +
    + + + + + +
    -
    - -
    -
    - -
    +
    + + + + + +
    -
    - -
    -
    - -
    +
    + + + + + +
    - + {% endblock %} diff --git a/supysonic/templates/change_mail.html b/supysonic/templates/change_mail.html index 4f6b0176..ce3c3d72 100644 --- a/supysonic/templates/change_mail.html +++ b/supysonic/templates/change_mail.html @@ -3,39 +3,36 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_users %} {% if request.user.id != user.id %} -
  • Users (current)
  • +Users {% else %} {{ super() }} {% endif %} {% endblock %} {% block navbar_profile %} {% if request.user.id == user.id %} -
  • {{ request.user.name }} (current)
  • +{{ request.user.name }} {% else %} {{ super() }} {% endif %} {% endblock %} {% block body %} - +

    {{ user.name }}

    -
    - -
    -
    - -
    +
    + + + + + +
    - + {% endblock %} - diff --git a/supysonic/templates/change_pass.html b/supysonic/templates/change_pass.html index 1ce3e93e..91e0624f 100644 --- a/supysonic/templates/change_pass.html +++ b/supysonic/templates/change_pass.html @@ -3,54 +3,54 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_users %} {% if request.user.id != user.id %} -
  • Users (current)
  • +Users {% else %} {{ super() }} {% endif %} {% endblock %} {% block navbar_profile %} {% if request.user.id == user.id %} -
  • {{ request.user.name }} (current)
  • +{{ request.user.name }} {% else %} {{ super() }} {% endif %} {% endblock %} {% block body %} - +

    {{ user.name }}

    {% if request.user.id == user.id %} -
    - -
    -
    - -
    +
    + + + + + +
    {% endif %} -
    - -
    -
    - -
    +
    + + + + + +
    -
    - -
    -
    - -
    +
    + + + + + +
    - + {% endblock %} diff --git a/supysonic/templates/change_username.html b/supysonic/templates/change_username.html index 3bc664d1..4debc8c2 100644 --- a/supysonic/templates/change_username.html +++ b/supysonic/templates/change_username.html @@ -9,26 +9,24 @@ -#} {% extends "layout.html" %} {% block navbar_users %} -
  • Users (current)
  • +Users {% endblock %} {% block body %} - +

    {{ user.name }}

    -
    - -
    -
    - - - - - -
    +
    + + + + + + + + +
    +
    - + {% endblock %} diff --git a/supysonic/templates/folders.html b/supysonic/templates/folders.html index b226f97e..4a48b09e 100644 --- a/supysonic/templates/folders.html +++ b/supysonic/templates/folders.html @@ -3,54 +3,54 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2019 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_folders %} -
  • Folders (current)
  • +Folders {% endblock %} {% block body %} - - +

    Music folders

    +
    {% if allow_scan %}{% endif %} {% for folder in folders %} - - {%if allow_scan %}{% endif %} + + + {%if allow_scan %}{% endif %} {% endfor %}
    NamePath
    {{ folder.name }}{{ folder.path }} - - {{ folder.name }}{{ folder.path }} +
    - - +
    {% endblock %} diff --git a/supysonic/templates/home.html b/supysonic/templates/home.html index 4efed799..fb0841aa 100644 --- a/supysonic/templates/home.html +++ b/supysonic/templates/home.html @@ -3,80 +3,82 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_index %} -
  • Home (current)
  • +Home {% endblock %} {% block body %} - +

    Welcome to Supysonic!

    There's nothing much to see here. If you want to listen to some music, you'll have to use one of the available clients. Here's a small list of clients that have been tested.

    -
    -
    -
    -
    -

    Web-based / Chrome extentions

    +
    +
    +
    +
    + Web-based
    -
    -

    Jamstash. Also available - as a Chrome - extension. There's a bug though, you'll have to click the - Save button in the settings each time you open it

    -

    Perisonic. Also as - a Chrome - extension if you just want to go full random.

    +
    -
    -
    -
    -

    Android apps

    +
    +
    +
    + Android apps
    -
    -

    DSub

    -

    UltraSonic

    +
    +
    -

    For a more complete list, check the Subsonic website.

    -
    -
    -
    -

    Stats

    -
    -
    -
    -
    -
    -

    {{ stats.artists }}

    -
    -

    artists

    -
    -
    -
    -

    {{ stats.albums }}

    -
    -

    albums

    +

    For a more complete list, check the Subsonic website.

    +
    +
    +
    +
    + Stats
    -
    -
    -

    {{ stats.tracks }}

    +
    +
    +
    +
    +
    +

    {{ stats.artists }}

    +
    + artists +
    +
    +
    +
    +
    +

    {{ stats.albums }}

    +
    + albums +
    +
    +
    +
    +
    +

    {{ stats.tracks }}

    +
    + tracks +
    +
    -

    tracks

    diff --git a/supysonic/templates/layout.html b/supysonic/templates/layout.html index c382c3fb..eda3c972 100644 --- a/supysonic/templates/layout.html +++ b/supysonic/templates/layout.html @@ -3,7 +3,7 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2021 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} @@ -15,89 +15,102 @@ Supysonic - + + - -
    {% if get_flashed_messages() %} - +
    + + + + + diff --git a/supysonic/templates/login.html b/supysonic/templates/login.html index 29b1b394..92a7d3fc 100644 --- a/supysonic/templates/login.html +++ b/supysonic/templates/login.html @@ -9,32 +9,36 @@ -#} {% extends "layout.html" %} {% block body %} -
    -
    -
    -
    Log in
    -
    -
    -
    -
    - -
    -
    - -
    +
    +
    +
    Please log in
    +
    + +
    + + + + + +
    -
    - -
    -
    - -
    +
    + + + + + +
    -
    - -
    - -
    +
    +
    diff --git a/supysonic/templates/playlist.html b/supysonic/templates/playlist.html index 8541ea8f..0f63d409 100644 --- a/supysonic/templates/playlist.html +++ b/supysonic/templates/playlist.html @@ -3,31 +3,28 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_playlists %} -
  • Playlists (current)
  • +Playlists {% endblock %} {% block body %} - +

    Playlist "{{ playlist.name }}"

    {% if playlist.user.id == request.user.id %}

    Edit

    - +
    - - + +
    NamePublic
    diff --git a/supysonic/templates/playlist_export.m3u b/supysonic/templates/playlist_export.m3u index c56d9ef0..fc5eaa2f 100644 --- a/supysonic/templates/playlist_export.m3u +++ b/supysonic/templates/playlist_export.m3u @@ -3,10 +3,10 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% for t in playlist.get_tracks() %} {{ t.path }} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/supysonic/templates/playlists.html b/supysonic/templates/playlists.html index 8e5c4792..612c7268 100644 --- a/supysonic/templates/playlists.html +++ b/supysonic/templates/playlists.html @@ -3,23 +3,20 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_playlists %} -
  • Playlists (current)
  • +Playlists {% endblock %} {% block body %} - +

    My playlists

    {% if not mine.count() %}

    You don't have any playlists.

    {% else %} - +
    @@ -28,24 +25,29 @@

    My playlists

    - - - + + + {% endfor %}
    PlaylistTracksPublicExportDelete
    {{ p.name }} {{ p.get_tracks()|length }}{% if p.public %}{% else %}{% endif %}{% if p.public %} + + + {% else %} + + {% endif %}
    {% endif %} {% if others.count() %} - - +

    Others' playlists

    +
    @@ -60,41 +62,41 @@

    Others' playlists

    PlaylistOwnerTracks
    {% endif %} - +
    +
    {% endblock %} diff --git a/supysonic/templates/profile.html b/supysonic/templates/profile.html index 6be53798..60176ef3 100644 --- a/supysonic/templates/profile.html +++ b/supysonic/templates/profile.html @@ -3,125 +3,82 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_users %} {% if request.user.id != user.id %} -
  • Users (current)
  • +Users {% else %} {{ super() }} {% endif %} {% endblock %} {% block navbar_profile %} {% if request.user.id == user.id %} -
  • {{ request.user.name }} (current)
  • +{{ request.user.name }} {% else %} {{ super() }} {% endif %} {% endblock %} {% block body %} - -
    +

    {{ user.name }}{% if user.admin %} + +{% endif %}

    +
    -
    - -
    -
    User eMail
    - -
    - {% if request.user.id == user.id %} - Change eMail - {% else %} - Change eMail - {% endif %} -
    -
    +
    + User eMail + + Change eMail
    -
    - -
    -
    LastFM status
    - {% if api_key != None %} - {% if user.lastfm_session %} - -
    - {% if request.user.id == user.id %} - Unlink - {% else %} - Unlink - {% endif %} -
    - {% else %} - -
    - {% if request.user.id == user.id %} - Link - {% else %} - Link - {% endif %} -
    - {% endif %} - {% else %} - - {% endif %} -
    +
    + LastFM status + {% if api_key != None %} + {% if user.lastfm_session %} + + Unlink + {% else %} + {% endif %} + + Link + {% else %} + + {% endif %}
    +
    +
    -
    - -
    -
    ListenBrainz status
    - {% if user.listenbrainz_session %} - -
    - {% if request.user.id == user.id %} - Unlink - {% else %} - Unlink - {% endif %} -
    - {% else %} - -
    - {% if request.user.id == user.id %} - - {% else %} - - {% endif %} -
    - {% endif %} -
    +
    + ListenBrainz status + {% if user.listenbrainz_session %} + + Unlink + {% else %} + + + {% endif %}
    -
    {% if request.user.id == user.id %} -Change password +Change password {% else %} -Change username or admin status -Change password +Change username or admin status +Change password {% endif %} {% if clients.count() %} - +

    Clients

    Here's a list of clients you used to stream music. If you want to use transcoding or downsampling with one of them (for instance using a low bitrate on mobile connections to reduce used bandwidth), but the client doesn't provide @@ -136,7 +93,7 @@

    Clients

    their original format, only transcoded if their bitrate exceed the selected one.

    - +
    @@ -161,7 +118,7 @@

    Clients

    {% endfor %}
    ClientFormatMax bitrateForget
    - +
    {% endif %} {% endblock %} diff --git a/supysonic/templates/users.html b/supysonic/templates/users.html index 13c91ad8..c40c6672 100644 --- a/supysonic/templates/users.html +++ b/supysonic/templates/users.html @@ -3,20 +3,17 @@ Supysonic is a Python implementation of the Subsonic server API. Copyright (C) 2013-2018 Alban 'spl0k' Féron - 2017 Óscar García Amor + 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. -#} {% extends "layout.html" %} {% block navbar_users %} -
  • Users (current)
  • +Users {% endblock %} {% block body %} - - +

    Users

    +
    @@ -26,29 +23,30 @@

    Users

    + {% if request.user.id != user.id %}{% endif %} {% endfor %}
    NameEMailAdminLast play date
    {% if request.user.id == user.id %}{{ user.name }}{% else %} {{ user.name }}{% endif %} {{ user.mail }}{{ user.admin }}{{ user.last_play_date }} - {% if request.user.id != user.id %}{% endif %}
    - - +
    {% endblock %} From ee9cb7a66d9674ba30aca6cd35ad5cffffee7f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Sun, 1 Dec 2024 21:42:47 +0100 Subject: [PATCH 191/237] Fixes unit tests --- tests/frontend/test_folder.py | 6 +++--- tests/frontend/test_user.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/frontend/test_folder.py b/tests/frontend/test_folder.py index d3828853..3dab5d2a 100644 --- a/tests/frontend/test_folder.py +++ b/tests/frontend/test_folder.py @@ -28,12 +28,12 @@ def test_add_get(self): self._login("bob", "B0b") rv = self.client.get("/folder/add", follow_redirects=True) self.assertIn("There's nothing much to see", rv.data) - self.assertNotIn("Add Folder", rv.data) + self.assertNotIn("Add new folder", rv.data) self._logout() self._login("alice", "Alic3") rv = self.client.get("/folder/add") - self.assertIn("Add Folder", rv.data) + self.assertIn("Add new folder", rv.data) def test_add_post(self): self._login("alice", "Alic3") @@ -44,7 +44,7 @@ def test_add_post(self): rv = self.client.post("/folder/add", data={"path": "path"}) self.assertIn("required", rv.data) rv = self.client.post("/folder/add", data={"name": "name", "path": "path"}) - self.assertIn("Add Folder", rv.data) + self.assertIn("Add new folder", rv.data) rv = self.client.post( "/folder/add", data={"name": "name", "path": "tests/assets"}, diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index 26922a57..245a128f 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -49,7 +49,7 @@ def test_details(self): self.assertIn("There's nothing much to see", rv.data) self.assertNotIn("

    bob

    ", rv.data) rv = self.client.get("/user/me") - self.assertIn("

    bob

    ", rv.data) + self.assertIn("

    bob

    ", rv.data) self.assertIn("tests", rv.data) def test_update_client_prefs(self): From 09920bc7068f4db28dcbf0906055903f5979adb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 2 Dec 2024 10:15:05 +0100 Subject: [PATCH 192/237] Fixes changemail POST --- supysonic/frontend/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index 1e329b39..466b4e03 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -182,6 +182,7 @@ def change_mail_post(uid, user): mail = request.form.get("mail", "") # No validation, lol. user.mail = mail + user.save() return redirect(url_for("frontend.user_profile", uid=uid)) From 3911da475a9f37cba98295d1f51a70101a6811e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 2 Dec 2024 12:10:01 +0100 Subject: [PATCH 193/237] Adds a fancy theme selector --- supysonic/static/js/supysonic.js | 27 +++++++++++++++++++++++++++ supysonic/templates/layout.html | 32 +++++++++++++++++++------------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/supysonic/static/js/supysonic.js b/supysonic/static/js/supysonic.js index 7bc3196c..f4d9b118 100644 --- a/supysonic/static/js/supysonic.js +++ b/supysonic/static/js/supysonic.js @@ -22,3 +22,30 @@ document.querySelectorAll('.modal').forEach(function (modal) { }, { once: true }); }); }); + +function setTheme(theme) { + if (theme === 'auto') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + document.body.setAttribute('data-bs-theme', systemTheme); + } else { + document.body.setAttribute('data-bs-theme', theme); + } +} + +const savedTheme = localStorage.getItem('theme') || 'light'; +document.querySelector(`input[value="${savedTheme}"]`).checked = true; +setTheme(savedTheme); + +document.querySelectorAll('input[name="theme"]').forEach(function (radio) { + radio.addEventListener('change', function () { + const selectedTheme = this.value; + localStorage.setItem('theme', selectedTheme); + setTheme(selectedTheme); + }); +}); + +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { + if (localStorage.getItem('theme') === 'auto') { + setTheme('auto'); + } +}); diff --git a/supysonic/templates/layout.html b/supysonic/templates/layout.html index eda3c972..3d2a7b09 100644 --- a/supysonic/templates/layout.html +++ b/supysonic/templates/layout.html @@ -57,12 +57,30 @@
    + {%if allow_scan %}
    Add new folder -{% if allow_scan %}Scan all folders{% endif %} +{% if allow_scan %}
    {% endif %} diff --git a/supysonic/templates/playlists.html b/supysonic/templates/playlists.html index aa28ff83..33c0c20d 100644 --- a/supysonic/templates/playlists.html +++ b/supysonic/templates/playlists.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -98,7 +98,9 @@

    Are you sure?

    diff --git a/supysonic/templates/profile.html b/supysonic/templates/profile.html index 63a56f4b..634ed730 100644 --- a/supysonic/templates/profile.html +++ b/supysonic/templates/profile.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -37,13 +37,13 @@

    {{ user.name }}{% if user.admin %}
    -
    +
    LastFM status {% if api_key != None %} {% if user.lastfm_session %} - Unlink + {% else %} Link @@ -57,15 +57,15 @@

    {{ user.name }}{% if user.admin %}
    - +
    ListenBrainz status {% if user.listenbrainz_session %} - Unlink + {% else %} - + {% endif %}
    diff --git a/supysonic/templates/users.html b/supysonic/templates/users.html index 2682b05d..dfd7c858 100644 --- a/supysonic/templates/users.html +++ b/supysonic/templates/users.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -46,7 +46,9 @@

    Are you sure?

    diff --git a/tests/frontend/test_folder.py b/tests/frontend/test_folder.py index 3dab5d2a..c150165a 100644 --- a/tests/frontend/test_folder.py +++ b/tests/frontend/test_folder.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2022 Alban 'spl0k' Féron +# Copyright (C) 2017-2026 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -57,17 +57,17 @@ def test_delete(self): folder = Folder.create(name="folder", path="tests/assets", root=True) self._login("bob", "B0b") - rv = self.client.get("/folder/del/" + str(folder.id), follow_redirects=True) + rv = self.client.post("/folder/del/" + str(folder.id), follow_redirects=True) self.assertIn("There's nothing much to see", rv.data) self.assertEqual(Folder.select().count(), 1) self._logout() self._login("alice", "Alic3") - rv = self.client.get("/folder/del/string", follow_redirects=True) + rv = self.client.post("/folder/del/string", follow_redirects=True) self.assertIn("Invalid folder id", rv.data) - rv = self.client.get("/folder/del/1234567890", follow_redirects=True) + rv = self.client.post("/folder/del/1234567890", follow_redirects=True) self.assertIn("No such folder", rv.data) - rv = self.client.get("/folder/del/" + str(folder.id), follow_redirects=True) + rv = self.client.post("/folder/del/" + str(folder.id), follow_redirects=True) self.assertIn("Music folders", rv.data) self.assertEqual(Folder.select().count(), 0) @@ -76,13 +76,13 @@ def test_scan(self): self._login("alice", "Alic3") - rv = self.client.get("/folder/scan/string", follow_redirects=True) + rv = self.client.post("/folder/scan/string", follow_redirects=True) self.assertIn("Invalid folder id", rv.data) - rv = self.client.get("/folder/scan/1234567890", follow_redirects=True) + rv = self.client.post("/folder/scan/1234567890", follow_redirects=True) self.assertIn("No such folder", rv.data) - rv = self.client.get("/folder/scan/" + str(folder.id), follow_redirects=True) + rv = self.client.post("/folder/scan/" + str(folder.id), follow_redirects=True) self.assertIn("start", rv.data) - rv = self.client.get("/folder/scan", follow_redirects=True) + rv = self.client.post("/folder/scan", follow_redirects=True) self.assertIn("start", rv.data) diff --git a/tests/frontend/test_playlist.py b/tests/frontend/test_playlist.py index 2416478a..c12ca05b 100644 --- a/tests/frontend/test_playlist.py +++ b/tests/frontend/test_playlist.py @@ -1,7 +1,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2022 Alban 'spl0k' Féron +# Copyright (C) 2017-2026 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -93,13 +93,13 @@ def test_update(self): def test_delete(self): self._login("bob", "B0b") - rv = self.client.get("/playlist/del/string", follow_redirects=True) + rv = self.client.post("/playlist/del/string", follow_redirects=True) self.assertIn("Invalid", rv.data) - rv = self.client.get( + rv = self.client.post( "/playlist/del/" + str(uuid.uuid4()), follow_redirects=True ) self.assertIn("Unknown", rv.data) - rv = self.client.get( + rv = self.client.post( "/playlist/del/" + str(self.playlistid), follow_redirects=True ) self.assertIn("not allowed", rv.data) @@ -107,7 +107,7 @@ def test_delete(self): self._logout() self._login("alice", "Alic3") - rv = self.client.get( + rv = self.client.post( "/playlist/del/" + str(self.playlistid), follow_redirects=True ) self.assertIn("deleted", rv.data) diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index fbfd3c59..aba55773 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -223,17 +223,17 @@ def test_delete(self): path = "/user/del/{}".format(self.users["bob"]) self._login("bob", "B0b") - rv = self.client.get(path, follow_redirects=True) + rv = self.client.post(path, follow_redirects=True) self.assertIn("There's nothing much to see", rv.data) self.assertEqual(User.select().count(), 2) self._logout() self._login("alice", "Alic3") - rv = self.client.get("/user/del/string", follow_redirects=True) + rv = self.client.post("/user/del/string", follow_redirects=True) self.assertIn("badly formed", rv.data) - rv = self.client.get("/user/del/" + str(uuid.uuid4()), follow_redirects=True) + rv = self.client.post("/user/del/" + str(uuid.uuid4()), follow_redirects=True) self.assertIn("No such user", rv.data) - rv = self.client.get(path, follow_redirects=True) + rv = self.client.post(path, follow_redirects=True) self.assertIn("Deleted", rv.data) self.assertEqual(User.select().count(), 1) self._logout() @@ -253,17 +253,17 @@ def test_lastfm_link(self): def test_lastfm_unlink(self): self._login("alice", "Alic3") - rv = self.client.get("/user/me/lastfm/unlink", follow_redirects=True) + rv = self.client.post("/user/me/lastfm/unlink", follow_redirects=True) self.assertIn("Unlinked", rv.data) def test_listenbrainz_unlink(self): self._login("alice", "Alic3") - rv = self.client.get("/user/me/listenbrainz/unlink", follow_redirects=True) + rv = self.client.post("/user/me/listenbrainz/unlink", follow_redirects=True) self.assertIn("Unlinked", rv.data) def test_listenbrainz_link(self): self._login("alice", "Alic3") - rv = self.client.get("/user/me/listenbrainz/link", follow_redirects=True) + rv = self.client.post("/user/me/listenbrainz/link", follow_redirects=True) self.assertIn("Missing ListenBrainz auth token", rv.data) # Invalid token: ListenBrainz reports it, the error is flashed back @@ -272,9 +272,9 @@ def test_listenbrainz_link(self): resp.raise_for_status.return_value = None resp.json.return_value = {"valid": False, "message": "bad token"} get.return_value = resp - rv = self.client.get( + rv = self.client.post( "/user/me/listenbrainz/link", - query_string={"token": "abcdef"}, + data={"token": "abcdef"}, follow_redirects=True, ) self.assertIn("Error: bad token", rv.data) @@ -285,9 +285,9 @@ def test_listenbrainz_link(self): resp.raise_for_status.return_value = None resp.json.return_value = {"valid": True} get.return_value = resp - rv = self.client.get( + rv = self.client.post( "/user/me/listenbrainz/link", - query_string={"token": "abcdef"}, + data={"token": "abcdef"}, follow_redirects=True, ) self.assertIn("Successfully linked", rv.data) From ce4f92be61a04595e1703a3d3a151b51e63fd09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 11 Jul 2026 15:20:13 +0200 Subject: [PATCH 224/237] security: add CSRF protection --- pyproject.toml | 1 + supysonic/templates/addfolder.html | 3 ++- supysonic/templates/adduser.html | 3 ++- supysonic/templates/change_mail.html | 3 ++- supysonic/templates/change_pass.html | 3 ++- supysonic/templates/change_username.html | 3 ++- supysonic/templates/folders.html | 22 +++++++++++++++++----- supysonic/templates/login.html | 3 ++- supysonic/templates/playlist.html | 3 ++- supysonic/templates/playlists.html | 1 + supysonic/templates/profile.html | 3 +++ supysonic/templates/users.html | 1 + supysonic/web.py | 7 +++++++ tests/testbase.py | 1 + 14 files changed, 45 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 81bd75a1..30587ca1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ dependencies = [ "click", "flask >=2.3", + "flask-wtf", "peewee", "Pillow >=9.1.0", "requests >=1.0.0", diff --git a/supysonic/templates/addfolder.html b/supysonic/templates/addfolder.html index d0a2fdc4..a74bffd3 100644 --- a/supysonic/templates/addfolder.html +++ b/supysonic/templates/addfolder.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -14,6 +14,7 @@ {% block body %}

    Add new folder

    +
    diff --git a/supysonic/templates/adduser.html b/supysonic/templates/adduser.html index dbd9954d..3d0a3773 100644 --- a/supysonic/templates/adduser.html +++ b/supysonic/templates/adduser.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -14,6 +14,7 @@ {% block body %}

    Add User

    +
    diff --git a/supysonic/templates/change_mail.html b/supysonic/templates/change_mail.html index ce3c3d72..986421ac 100644 --- a/supysonic/templates/change_mail.html +++ b/supysonic/templates/change_mail.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -25,6 +25,7 @@ {% block body %}

    {{ user.name }}

    +
    diff --git a/supysonic/templates/change_pass.html b/supysonic/templates/change_pass.html index 91e0624f..77abd016 100644 --- a/supysonic/templates/change_pass.html +++ b/supysonic/templates/change_pass.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017-2024 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -25,6 +25,7 @@ {% block body %}

    {{ user.name }}

    + {% if request.user.id == user.id %}
    diff --git a/supysonic/templates/change_username.html b/supysonic/templates/change_username.html index 4debc8c2..dc8a9b7e 100644 --- a/supysonic/templates/change_username.html +++ b/supysonic/templates/change_username.html @@ -2,7 +2,7 @@ This file is part of Supysonic. Supysonic is a Python implementation of the Subsonic server API. - Copyright (C) 2013-2018 Alban 'spl0k' Féron + Copyright (C) 2013-2026 Alban 'spl0k' Féron 2017 Óscar García Amor Distributed under terms of the GNU AGPLv3 license. @@ -14,6 +14,7 @@ {% block body %}

    {{ user.name }}

    +
    diff --git a/supysonic/templates/folders.html b/supysonic/templates/folders.html index 14da58dc..7dec04ac 100644 --- a/supysonic/templates/folders.html +++ b/supysonic/templates/folders.html @@ -27,17 +27,28 @@

    Music folders

    - {%if allow_scan %}{% endif %} + {%if allow_scan %} +
    + + +
    + {% endif %} {% endfor %}
    Add new folder -{% if allow_scan %}
    {% endif %} +{% if allow_scan %} +
    + + +
    +{% endif %}