├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml ├── actions │ ├── FFmpeg │ │ └── action.yml │ ├── get-tag │ │ └── action.yml │ ├── string-case │ │ └── action.yml │ └── yt-dlp │ │ └── action.yml ├── sh │ └── library │ │ └── variables.inc.sh └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── Pipfile ├── README.md ├── config └── root │ ├── docs │ └── index.html │ └── etc │ ├── nginx │ ├── nginx.conf │ └── token_server.conf │ └── s6-overlay │ └── s6-rc.d │ ├── gunicorn │ ├── dependencies │ ├── run │ └── type │ ├── nginx │ ├── dependencies │ ├── run │ └── type │ ├── tubesync-db-worker │ ├── dependencies │ ├── down-signal │ ├── run │ └── type │ ├── tubesync-fs-worker │ ├── dependencies │ ├── down-signal │ ├── run │ └── type │ ├── tubesync-init │ ├── dependancies │ ├── run │ ├── type │ └── up │ ├── tubesync-network-worker │ ├── dependencies │ ├── down-signal │ ├── run │ └── type │ └── user │ └── contents.d │ ├── gunicorn │ ├── nginx │ ├── tubesync-db-worker │ ├── tubesync-fs-worker │ ├── tubesync-init │ └── tubesync-network-worker ├── dev.env ├── docs ├── create-missing-metadata.md ├── custom-filters.md ├── dashboard-v0.5.png ├── import-existing-media.md ├── media-item-v0.5.png ├── media-v0.5.png ├── other-database-backends.md ├── reset-metadata.md ├── reset-tasks.md ├── source-v0.5.png ├── sources-v0.5.png └── using-cookies.md ├── patches ├── background_task │ ├── management │ │ └── commands │ │ │ └── process_tasks.py │ ├── models.py │ └── utils.py └── yt_dlp │ ├── patch │ ├── __init__.py │ ├── check_thumbnails.py │ └── fatal_http_errors.py │ └── postprocessor │ └── modify_chapters.py └── tubesync ├── common ├── __init__.py ├── admin.py ├── apps.py ├── context_processors.py ├── errors.py ├── json.py ├── logger.py ├── middleware.py ├── migrations │ └── __init__.py ├── models.py ├── static │ ├── fonts │ │ ├── fontawesome │ │ │ ├── fa-brands-400.eot │ │ │ ├── fa-brands-400.svg │ │ │ ├── fa-brands-400.ttf │ │ │ ├── fa-brands-400.woff │ │ │ ├── fa-brands-400.woff2 │ │ │ ├── fa-regular-400.eot │ │ │ ├── fa-regular-400.svg │ │ │ ├── fa-regular-400.ttf │ │ │ ├── fa-regular-400.woff │ │ │ ├── fa-regular-400.woff2 │ │ │ ├── fa-solid-900.eot │ │ │ ├── fa-solid-900.svg │ │ │ ├── fa-solid-900.ttf │ │ │ ├── fa-solid-900.woff │ │ │ └── fa-solid-900.woff2 │ │ └── roboto │ │ │ ├── roboto-bold.woff │ │ │ ├── roboto-light.woff │ │ │ └── roboto-regular.woff │ ├── images │ │ ├── favicon.ico │ │ ├── nothumb.png │ │ ├── tubesync-transparent.png │ │ └── tubesync.png │ └── styles │ │ ├── _colours.scss │ │ ├── _fonts.scss │ │ ├── _forms.scss │ │ ├── _helpers.scss │ │ ├── _template.scss │ │ ├── _variables.scss │ │ ├── fontawesome │ │ ├── _animated.scss │ │ ├── _bordered-pulled.scss │ │ ├── _core.scss │ │ ├── _fixed-width.scss │ │ ├── _icons.scss │ │ ├── _larger.scss │ │ ├── _list.scss │ │ ├── _mixins.scss │ │ ├── _rotated-flipped.scss │ │ ├── _screen-reader.scss │ │ ├── _shims.scss │ │ ├── _stacked.scss │ │ ├── _variables.scss │ │ ├── brands.scss │ │ ├── fontawesome.scss │ │ ├── regular.scss │ │ ├── solid.scss │ │ └── v4-shims.scss │ │ ├── materializecss │ │ ├── components │ │ │ ├── _badges.scss │ │ │ ├── _buttons.scss │ │ │ ├── _cards.scss │ │ │ ├── _carousel.scss │ │ │ ├── _chips.scss │ │ │ ├── _collapsible.scss │ │ │ ├── _color-classes.scss │ │ │ ├── _color-variables.scss │ │ │ ├── _datepicker.scss │ │ │ ├── _dropdown.scss │ │ │ ├── _global.scss │ │ │ ├── _grid.scss │ │ │ ├── _icons-material-design.scss │ │ │ ├── _materialbox.scss │ │ │ ├── _modal.scss │ │ │ ├── _navbar.scss │ │ │ ├── _normalize.scss │ │ │ ├── _preloader.scss │ │ │ ├── _pulse.scss │ │ │ ├── _sidenav.scss │ │ │ ├── _slider.scss │ │ │ ├── _table_of_contents.scss │ │ │ ├── _tabs.scss │ │ │ ├── _tapTarget.scss │ │ │ ├── _timepicker.scss │ │ │ ├── _toast.scss │ │ │ ├── _tooltip.scss │ │ │ ├── _transitions.scss │ │ │ ├── _typography.scss │ │ │ ├── _variables.scss │ │ │ ├── _waves.scss │ │ │ └── forms │ │ │ │ ├── _checkboxes.scss │ │ │ │ ├── _file-input.scss │ │ │ │ ├── _forms.scss │ │ │ │ ├── _input-fields.scss │ │ │ │ ├── _radio-buttons.scss │ │ │ │ ├── _range.scss │ │ │ │ ├── _select.scss │ │ │ │ └── _switches.scss │ │ └── materialize.scss │ │ └── tubesync.scss ├── templates │ ├── base.html │ ├── error403.html │ ├── error404.html │ ├── error500.html │ ├── errorbox.html │ ├── infobox.html │ ├── pagination.html │ ├── simpleform.html │ ├── tubesync-coloured.svg │ └── tubesync.svg ├── tests.py ├── testutils.py ├── third_party_versions.py ├── timestamp.py ├── urls.py ├── utils.py └── views.py ├── full_playlist.sh ├── healthcheck.py ├── manage.py ├── restart_services.sh ├── sync ├── __init__.py ├── admin.py ├── apps.py ├── choices.py ├── fields.py ├── filtering.py ├── forms.py ├── hooks.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── delete-source.py │ │ ├── fix-mariadb.py │ │ ├── import-existing-media.py │ │ ├── list-sources.py │ │ ├── reset-metadata.py │ │ ├── reset-tasks.py │ │ ├── sync-missing-metadata.py │ │ └── youtube-dl-info.py ├── matching.py ├── mediaservers.py ├── migrations │ ├── 0001_initial.py │ ├── 0001_squashed_0030_alter_source_source_vcodec.py │ ├── 0002_auto_20201213_0817.py │ ├── 0003_source_copy_thumbnails.py │ ├── 0004_source_media_format.py │ ├── 0005_auto_20201219_0312.py │ ├── 0006_source_write_nfo.py │ ├── 0007_auto_20201219_0645.py │ ├── 0008_source_download_cap.py │ ├── 0009_auto_20210218_0442.py │ ├── 0010_auto_20210924_0554.py │ ├── 0011_auto_20220201_1654.py │ ├── 0012_alter_media_downloaded_format.py │ ├── 0013_fix_elative_media_file.py │ ├── 0014_alter_media_media_file.py │ ├── 0015_auto_20230213_0603.py │ ├── 0016_auto_20230214_2052.py │ ├── 0017_alter_source_sponsorblock_categories.py │ ├── 0018_source_subtitles.py │ ├── 0019_add_delete_removed_media.py │ ├── 0020_auto_20231024_1825.py │ ├── 0021_source_copy_channel_images.py │ ├── 0022_add_delete_files_on_disk.py │ ├── 0023_media_duration_filter.py │ ├── 0024_auto_20240717_1535.py │ ├── 0025_add_video_type_support.py │ ├── 0026_alter_source_sub_langs.py │ ├── 0027_alter_source_sponsorblock_categories.py │ ├── 0028_alter_source_source_resolution.py │ ├── 0029_alter_mediaserver_fields.py │ ├── 0030_alter_source_source_vcodec.py │ ├── 0031_metadata_metadataformat.py │ ├── 0031_squashed_metadata_metadataformat.py │ ├── 0032_alter_metadata_options_alter_metadataformat_options_and_more.py │ ├── 0032_metadata_transfer.py │ ├── 0033_alter_mediaserver_options_alter_source_source_acodec_and_more.py │ ├── 0034_source_target_schedule_and_more.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── _migrations.py │ ├── _private.py │ ├── media.py │ ├── media__tasks.py │ ├── media_server.py │ ├── metadata.py │ ├── metadata_format.py │ └── source.py ├── overrides │ └── custom_filter.py ├── signals.py ├── tasks.py ├── templates │ ├── sync │ │ ├── _mediaformatvars.html │ │ ├── dashboard.html │ │ ├── media-enable.html │ │ ├── media-item.html │ │ ├── media-redownload.html │ │ ├── media-skip.html │ │ ├── media.html │ │ ├── mediaserver-add.html │ │ ├── mediaserver-delete.html │ │ ├── mediaserver-update.html │ │ ├── mediaserver.html │ │ ├── mediaservers.html │ │ ├── source-add.html │ │ ├── source-delete.html │ │ ├── source-update.html │ │ ├── source-validate.html │ │ ├── source.html │ │ ├── sources.html │ │ ├── task-schedule.html │ │ ├── tasks-completed.html │ │ ├── tasks-reset.html │ │ └── tasks.html │ └── widgets │ │ ├── checkbox_option.html │ │ └── checkbox_select.html ├── templatetags │ ├── __init__.py │ └── filters.py ├── testdata │ ├── README.md │ ├── metadata.json │ ├── metadata_2023-06-29.json │ ├── metadata_60fps.json │ ├── metadata_60fps_hdr.json │ ├── metadata_hdr.json │ └── metadata_low_formats.json ├── tests.py ├── urls.py ├── utils.py ├── views.py └── youtube.py ├── tubesync ├── __init__.py ├── asgi.py ├── dbutils.py ├── gunicorn.py ├── local_settings.py.container ├── local_settings.py.example ├── settings.py ├── urls.py └── wsgi.py └── upgrade_yt-dlp.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .github 4 | .gitattributes 5 | README.md 6 | tubesync/media 7 | tubesync/tubesync/downloads 8 | db.sqlite3 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # rc.d files should be unchanged 14 | [config/root/etc/s6-overlay/s6-rc.d/**] 15 | indent_style = unset 16 | indent_size = tab 17 | insert_final_newline = false 18 | trim_trailing_whitespace = false 19 | 20 | # Docstrings and comments use max_line_length = 79 21 | [*.py] 22 | max_line_length = 88 23 | 24 | # Use 2 spaces for the YAML files 25 | [*.y{,a}ml] 26 | indent_size = 2 27 | 28 | # Use 2 spaces for the HTML files 29 | [*.html] 30 | indent_size = 2 31 | 32 | # Use 2 spaces for the SCSS files 33 | [*.scss] 34 | indent_size = 2 35 | 36 | [**/admin/js/vendor/**] 37 | indent_style = unset 38 | indent_size = unset 39 | 40 | # Minified JavaScript files shouldn't be changed 41 | [**.min.js] 42 | indent_style = unset 43 | indent_size = unset 44 | insert_final_newline = false 45 | trim_trailing_whitespace = false 46 | 47 | # Makefiles always use tabs for indentation 48 | [Makefile] 49 | indent_style = tab 50 | 51 | # Batch files use tabs for indentation 52 | [*.bat] 53 | indent_style = tab 54 | 55 | [docs/**.txt] 56 | max_line_length = 79 57 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [meeb] 2 | -------------------------------------------------------------------------------- /.github/actions/get-tag/action.yml: -------------------------------------------------------------------------------- 1 | name: Get tag 2 | description: Get tag name from GITHUB_REF environment variable 3 | inputs: 4 | strip_v: 5 | required: false 6 | default: false 7 | description: Whether to strip "v" from the tag or not 8 | outputs: 9 | tag: 10 | value: ${{ steps.set.outputs.tag }} 11 | description: Git tag name 12 | 13 | runs: 14 | using: 'composite' 15 | steps: 16 | - name: Set outputs 17 | id: 'set' 18 | env: 19 | INPUT_STRIP_V: '${{ inputs.strip_v }}' 20 | shell: 'bash' 21 | run: | 22 | tag="${GITHUB_REF}" 23 | printf -- 'Manipulating string: %s\n' "${tag}" 24 | test -n "${tag}" || exit 1 25 | 26 | case "${tag}" in 27 | (refs/tags/*) tag="${tag#refs/tags/}" ;; 28 | (*) printf -- 'Not a tag ref\n' ; exit 2 ;; 29 | esac 30 | 31 | if [ 'true' = "${INPUT_STRIP_V,,}" ] 32 | then 33 | tag="${tag#[Vv]}" 34 | fi 35 | 36 | set_sl_var() { local f='%s=%s\n' ; printf -- "${f}" "$@" ; } ; 37 | 38 | set_sl_var tag "${tag}" >> "${GITHUB_OUTPUT}" 39 | 40 | set_sl_var 'tag ' " ${tag}" 41 | 42 | -------------------------------------------------------------------------------- /.github/actions/string-case/action.yml: -------------------------------------------------------------------------------- 1 | name: Change String Case 2 | description: Make a string lowercase, uppercase, or capitalized 3 | 4 | inputs: 5 | string: 6 | description: The input string 7 | required: true 8 | 9 | outputs: 10 | lowercase: 11 | value: ${{ steps.set.outputs.lowercase }} 12 | description: The input string, with any uppercase characters replaced with lowercase ones 13 | uppercase: 14 | value: ${{ steps.set.outputs.uppercase }} 15 | description: The input string, with any lowercase characters replaced with uppercase ones 16 | capitalized: 17 | value: ${{ steps.set.outputs.capitalized }} 18 | description: The input string, with any alphabetical characters lowercase, except for the first character, which is uppercased 19 | 20 | runs: 21 | using: 'composite' 22 | steps: 23 | - name: Set outputs 24 | id: 'set' 25 | env: 26 | INPUT_STRING: '${{ inputs.string }}' 27 | shell: 'bash' 28 | run: | 29 | printf -- 'Manipulating string: %s\n' "${INPUT_STRING}" 30 | set_sl_var() { local f='%s=%s\n' ; printf -- "${f}" "$@" ; } ; 31 | mk_delim() { printf -- '"%s_EOF_%d_"' "$1" "${RANDOM}" ; } ; 32 | open_ml_var() { local f=''\%'s<<'\%'s\n' ; printf -- "${f}" "$2" "$1" ; } ; 33 | close_ml_var() { local f='%s\n' ; printf -- "${f}" "$1" ; } ; 34 | { 35 | 36 | var='lowercase' ; 37 | delim="$(mk_delim "${var}")" ; 38 | open_ml_var "${delim}" "${var}" ; 39 | printf -- '%s\n' "${INPUT_STRING,,}" ; 40 | close_ml_var "${delim}" "${var}" ; 41 | 42 | var='capitalized' ; 43 | delim="$(mk_delim "${var}")" ; 44 | open_ml_var "${delim}" "${var}" ; 45 | printf -- '%s\n' "${INPUT_STRING^}" ; 46 | close_ml_var "${delim}" "${var}" ; 47 | 48 | var='uppercase' ; 49 | delim="$(mk_delim "${var}")" ; 50 | open_ml_var "${delim}" "${var}" ; 51 | printf -- '%s\n' "${INPUT_STRING^^}" ; 52 | close_ml_var "${delim}" "${var}" ; 53 | 54 | } >> "${GITHUB_OUTPUT}" 55 | printf -- '%s: %s\n' 'lowercase' "${INPUT_STRING,,}" 56 | printf -- '%s: %s\n' 'uppercase' "${INPUT_STRING^^}" 57 | printf -- '%s: %s\n' 'capitalized' "${INPUT_STRING^}" 58 | -------------------------------------------------------------------------------- /.github/sh/library/variables.inc.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # For setting single line variables in the environment or output 4 | set_sl_var() { local f='%s=%s\n' ; printf -- "${f}" "$@" ; } ; 5 | 6 | # Used together to set multiple line variables in the environment or output 7 | mk_delim() { local f='%s_EOF_%d_' ; printf -- "${f}" "$1" "${RANDOM}" ; } ; 8 | open_ml_var() { local f=''\%'s<<'\%'s\n' ; printf -- "${f}" "$2" "$1" ; } ; 9 | close_ml_var() { local f='%s\n' ; printf -- "${f}" "$1" ; } ; 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # vim swap files 11 | .*.swp 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | /tubesync/static/ 68 | /tubesync/media/ 69 | /tubesync/tubesync/downloads/ 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | Pipfile.lock 139 | .vscode/launch.json 140 | 141 | # Ignore Jetbrains IDE files 142 | .idea/ 143 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | python=/usr/bin/env python 2 | docker=/usr/bin/docker 3 | name=tubesync 4 | image=$(name):latest 5 | 6 | 7 | all: clean build 8 | 9 | 10 | dev: 11 | $(python) tubesync/manage.py runserver 12 | 13 | 14 | build: 15 | mkdir -p tubesync/media 16 | mkdir -p tubesync/static 17 | $(python) tubesync/manage.py collectstatic --noinput 18 | 19 | 20 | clean: 21 | rm -rf tubesync/static 22 | 23 | 24 | container: clean 25 | $(docker) build -t $(image) . 26 | 27 | 28 | runcontainer: 29 | $(docker) run --rm --name $(name) --env-file dev.env --log-opt max-size=50m -ti -p 4848:4848 $(image) 30 | 31 | 32 | stopcontainer: 33 | $(docker) stop $(name) 34 | 35 | 36 | test: build 37 | cd tubesync && $(python) manage.py test --verbosity=2 && cd .. 38 | 39 | 40 | shell: 41 | cd tubesync && $(python) manage.py shell 42 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | autopep8 = "*" 8 | 9 | [packages] 10 | django = "~=5.2.1" 11 | django-sass-processor = {extras = ["management-command"], version = "*"} 12 | pillow = "*" 13 | whitenoise = "*" 14 | gunicorn = "*" 15 | httptools = "*" 16 | django-background-tasks = ">=1.2.8" 17 | django-basicauth = "*" 18 | psycopg = {extras = ["binary", "pool"], version = "*"} 19 | mysqlclient = "*" 20 | PySocks = "*" 21 | urllib3 = {extras = ["socks"], version = "*"} 22 | requests = {extras = ["socks"], version = "*"} 23 | yt-dlp = {extras = ["default", "curl-cffi"], version = "*"} 24 | emoji = "*" 25 | brotli = "*" 26 | html5lib = "*" 27 | bgutil-ytdlp-pot-provider = "*" 28 | -------------------------------------------------------------------------------- /config/root/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Something has gone very wrong 7 | 14 | 15 | 16 |
17 |

Something has gone very wrong

18 |
19 |
20 |

21 | If you can see this message then the front end web server has not forwarded the 22 | connection on to the TubeSync back end server. This probably means something has 23 | gone wrong with the container build or a process has crashed. Try restarting it. 24 |

25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /config/root/etc/nginx/token_server.conf: -------------------------------------------------------------------------------- 1 | upstream token_server { 2 | server 127.0.0.2:4416 down; 3 | } 4 | 5 | server { 6 | 7 | # Ports 8 | listen 4416; 9 | listen [::]:4416; 10 | 11 | # Server domain name 12 | server_name _; 13 | 14 | set_by_lua_block $pot_url { 15 | local default = 'http://token_server' 16 | local url = os.getenv('YT_POT_BGUTIL_BASE_URL') 17 | if not url then 18 | return default 19 | end 20 | if #url and url:find('://') then 21 | return url 22 | end 23 | return default 24 | } 25 | 26 | location / { 27 | proxy_pass $pot_url; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/gunicorn/dependencies: -------------------------------------------------------------------------------- 1 | tubesync-init -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/gunicorn/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bash 2 | 3 | UMASK_SET=${UMASK_SET:-022} 4 | umask "$UMASK_SET" 5 | 6 | cd /app || exit 7 | 8 | PIDFILE=/run/app/gunicorn.pid 9 | 10 | if [ -f "${PIDFILE}" ] 11 | then 12 | PID=$(cat $PIDFILE) 13 | echo "Unexpected PID file exists at ${PIDFILE} with PID: ${PID}" 14 | if kill -0 $PID 15 | then 16 | echo "Killing old gunicorn process with PID: ${PID}" 17 | kill -9 $PID 18 | fi 19 | echo "Removing stale PID file: ${PIDFILE}" 20 | rm ${PIDFILE} 21 | fi 22 | 23 | exec s6-setuidgid app \ 24 | /usr/local/bin/gunicorn -c /app/tubesync/gunicorn.py --capture-output tubesync.wsgi:application 25 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/gunicorn/type: -------------------------------------------------------------------------------- 1 | longrun -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/nginx/dependencies: -------------------------------------------------------------------------------- 1 | gunicorn -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/nginx/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bash 2 | 3 | cd / 4 | 5 | https="${TUBESYNC_POT_HTTPS:+https}" 6 | ip_address="${TUBESYNC_POT_IPADDR:-${POTSERVER_PORT_4416_TCP_ADDR}}" 7 | : "${TUBESYNC_POT_PORT:=${POTSERVER_PORT_4416_TCP_PORT}}" 8 | port="${TUBESYNC_POT_PORT:+:}${TUBESYNC_POT_PORT}" 9 | 10 | if [ -n "${ip_address}" ] 11 | then 12 | YT_POT_BGUTIL_BASE_URL="${https:-http}://${ip_address}${port}" 13 | export YT_POT_BGUTIL_BASE_URL 14 | fi 15 | 16 | exec /usr/bin/openresty -c /etc/nginx/nginx.conf -e stderr 17 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/nginx/type: -------------------------------------------------------------------------------- 1 | longrun -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-db-worker/dependencies: -------------------------------------------------------------------------------- 1 | gunicorn -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-db-worker/down-signal: -------------------------------------------------------------------------------- 1 | SIGINT 2 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-db-worker/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bash 2 | 3 | exec nice -n "${TUBESYNC_NICE:-1}" s6-setuidgid app \ 4 | /usr/bin/python3 /app/manage.py process_tasks \ 5 | --queue database --duration 86400 \ 6 | --sleep "30.${RANDOM}" 7 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-db-worker/type: -------------------------------------------------------------------------------- 1 | longrun -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-fs-worker/dependencies: -------------------------------------------------------------------------------- 1 | gunicorn -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-fs-worker/down-signal: -------------------------------------------------------------------------------- 1 | SIGINT 2 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-fs-worker/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bash 2 | 3 | exec nice -n "${TUBESYNC_NICE:-1}" s6-setuidgid app \ 4 | /usr/bin/python3 /app/manage.py process_tasks \ 5 | --queue filesystem --duration 43200 \ 6 | --sleep "20.${RANDOM}" 7 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-fs-worker/type: -------------------------------------------------------------------------------- 1 | longrun -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-init/dependancies: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/config/root/etc/s6-overlay/s6-rc.d/tubesync-init/dependancies -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-init/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bash 2 | 3 | # Change runtime user UID and GID 4 | groupmod -o -g "${PGID:=911}" app 5 | usermod -o -u "${PUID:=911}" app 6 | 7 | # Reset permissions 8 | chown -R app:app /run/app 9 | chmod -R 0700 /run/app 10 | chown -R app:app /config 11 | chmod -R 0755 /config 12 | chown -R root:app /app 13 | chmod -R 0750 /app 14 | chmod 0755 /app/*.py /app/*.sh 15 | find /app -mindepth 2 -type f -execdir chmod 640 '{}' + 16 | chown -R app:app /app/common/static 17 | chown -R app:app /app/static 18 | 19 | # Optionally reset the download dir permissions 20 | if [ "${TUBESYNC_RESET_DOWNLOAD_DIR:=True}" == "True" ] 21 | then 22 | export TUBESYNC_RESET_DOWNLOAD_DIR 23 | echo "TUBESYNC_RESET_DOWNLOAD_DIR=True, Resetting /downloads directory permissions" 24 | chown -R app:app /downloads 25 | chmod -R 0755 /downloads 26 | fi 27 | 28 | if [ 'True' = "${TUBESYNC_DEBUG:-False}" ] 29 | then 30 | s6-setuidgid app \ 31 | /usr/bin/python3 /app/manage.py \ 32 | showmigrations -v 3 --list 33 | fi 34 | 35 | # Run migrations 36 | exec s6-setuidgid app \ 37 | /usr/bin/python3 /app/manage.py migrate 38 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-init/type: -------------------------------------------------------------------------------- 1 | oneshot -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-init/up: -------------------------------------------------------------------------------- 1 | #!/command/execlineb -P 2 | 3 | /etc/s6-overlay/s6-rc.d/tubesync-init/run 4 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-network-worker/dependencies: -------------------------------------------------------------------------------- 1 | gunicorn -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-network-worker/down-signal: -------------------------------------------------------------------------------- 1 | SIGINT 2 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-network-worker/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bash 2 | 3 | exec nice -n "${TUBESYNC_NICE:-1}" s6-setuidgid app \ 4 | /usr/bin/python3 /app/manage.py process_tasks \ 5 | --queue network --duration 43200 \ 6 | --sleep "10.${RANDOM}" 7 | -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/tubesync-network-worker/type: -------------------------------------------------------------------------------- 1 | longrun -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/user/contents.d/gunicorn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/config/root/etc/s6-overlay/s6-rc.d/user/contents.d/gunicorn -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/user/contents.d/nginx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/config/root/etc/s6-overlay/s6-rc.d/user/contents.d/nginx -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-db-worker: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-db-worker -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-fs-worker: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-fs-worker -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-init: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-init -------------------------------------------------------------------------------- /config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-network-worker: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/config/root/etc/s6-overlay/s6-rc.d/user/contents.d/tubesync-network-worker -------------------------------------------------------------------------------- /dev.env: -------------------------------------------------------------------------------- 1 | GUNICORN_WORKERS=1 2 | DJANGO_ALLOWED_HOSTS=localhost 3 | DJANGO_SECRET_KEY=not-a-secret 4 | PUID=1234 5 | PGID=1234 6 | -------------------------------------------------------------------------------- /docs/create-missing-metadata.md: -------------------------------------------------------------------------------- 1 | # TubeSync 2 | 3 | ## Advanced usage guide - creating missing metadata 4 | 5 | This is a new feature in v0.9 of TubeSync and later. It allows you to create or 6 | re-create missing metadata in your TubeSync download directories for missing `nfo` 7 | files and thumbnails. 8 | 9 | If you add a source with "write NFO files" or "copy thumbnails" disabled, download 10 | some media and then update the source to write NFO files or copy thumbnails then 11 | TubeSync will not automatically retroactively attempt to copy or create your missing 12 | metadata files. You can use a special one-off command to manually write missing 13 | metadata files to the correct locations. 14 | 15 | ## Requirements 16 | 17 | You have added a source without metadata writing enabled, downloaded some media, then 18 | updated the source to enable metadata writing. 19 | 20 | ## Steps 21 | 22 | ### 1. Run the batch metadata sync command 23 | 24 | Execute the following Django command: 25 | 26 | `./manage.py sync-missing-metadata` 27 | 28 | When deploying TubeSync inside a container, you can execute this with: 29 | 30 | `docker exec -ti tubesync python3 /app/manage.py sync-missing-metadata` 31 | 32 | This command will log what its doing to the terminal when you run it. 33 | 34 | Internally, this command loops over all your sources which have been saved with 35 | "write NFO files" or "copy thumbnails" enabled. Then, loops over all media saved to 36 | that source and confirms that the appropriate thumbnail files have been copied over and 37 | the NFO file has been written if enabled. 38 | -------------------------------------------------------------------------------- /docs/custom-filters.md: -------------------------------------------------------------------------------- 1 | # TubeSync 2 | 3 | ## Advanced usage guide - Writing Custom Filters 4 | 5 | Tubesync provides ways to filter media based on age, title string, and 6 | duration. This is sufficient for most use cases, but there more complicated 7 | use cases that can't easily be anticipated. Custom filters allow you to 8 | write some Python code to easily add your own logic into the filtering. 9 | 10 | Any call to an external API, or that requires access the metadata of the 11 | media item, will be much slower than the checks for title/age/duration. So 12 | this custom filter is only called if the other checks have already passed. 13 | You should also be aware that external API calls will significantly slow 14 | down the check process, and for large channels or databases this could be 15 | an issue. 16 | 17 | ### How to use 18 | 1. Copy `tubesync/sync/overrides/custom_filter.py` to your local computer 19 | 2. Make your code changes to the `filter_custom` function in that file. Simply return `True` to skip downloading the item, and `False` to allow it to download 20 | 3. Override `tubesync/sync/overrides/custom_filter.py` in your docker container. 21 | 22 | #### Docker run 23 | Include `-v /some/directory/tubesync-overrides:/app/sync/overrides` in your docker run 24 | command, pointing to the location of your override file. 25 | 26 | #### Docker Compose 27 | Include a volume line pointing to the location of your override file. 28 | e.g. 29 | ```yaml 30 | services: 31 | tubesync: 32 | image: ghcr.io/meeb/tubesync:latest 33 | container_name: tubesync 34 | restart: unless-stopped 35 | ports: 36 | - 4848:4848 37 | volumes: 38 | - /some/directory/tubesync-config:/config 39 | - /some/directory/tubesync-downloads:/downloads 40 | - /some/directory/tubesync-overrides:/app/sync/overrides 41 | ``` -------------------------------------------------------------------------------- /docs/dashboard-v0.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/docs/dashboard-v0.5.png -------------------------------------------------------------------------------- /docs/import-existing-media.md: -------------------------------------------------------------------------------- 1 | # TubeSync 2 | 3 | ## Advanced usage guide - importing existing media 4 | 5 | This is a new feature in v0.9 of TubeSync and later. It allows you to mark existing 6 | downloaded media as "downloaded" in TubeSync. You can use this feature if, for example, 7 | you already have an extensive catalogue of downloaded media which you want to mark 8 | as downloaded into TubeSync so TubeSync doesn't re-download media you already have. 9 | 10 | ## Requirements 11 | 12 | Your existing downloaded media MUST contain the unique ID. For YouTube videos, this is 13 | means the YouTube video ID MUST be in the filename. 14 | 15 | Supported extensions to be imported are .m4a, .ogg, .mkv, .mp3, .mp4 and .avi. Your 16 | media you want to import must end in one of these file extensions. 17 | 18 | ## Caveats 19 | 20 | As TubeSync does not probe media and your existing media may be re-encoded or in 21 | different formats to what is available in the current media metadata there is no way 22 | for TubeSync to know what codecs, resolution, bitrate etc. your imported media is in. 23 | Any manually imported existing local media will display blank boxes for this 24 | information on the TubeSync interface as it's unavailable. 25 | 26 | ## Steps 27 | 28 | ### 1. Add your source to TubeSync 29 | 30 | Add your source to TubeSync, such as a YouTube channel. **Make sure you untick the 31 | "download media" checkbox.** 32 | 33 | This will allow TubeSync to index all the available media on your source, but won't 34 | start downloading any media. 35 | 36 | ### 2. Wait 37 | 38 | Wait for all the media on your source to be indexed. This may take some time. 39 | 40 | ### 3. Move your existing media into TubeSync 41 | 42 | You now need to move your existing media into TubeSync. You need to move the media 43 | files into the correct download directories created by TubeSync. For example, if you 44 | have downloaded videos for a YouTube channel "TestChannel", you would have added this 45 | as a source called TestChannel and in a directory called test-channel in Tubesync. It 46 | would have a download directory created on disk at: 47 | 48 | `/path/to/downloads/test-channel` 49 | 50 | You would move all of your pre-existing videos you downloaded outside of TubeSync for 51 | this channel into this directory. 52 | 53 | In short, your existing media needs to be moved into the correct TubeSync source 54 | directory to be detected. 55 | 56 | This is required so TubeSync can known which Source to link the media to. 57 | 58 | ### 4. Run the batch import command 59 | 60 | Execute the following Django command: 61 | 62 | `./manage.py import-existing-media` 63 | 64 | When deploying TubeSync inside a container, you can execute this with: 65 | 66 | `docker exec -ti tubesync python3 /app/manage.py import-existing-media` 67 | 68 | This command will log what its doing to the terminal when you run it. 69 | 70 | Internally, `import-existing-media` looks for the unique media key (for YouTube, this 71 | is the YouTube video ID) in the filename and detects the source to link it to based 72 | on the directory the media file is inside. 73 | 74 | 75 | ### 5. Re-enable downloading at the source 76 | 77 | Edit your source and re-enable / tick the "download media" option. This will allow 78 | TubeSync to download any missing media you did not manually import. 79 | 80 | Note that TubeSync will still get screenshots write `nfo` files etc. for files you 81 | manually import if enabled at the source level. 82 | -------------------------------------------------------------------------------- /docs/media-item-v0.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/docs/media-item-v0.5.png -------------------------------------------------------------------------------- /docs/media-v0.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/docs/media-v0.5.png -------------------------------------------------------------------------------- /docs/reset-metadata.md: -------------------------------------------------------------------------------- 1 | # TubeSync 2 | 3 | ## Advanced usage guide - reset media metadata from the command line 4 | 5 | This command allows you to reset all media item metadata. You might want to use 6 | this if you have a lot of media items with invalid metadata and you want to 7 | wipe it which triggers the metadata to be redownloaded. 8 | 9 | 10 | ## Requirements 11 | 12 | You have added some sources and media 13 | 14 | ## Steps 15 | 16 | ### 1. Run the reset tasks command 17 | 18 | Execute the following Django command: 19 | 20 | `./manage.py reset-metadata` 21 | 22 | When deploying TubeSync inside a container, you can execute this with: 23 | 24 | `docker exec -ti tubesync python3 /app/manage.py reset-metadata` 25 | 26 | This command will log what its doing to the terminal when you run it. 27 | 28 | When this is run, new tasks will be immediately created so all your media 29 | items will start downloading updated metadata straight away, any missing information 30 | such as thumbnails will be redownloaded, etc. 31 | -------------------------------------------------------------------------------- /docs/reset-tasks.md: -------------------------------------------------------------------------------- 1 | # TubeSync 2 | 3 | ## Advanced usage guide - reset tasks from the command line 4 | 5 | This is a new feature in v1.0 of TubeSync and later. It allows you to reset all 6 | scheduled tasks from the command line as well as the "reset tasks" button in the 7 | "tasks" tab of the dashboard. 8 | 9 | This is useful for TubeSync installations where you may have a lot of media and 10 | sources added and the "reset tasks" button may take too long to the extent where 11 | the page times out (with a 502 error or similar issue). 12 | 13 | ## Requirements 14 | 15 | You have added some sources and media 16 | 17 | ## Steps 18 | 19 | ### 1. Run the reset tasks command 20 | 21 | Execute the following Django command: 22 | 23 | `./manage.py reset-tasks` 24 | 25 | When deploying TubeSync inside a container, you can execute this with: 26 | 27 | `docker exec -ti tubesync python3 /app/manage.py reset-tasks` 28 | 29 | This command will log what its doing to the terminal when you run it. 30 | 31 | When this is run, new tasks will be immediately created so all your sources will be 32 | indexed again straight away, any missing information such as thumbnails will be 33 | redownloaded, etc. 34 | -------------------------------------------------------------------------------- /docs/source-v0.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/docs/source-v0.5.png -------------------------------------------------------------------------------- /docs/sources-v0.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/docs/sources-v0.5.png -------------------------------------------------------------------------------- /docs/using-cookies.md: -------------------------------------------------------------------------------- 1 | # TubeSync 2 | 3 | ## Advanced usage guide - using exported cookies 4 | 5 | This is a new feature in v0.10 of TubeSync and later. It allows you to use the cookies 6 | file exported from your browser in "Netscape" format with TubeSync to authenticate 7 | to YouTube. This can bypass some throttling, age restrictions and other blocks at 8 | YouTube. 9 | 10 | **IMPORTANT NOTE**: Using cookies exported from your browser that is authenticated 11 | to YouTube identifes your Google account as using TubeSync. This may result in 12 | potential account impacts and is entirely at your own risk. Do not use this 13 | feature unless you really know what you're doing. 14 | 15 | ## Requirements 16 | 17 | Have a browser that supports exporting your cookies and be logged into YouTube. 18 | 19 | ## Steps 20 | 21 | ### 1. Export your cookies 22 | 23 | You need to export cookies for youtube.com from your browser, you can either do 24 | this manually or there are plug-ins to automate this for you. This file must be 25 | in the "Netscape" cookie export format. 26 | 27 | Save your cookies as a `cookies.txt` file. 28 | 29 | ### 2. Import into TubeSync 30 | 31 | Drop the `cookies.txt` file into your TubeSync `config` directory. 32 | 33 | If detected correctly, you will see something like this in the worker or container 34 | logs: 35 | 36 | ``` 37 | YYYY-MM-DD HH:MM:SS,mmm [tubesync/INFO] [youtube-dl] using cookies.txt from: /config/cookies.txt 38 | ``` 39 | 40 | If you see that line it's working correctly. 41 | 42 | If you see errors in your logs like this: 43 | 44 | ``` 45 | http.cookiejar.LoadError: '/config/cookies.txt' does not look like a Netscape format cookies file 46 | ``` 47 | 48 | Then your `cookies.txt` file was not generated or created correctly as it's not 49 | in the required "Netscape" format. You can fix this by exporting your `cookies.txt` 50 | in the correct "Netscape" format. 51 | -------------------------------------------------------------------------------- /patches/background_task/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import signal 3 | import platform 4 | 5 | TTW_SLOW = [0.5, 1.5] 6 | TTW_FAST = [0.0, 0.1] 7 | 8 | 9 | class SignalManager(): 10 | """Manages POSIX signals.""" 11 | 12 | kill_now = False 13 | time_to_wait = TTW_SLOW 14 | 15 | def __init__(self): 16 | signal.signal(signal.SIGINT, self.exit_gracefully) 17 | # On Windows, signal() can only be called with: 18 | # SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM, or SIGBREAK. 19 | if platform.system() == 'Windows': 20 | signal.signal(signal.SIGBREAK, self.exit_gracefully) 21 | else: 22 | signal.signal(signal.SIGHUP, self.exit_gracefully) 23 | signal.signal(signal.SIGUSR1, self.speed_up) 24 | signal.signal(signal.SIGUSR2, self.slow_down) 25 | 26 | def exit_gracefully(self, signum, frame): 27 | self.kill_now = True 28 | # Using interrupt again should raise 29 | # a KeyboardInterrupt exception. 30 | signal.signal(signal.SIGINT, signal.SIG_DFL) 31 | 32 | def speed_up(self, signum, frame): 33 | self.time_to_wait = TTW_FAST 34 | 35 | def slow_down(self, signum, frame): 36 | self.time_to_wait = TTW_SLOW 37 | -------------------------------------------------------------------------------- /patches/yt_dlp/patch/__init__.py: -------------------------------------------------------------------------------- 1 | from yt_dlp.compat.compat_utils import passthrough_module 2 | 3 | passthrough_module(__name__, '.patch') 4 | del passthrough_module 5 | 6 | -------------------------------------------------------------------------------- /patches/yt_dlp/patch/check_thumbnails.py: -------------------------------------------------------------------------------- 1 | from yt_dlp import YoutubeDL 2 | from yt_dlp.utils import sanitize_url, LazyList 3 | 4 | class PatchedYoutubeDL(YoutubeDL): 5 | 6 | def _sanitize_thumbnails(self, info_dict): 7 | thumbnails = info_dict.get('thumbnails') 8 | if thumbnails is None: 9 | thumbnail = info_dict.get('thumbnail') 10 | if thumbnail: 11 | info_dict['thumbnails'] = thumbnails = [{'url': thumbnail}] 12 | if not thumbnails: 13 | return 14 | 15 | 16 | def check_thumbnails(thumbnails): 17 | for t in thumbnails: 18 | self.to_screen(f'[info] Testing thumbnail {t["id"]}: {t["url"]!r}') 19 | try: 20 | self.urlopen(HEADRequest(t['url'])) 21 | except network_exceptions as err: 22 | self.to_screen(f'[info] Unable to connect to thumbnail {t["id"]} URL {t["url"]!r} - {err}. Skipping...') 23 | continue 24 | yield t 25 | 26 | 27 | self._sort_thumbnails(thumbnails) 28 | for i, t in enumerate(thumbnails): 29 | if t.get('id') is None: 30 | t['id'] = str(i) 31 | if t.get('width') and t.get('height'): 32 | t['resolution'] = '%dx%d' % (t['width'], t['height']) 33 | t['url'] = sanitize_url(t['url']) 34 | 35 | 36 | if self.params.get('check_thumbnails') is True: 37 | info_dict['thumbnails'] = LazyList(check_thumbnails(thumbnails[::-1]), reverse=True) 38 | else: 39 | info_dict['thumbnails'] = thumbnails 40 | 41 | 42 | YoutubeDL.__unpatched___sanitize_thumbnails = YoutubeDL._sanitize_thumbnails 43 | YoutubeDL._sanitize_thumbnails = PatchedYoutubeDL._sanitize_thumbnails 44 | -------------------------------------------------------------------------------- /patches/yt_dlp/patch/fatal_http_errors.py: -------------------------------------------------------------------------------- 1 | from yt_dlp.extractor.youtube import YoutubeIE 2 | 3 | 4 | class PatchedYoutubeIE(YoutubeIE): 5 | 6 | def _download_player_responses(self, url, smuggled_data, video_id, webpage_url): 7 | webpage = None 8 | if 'webpage' not in self._configuration_arg('player_skip'): 9 | query = {'bpctr': '9999999999', 'has_verified': '1'} 10 | pp = self._configuration_arg('player_params', [None], casesense=True)[0] 11 | if pp: 12 | query['pp'] = pp 13 | webpage = self._download_webpage_with_retries(webpage_url, video_id, retry_fatal=True, query=query) 14 | 15 | master_ytcfg = self.extract_ytcfg(video_id, webpage) or self._get_default_ytcfg() 16 | 17 | player_responses, player_url = self._extract_player_responses( 18 | self._get_requested_clients(url, smuggled_data), 19 | video_id, webpage, master_ytcfg, smuggled_data) 20 | 21 | return webpage, master_ytcfg, player_responses, player_url 22 | 23 | 24 | YoutubeIE.__unpatched___download_player_responses = YoutubeIE._download_player_responses 25 | YoutubeIE._download_player_responses = PatchedYoutubeIE._download_player_responses 26 | -------------------------------------------------------------------------------- /tubesync/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/__init__.py -------------------------------------------------------------------------------- /tubesync/common/admin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/admin.py -------------------------------------------------------------------------------- /tubesync/common/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommonConfig(AppConfig): 5 | 6 | name = 'common' 7 | -------------------------------------------------------------------------------- /tubesync/common/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from .third_party_versions import yt_dlp_version, ffmpeg_version 3 | 4 | 5 | def app_details(request): 6 | return { 7 | 'app_version': str(settings.VERSION), 8 | 'yt_dlp_version': yt_dlp_version, 9 | 'ffmpeg_version': ffmpeg_version, 10 | } 11 | -------------------------------------------------------------------------------- /tubesync/common/errors.py: -------------------------------------------------------------------------------- 1 | class NoMediaException(Exception): 2 | ''' 3 | Raised when a source returns no media to be indexed. Could be an invalid 4 | playlist name or similar, or the upstream source returned an error. 5 | ''' 6 | pass 7 | 8 | 9 | class NoFormatException(Exception): 10 | ''' 11 | Raised when a media item is attempted to be downloaded but it has no valid 12 | format combination. 13 | ''' 14 | pass 15 | 16 | 17 | class NoMetadataException(Exception): 18 | ''' 19 | Raised when a media item is attempted to be downloaded but it has no valid 20 | metadata. 21 | ''' 22 | pass 23 | 24 | 25 | class NoThumbnailException(Exception): 26 | ''' 27 | Raised when a thumbnail was not found at the remote URL. 28 | ''' 29 | pass 30 | 31 | 32 | class DownloadFailedException(Exception): 33 | ''' 34 | Raised when a downloaded media file is expected to be present, but doesn't 35 | exist. 36 | ''' 37 | pass 38 | 39 | 40 | class DatabaseConnectionError(Exception): 41 | ''' 42 | Raised when parsing or initially connecting to a database. 43 | ''' 44 | pass 45 | -------------------------------------------------------------------------------- /tubesync/common/json.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.core.serializers.json import DjangoJSONEncoder 3 | from yt_dlp.utils import LazyList 4 | 5 | 6 | class JSONEncoder(DjangoJSONEncoder): 7 | item_separator = ',' 8 | key_separator = ':' 9 | 10 | def default(self, obj): 11 | try: 12 | iterable = iter(obj) 13 | except TypeError: 14 | pass 15 | else: 16 | return list(iterable) 17 | return super().default(obj) 18 | 19 | 20 | def json_serial(obj): 21 | if isinstance(obj, datetime): 22 | return obj.isoformat() 23 | if isinstance(obj, LazyList): 24 | return list(obj) 25 | raise TypeError(f'Type {type(obj)} is not json_serial()-able') 26 | 27 | -------------------------------------------------------------------------------- /tubesync/common/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from django.conf import settings 3 | from .utils import getenv 4 | 5 | 6 | logging_level = logging.DEBUG if settings.DEBUG else logging.INFO 7 | default_formatter = logging.Formatter( 8 | '%(asctime)s [%(name)s/%(levelname)s] %(message)s' 9 | ) 10 | default_sh = logging.StreamHandler() 11 | default_sh.setFormatter(default_formatter) 12 | default_sh.setLevel(logging_level) 13 | 14 | 15 | app_name = getenv('DJANGO_SETTINGS_MODULE') 16 | first_part = app_name.split('.', 1)[0] 17 | log = app_logger = logging.getLogger(first_part) 18 | app_logger.addHandler(default_sh) 19 | app_logger.setLevel(logging_level) 20 | 21 | 22 | class NoWaitingForTasksFilter(logging.Filter): 23 | def filter(self, record): 24 | return 'waiting for tasks' != record.getMessage() 25 | 26 | background_task_name = 'background_task.management.commands.process_tasks' 27 | last_part = background_task_name.rsplit('.', 1)[-1] 28 | background_task_formatter = logging.Formatter( 29 | f'%(asctime)s [{last_part}/%(levelname)s] %(message)s' 30 | ) 31 | background_task_sh = logging.StreamHandler() 32 | background_task_sh.addFilter(NoWaitingForTasksFilter()) 33 | background_task_sh.setFormatter(background_task_formatter) 34 | background_task_sh.setLevel(logging_level) 35 | background_task_logger = logging.getLogger(background_task_name) 36 | background_task_logger.addHandler(background_task_sh) 37 | background_task_logger.setLevel(logging_level) 38 | -------------------------------------------------------------------------------- /tubesync/common/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.forms import BaseForm 3 | from basicauth.middleware import BasicAuthMiddleware as BaseBasicAuthMiddleware 4 | 5 | 6 | class MaterializeDefaultFieldsMiddleware: 7 | ''' 8 | Adds 'browser-default' CSS attribute class to all form fields. 9 | ''' 10 | 11 | def __init__(self, get_response): 12 | self.get_response = get_response 13 | 14 | def __call__(self, request): 15 | response = self.get_response(request) 16 | return response 17 | 18 | def process_template_response(self, request, response): 19 | for _, v in getattr(response, 'context_data', {}).items(): 20 | if isinstance(v, BaseForm): 21 | for _, field in v.fields.items(): 22 | field.widget.attrs.update({'class':'browser-default'}) 23 | return response 24 | 25 | 26 | class BasicAuthMiddleware(BaseBasicAuthMiddleware): 27 | 28 | def process_request(self, request): 29 | bypass_uris = getattr(settings, 'BASICAUTH_ALWAYS_ALLOW_URIS', []) 30 | if request.path in bypass_uris: 31 | return None 32 | return super().process_request(request) 33 | -------------------------------------------------------------------------------- /tubesync/common/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/migrations/__init__.py -------------------------------------------------------------------------------- /tubesync/common/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/models.py -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-brands-400.eot -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-brands-400.ttf -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-brands-400.woff -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-brands-400.woff2 -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-regular-400.eot -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-regular-400.ttf -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-regular-400.woff -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-regular-400.woff2 -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-solid-900.eot -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-solid-900.ttf -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-solid-900.woff -------------------------------------------------------------------------------- /tubesync/common/static/fonts/fontawesome/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/fontawesome/fa-solid-900.woff2 -------------------------------------------------------------------------------- /tubesync/common/static/fonts/roboto/roboto-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/roboto/roboto-bold.woff -------------------------------------------------------------------------------- /tubesync/common/static/fonts/roboto/roboto-light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/roboto/roboto-light.woff -------------------------------------------------------------------------------- /tubesync/common/static/fonts/roboto/roboto-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/fonts/roboto/roboto-regular.woff -------------------------------------------------------------------------------- /tubesync/common/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/images/favicon.ico -------------------------------------------------------------------------------- /tubesync/common/static/images/nothumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/images/nothumb.png -------------------------------------------------------------------------------- /tubesync/common/static/images/tubesync-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/images/tubesync-transparent.png -------------------------------------------------------------------------------- /tubesync/common/static/images/tubesync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/common/static/images/tubesync.png -------------------------------------------------------------------------------- /tubesync/common/static/styles/_colours.scss: -------------------------------------------------------------------------------- 1 | $colour-white: #ffffff; 2 | $colour-black: #000000; 3 | $colour-near-black: #011627; 4 | $colour-near-white: #fdfffc; 5 | $colour-light-blue: #1e5c83; 6 | $colour-red: #e71d36; 7 | $colour-orange: #ff9c00; 8 | 9 | $background-colour: $colour-near-white; 10 | $text-colour: $colour-near-black; 11 | 12 | $header-background-colour: $colour-red; 13 | $header-text-colour: $colour-near-white; 14 | 15 | $nav-background-colour: $colour-near-black; 16 | $nav-text-colour: $colour-near-white; 17 | $nav-link-background-hover-colour: $colour-orange; 18 | 19 | $main-button-background-colour: $colour-light-blue; 20 | $main-button-background-hover-colour: $colour-orange; 21 | $main-button-text-colour: $colour-near-white; 22 | $main-link-colour: $colour-light-blue; 23 | $main-link-hover-colour: $colour-orange; 24 | 25 | $footer-background-colour: $colour-red; 26 | $footer-text-colour: $colour-near-white; 27 | $footer-link-colour: $colour-near-black; 28 | $footer-link-hover-colour: $colour-orange; 29 | 30 | $dash-desc-border: $colour-near-black; 31 | 32 | $form-label-text-colour: $colour-near-black; 33 | $form-input-border-colour: $colour-light-blue; 34 | $form-input-border-active-colour: $colour-orange; 35 | $form-select-border-colour: $colour-light-blue; 36 | $form-error-background-colour: $colour-red; 37 | $form-error-text-colour: $colour-near-white; 38 | $form-help-text-colour: $colour-light-blue; 39 | $form-delete-button-background-colour: $colour-red; 40 | 41 | $collection-no-items-text-colour: $colour-near-black; 42 | $collection-text-colour: $colour-light-blue; 43 | $collection-background-hover-colour: $colour-orange; 44 | $collection-text-hover-colour: $colour-near-white; 45 | 46 | $mediacard-title-background-colour: $colour-near-black; 47 | $mediacard-title-text-colour: $colour-near-white; 48 | $mediacard-border-colour: $colour-near-white; 49 | $mediacard-border-hover-colour: $colour-orange; 50 | 51 | $box-error-background-colour: $colour-red; 52 | $box-error-text-colour: $colour-near-white; 53 | 54 | $infobox-background-colour: $colour-near-black; 55 | $infobox-text-colour: $colour-near-white; 56 | 57 | $errorbox-background-colour: $colour-red; 58 | $errorbox-text-colour: $colour-near-white; 59 | $error-text-colour: $colour-red; 60 | 61 | $pagination-background-colour: $colour-near-white; 62 | $pagination-text-colour: $colour-near-black; 63 | $pagination-border-colour: $colour-light-blue; 64 | $pagination-background-hover-colour: $colour-light-blue; 65 | $pagination-text-hover-colour: $colour-near-white; 66 | $pagination-border-hover-colour: $colour-light-blue; 67 | $pagination-current-background-colour: $colour-orange; 68 | $pagination-current-text-colour: $colour-near-white; 69 | $pagination-current-border-colour: $colour-orange; 70 | 71 | $error-text-colour: $colour-red; 72 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/_fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'roboto'; 3 | src: url('../fonts/roboto/roboto-light.woff') format('woff'); 4 | font-weight: lighter; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: 'roboto'; 10 | src: url('../fonts/roboto/roboto-regular.woff') format('woff'); 11 | font-weight: normal; 12 | font-style: normal; 13 | } 14 | 15 | @font-face { 16 | font-family: 'roboto'; 17 | src: url('../fonts/roboto/roboto-bold.woff') format('woff'); 18 | font-weight: bold; 19 | font-style: normal; 20 | } 21 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/_forms.scss: -------------------------------------------------------------------------------- 1 | .simpleform { 2 | .row { 3 | margin-bottom: 0; 4 | } 5 | .help-text { 6 | color: $form-help-text-colour; 7 | padding-bottom: 1rem; 8 | } 9 | label { 10 | text-transform: uppercase; 11 | display: block; 12 | font-size: 0.9rem; 13 | position: relative; 14 | transition: none; 15 | top: initial; 16 | left: initial !important; 17 | transform: none; 18 | color: $form-label-text-colour; 19 | } 20 | input { 21 | width: 100%; 22 | padding: 5px 8px 5px 8px; 23 | font-size: 1.1rem; 24 | border: 2px $form-input-border-colour solid; 25 | border-radius: 2px; 26 | outline: none; 27 | &:focus { 28 | outline: none; 29 | border: 2px $form-input-border-active-colour solid; 30 | } 31 | } 32 | textarea { 33 | min-height: 150px; 34 | } 35 | } 36 | 37 | select { 38 | display: initial !important; 39 | border: 2px $form-select-border-colour solid; 40 | height: initial !important; 41 | } 42 | 43 | .delete-button { 44 | background-color: $form-delete-button-background-colour !important; 45 | &:hover { 46 | background-color: $main-button-background-hover-colour !important; 47 | } 48 | } 49 | 50 | .no-text-transform { 51 | text-transform: none; 52 | } 53 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/_helpers.scss: -------------------------------------------------------------------------------- 1 | strong { 2 | font-weight: bold; 3 | } 4 | 5 | .nowrap { 6 | white-space: nowrap; 7 | } 8 | 9 | .no-para-margin-top { 10 | margin-block-start: 0; 11 | } 12 | 13 | .no-margin-bottom { 14 | margin-bottom: 0 !important; 15 | } 16 | 17 | .margin-bottom { 18 | margin-bottom: 20px !important; 19 | } 20 | 21 | .padding-top { 22 | padding-top: 20px; 23 | } 24 | 25 | .error-text { 26 | color: $error-text-colour; 27 | } 28 | 29 | .errors { 30 | background-color: $box-error-background-colour; 31 | border-radius: 2px; 32 | padding: 10px 0 5px 0; 33 | } 34 | 35 | .errorlist { 36 | li { 37 | color: $box-error-text-colour; 38 | padding: 0 10px 5px 10px; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $font-family: 'roboto', Arial, Helvetica, sans-serif; 2 | $font-size: 1.05rem; 3 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_animated.scss: -------------------------------------------------------------------------------- 1 | // Animated Icons 2 | // -------------------------- 3 | 4 | .#{$fa-css-prefix}-spin { 5 | animation: fa-spin 2s infinite linear; 6 | } 7 | 8 | .#{$fa-css-prefix}-pulse { 9 | animation: fa-spin 1s infinite steps(8); 10 | } 11 | 12 | @keyframes fa-spin { 13 | 0% { 14 | transform: rotate(0deg); 15 | } 16 | 17 | 100% { 18 | transform: rotate(360deg); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_bordered-pulled.scss: -------------------------------------------------------------------------------- 1 | // Bordered & Pulled 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-border { 5 | border: solid .08em $fa-border-color; 6 | border-radius: .1em; 7 | padding: .2em .25em .15em; 8 | } 9 | 10 | .#{$fa-css-prefix}-pull-left { float: left; } 11 | .#{$fa-css-prefix}-pull-right { float: right; } 12 | 13 | .#{$fa-css-prefix}, 14 | .fas, 15 | .far, 16 | .fal, 17 | .fab { 18 | &.#{$fa-css-prefix}-pull-left { margin-right: .3em; } 19 | &.#{$fa-css-prefix}-pull-right { margin-left: .3em; } 20 | } 21 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_core.scss: -------------------------------------------------------------------------------- 1 | // Base Class Definition 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}, 5 | .fas, 6 | .far, 7 | .fal, 8 | .fad, 9 | .fab { 10 | -moz-osx-font-smoothing: grayscale; 11 | -webkit-font-smoothing: antialiased; 12 | display: inline-block; 13 | font-style: normal; 14 | font-variant: normal; 15 | text-rendering: auto; 16 | line-height: 1; 17 | } 18 | 19 | %fa-icon { 20 | @include fa-icon; 21 | } 22 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_fixed-width.scss: -------------------------------------------------------------------------------- 1 | // Fixed Width Icons 2 | // ------------------------- 3 | .#{$fa-css-prefix}-fw { 4 | text-align: center; 5 | width: $fa-fw-width; 6 | } 7 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_larger.scss: -------------------------------------------------------------------------------- 1 | // Icon Sizes 2 | // ------------------------- 3 | 4 | // makes the font 33% larger relative to the icon container 5 | .#{$fa-css-prefix}-lg { 6 | font-size: (4em / 3); 7 | line-height: (3em / 4); 8 | vertical-align: -.0667em; 9 | } 10 | 11 | .#{$fa-css-prefix}-xs { 12 | font-size: .75em; 13 | } 14 | 15 | .#{$fa-css-prefix}-sm { 16 | font-size: .875em; 17 | } 18 | 19 | @for $i from 1 through 10 { 20 | .#{$fa-css-prefix}-#{$i}x { 21 | font-size: $i * 1em; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_list.scss: -------------------------------------------------------------------------------- 1 | // List Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-ul { 5 | list-style-type: none; 6 | margin-left: $fa-li-width * 5/4; 7 | padding-left: 0; 8 | 9 | > li { position: relative; } 10 | } 11 | 12 | .#{$fa-css-prefix}-li { 13 | left: -$fa-li-width; 14 | position: absolute; 15 | text-align: center; 16 | width: $fa-li-width; 17 | line-height: inherit; 18 | } 19 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_mixins.scss: -------------------------------------------------------------------------------- 1 | // Mixins 2 | // -------------------------- 3 | 4 | @mixin fa-icon { 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | display: inline-block; 8 | font-style: normal; 9 | font-variant: normal; 10 | font-weight: normal; 11 | line-height: 1; 12 | } 13 | 14 | @mixin fa-icon-rotate($degrees, $rotation) { 15 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation})"; 16 | transform: rotate($degrees); 17 | } 18 | 19 | @mixin fa-icon-flip($horiz, $vert, $rotation) { 20 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}, mirror=1)"; 21 | transform: scale($horiz, $vert); 22 | } 23 | 24 | 25 | // Only display content to screen readers. A la Bootstrap 4. 26 | // 27 | // See: http://a11yproject.com/posts/how-to-hide-content/ 28 | 29 | @mixin sr-only { 30 | border: 0; 31 | clip: rect(0, 0, 0, 0); 32 | height: 1px; 33 | margin: -1px; 34 | overflow: hidden; 35 | padding: 0; 36 | position: absolute; 37 | width: 1px; 38 | } 39 | 40 | // Use in conjunction with .sr-only to only display content when it's focused. 41 | // 42 | // Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 43 | // 44 | // Credit: HTML5 Boilerplate 45 | 46 | @mixin sr-only-focusable { 47 | &:active, 48 | &:focus { 49 | clip: auto; 50 | height: auto; 51 | margin: 0; 52 | overflow: visible; 53 | position: static; 54 | width: auto; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_rotated-flipped.scss: -------------------------------------------------------------------------------- 1 | // Rotated & Flipped Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-rotate-90 { @include fa-icon-rotate(90deg, 1); } 5 | .#{$fa-css-prefix}-rotate-180 { @include fa-icon-rotate(180deg, 2); } 6 | .#{$fa-css-prefix}-rotate-270 { @include fa-icon-rotate(270deg, 3); } 7 | 8 | .#{$fa-css-prefix}-flip-horizontal { @include fa-icon-flip(-1, 1, 0); } 9 | .#{$fa-css-prefix}-flip-vertical { @include fa-icon-flip(1, -1, 2); } 10 | .#{$fa-css-prefix}-flip-both, .#{$fa-css-prefix}-flip-horizontal.#{$fa-css-prefix}-flip-vertical { @include fa-icon-flip(-1, -1, 2); } 11 | 12 | // Hook for IE8-9 13 | // ------------------------- 14 | 15 | :root { 16 | .#{$fa-css-prefix}-rotate-90, 17 | .#{$fa-css-prefix}-rotate-180, 18 | .#{$fa-css-prefix}-rotate-270, 19 | .#{$fa-css-prefix}-flip-horizontal, 20 | .#{$fa-css-prefix}-flip-vertical, 21 | .#{$fa-css-prefix}-flip-both { 22 | filter: none; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_screen-reader.scss: -------------------------------------------------------------------------------- 1 | // Screen Readers 2 | // ------------------------- 3 | 4 | .sr-only { @include sr-only; } 5 | .sr-only-focusable { @include sr-only-focusable; } 6 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/_stacked.scss: -------------------------------------------------------------------------------- 1 | // Stacked Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-stack { 5 | display: inline-block; 6 | height: 2em; 7 | line-height: 2em; 8 | position: relative; 9 | vertical-align: middle; 10 | width: ($fa-fw-width*2); 11 | } 12 | 13 | .#{$fa-css-prefix}-stack-1x, 14 | .#{$fa-css-prefix}-stack-2x { 15 | left: 0; 16 | position: absolute; 17 | text-align: center; 18 | width: 100%; 19 | } 20 | 21 | .#{$fa-css-prefix}-stack-1x { 22 | line-height: inherit; 23 | } 24 | 25 | .#{$fa-css-prefix}-stack-2x { 26 | font-size: 2em; 27 | } 28 | 29 | .#{$fa-css-prefix}-inverse { 30 | color: $fa-inverse; 31 | } 32 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/brands.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @import 'variables'; 6 | 7 | @font-face { 8 | font-family: 'Font Awesome 5 Brands'; 9 | font-style: normal; 10 | font-weight: 400; 11 | font-display: $fa-font-display; 12 | src: url('#{$fa-font-path}/fa-brands-400.eot'); 13 | src: url('#{$fa-font-path}/fa-brands-400.eot?#iefix') format('embedded-opentype'), 14 | url('#{$fa-font-path}/fa-brands-400.woff2') format('woff2'), 15 | url('#{$fa-font-path}/fa-brands-400.woff') format('woff'), 16 | url('#{$fa-font-path}/fa-brands-400.ttf') format('truetype'), 17 | url('#{$fa-font-path}/fa-brands-400.svg#fontawesome') format('svg'); 18 | } 19 | 20 | .fab { 21 | font-family: 'Font Awesome 5 Brands'; 22 | font-weight: 400; 23 | } 24 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/fontawesome.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @import 'variables'; 6 | @import 'mixins'; 7 | @import 'core'; 8 | @import 'larger'; 9 | @import 'fixed-width'; 10 | @import 'list'; 11 | @import 'bordered-pulled'; 12 | @import 'animated'; 13 | @import 'rotated-flipped'; 14 | @import 'stacked'; 15 | @import 'icons'; 16 | @import 'screen-reader'; 17 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/regular.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @import 'variables'; 6 | 7 | @font-face { 8 | font-family: 'Font Awesome 5 Free'; 9 | font-style: normal; 10 | font-weight: 400; 11 | font-display: $fa-font-display; 12 | src: url('#{$fa-font-path}/fa-regular-400.eot'); 13 | src: url('#{$fa-font-path}/fa-regular-400.eot?#iefix') format('embedded-opentype'), 14 | url('#{$fa-font-path}/fa-regular-400.woff2') format('woff2'), 15 | url('#{$fa-font-path}/fa-regular-400.woff') format('woff'), 16 | url('#{$fa-font-path}/fa-regular-400.ttf') format('truetype'), 17 | url('#{$fa-font-path}/fa-regular-400.svg#fontawesome') format('svg'); 18 | } 19 | 20 | .far { 21 | font-family: 'Font Awesome 5 Free'; 22 | font-weight: 400; 23 | } 24 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/solid.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @import 'variables'; 6 | 7 | @font-face { 8 | font-family: 'Font Awesome 5 Free'; 9 | font-style: normal; 10 | font-weight: 900; 11 | font-display: $fa-font-display; 12 | src: url('#{$fa-font-path}/fa-solid-900.eot'); 13 | src: url('#{$fa-font-path}/fa-solid-900.eot?#iefix') format('embedded-opentype'), 14 | url('#{$fa-font-path}/fa-solid-900.woff2') format('woff2'), 15 | url('#{$fa-font-path}/fa-solid-900.woff') format('woff'), 16 | url('#{$fa-font-path}/fa-solid-900.ttf') format('truetype'), 17 | url('#{$fa-font-path}/fa-solid-900.svg#fontawesome') format('svg'); 18 | } 19 | 20 | .fa, 21 | .fas { 22 | font-family: 'Font Awesome 5 Free'; 23 | font-weight: 900; 24 | } 25 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/fontawesome/v4-shims.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @import 'variables'; 6 | @import 'shims'; 7 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_badges.scss: -------------------------------------------------------------------------------- 1 | // Badges 2 | span.badge { 3 | min-width: 3rem; 4 | padding: 0 6px; 5 | margin-left: 14px; 6 | text-align: center; 7 | font-size: 1rem; 8 | line-height: $badge-height; 9 | height: $badge-height; 10 | color: color('grey', 'darken-1'); 11 | float: right; 12 | box-sizing: border-box; 13 | 14 | &.new { 15 | font-weight: 300; 16 | font-size: 0.8rem; 17 | color: #fff; 18 | background-color: $badge-bg-color; 19 | border-radius: 2px; 20 | } 21 | &.new:after { 22 | content: " new"; 23 | } 24 | 25 | &[data-badge-caption]::after { 26 | content: " " attr(data-badge-caption); 27 | } 28 | } 29 | 30 | // Special cases 31 | nav ul a span.badge { 32 | display: inline-block; 33 | float: none; 34 | margin-left: 4px; 35 | line-height: $badge-height; 36 | height: $badge-height; 37 | -webkit-font-smoothing: auto; 38 | } 39 | 40 | // Line height centering 41 | .collection-item span.badge { 42 | margin-top: calc(#{$collection-line-height / 2} - #{$badge-height / 2}); 43 | } 44 | .collapsible span.badge { 45 | margin-left: auto; 46 | } 47 | .sidenav span.badge { 48 | margin-top: calc(#{$sidenav-line-height / 2} - #{$badge-height / 2}); 49 | } 50 | 51 | table span.badge { 52 | display: inline-block; 53 | float: none; 54 | margin-left: auto; 55 | } 56 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_carousel.scss: -------------------------------------------------------------------------------- 1 | .carousel { 2 | &.carousel-slider { 3 | top: 0; 4 | left: 0; 5 | 6 | .carousel-fixed-item { 7 | &.with-indicators { 8 | bottom: 68px; 9 | } 10 | 11 | position: absolute; 12 | left: 0; 13 | right: 0; 14 | bottom: 20px; 15 | z-index: 1; 16 | } 17 | 18 | .carousel-item { 19 | width: 100%; 20 | height: 100%; 21 | min-height: $carousel-height; 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | 26 | h2 { 27 | font-size: 24px; 28 | font-weight: 500; 29 | line-height: 32px; 30 | } 31 | 32 | p { 33 | font-size: 15px; 34 | } 35 | } 36 | } 37 | 38 | overflow: hidden; 39 | position: relative; 40 | width: 100%; 41 | height: $carousel-height; 42 | perspective: 500px; 43 | transform-style: preserve-3d; 44 | transform-origin: 0% 50%; 45 | 46 | .carousel-item { 47 | visibility: hidden; 48 | width: $carousel-item-width; 49 | height: $carousel-item-height; 50 | position: absolute; 51 | top: 0; 52 | left: 0; 53 | 54 | & > img { 55 | width: 100%; 56 | } 57 | } 58 | 59 | .indicators { 60 | position: absolute; 61 | text-align: center; 62 | left: 0; 63 | right: 0; 64 | bottom: 0; 65 | margin: 0; 66 | 67 | .indicator-item { 68 | &.active { 69 | background-color: #fff; 70 | } 71 | 72 | display: inline-block; 73 | position: relative; 74 | cursor: pointer; 75 | height: 8px; 76 | width: 8px; 77 | margin: 24px 4px; 78 | background-color: rgba(255,255,255,.5); 79 | 80 | transition: background-color .3s; 81 | border-radius: 50%; 82 | } 83 | } 84 | 85 | // Materialbox compatibility 86 | &.scrolling .carousel-item .materialboxed, 87 | .carousel-item:not(.active) .materialboxed { 88 | pointer-events: none; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_chips.scss: -------------------------------------------------------------------------------- 1 | .chip { 2 | &:focus { 3 | outline: none; 4 | background-color: $chip-selected-color; 5 | color: #fff; 6 | } 7 | 8 | display: inline-block; 9 | height: 32px; 10 | font-size: 13px; 11 | font-weight: 500; 12 | color: rgba(0,0,0,.6); 13 | line-height: 32px; 14 | padding: 0 12px; 15 | border-radius: 16px; 16 | background-color: $chip-bg-color; 17 | margin-bottom: $chip-margin; 18 | margin-right: $chip-margin; 19 | 20 | > img { 21 | float: left; 22 | margin: 0 8px 0 -12px; 23 | height: 32px; 24 | width: 32px; 25 | border-radius: 50%; 26 | } 27 | 28 | .close { 29 | cursor: pointer; 30 | float: right; 31 | font-size: 16px; 32 | line-height: 32px; 33 | padding-left: 8px; 34 | } 35 | } 36 | 37 | .chips { 38 | border: none; 39 | border-bottom: 1px solid $chip-border-color; 40 | box-shadow: none; 41 | margin: $input-margin; 42 | min-height: 45px; 43 | outline: none; 44 | transition: all .3s; 45 | 46 | &.focus { 47 | border-bottom: 1px solid $chip-selected-color; 48 | box-shadow: 0 1px 0 0 $chip-selected-color; 49 | } 50 | 51 | &:hover { 52 | cursor: text; 53 | } 54 | 55 | .input { 56 | background: none; 57 | border: 0; 58 | color: rgba(0,0,0,.6); 59 | display: inline-block; 60 | font-size: $input-font-size; 61 | height: $input-height; 62 | line-height: 32px; 63 | outline: 0; 64 | margin: 0; 65 | padding: 0 !important; 66 | width: 120px !important; 67 | } 68 | 69 | .input:focus { 70 | border: 0 !important; 71 | box-shadow: none !important; 72 | } 73 | 74 | // Autocomplete 75 | .autocomplete-content { 76 | margin-top: 0; 77 | margin-bottom: 0; 78 | } 79 | } 80 | 81 | // Form prefix 82 | .prefix ~ .chips { 83 | margin-left: 3rem; 84 | width: 92%; 85 | width: calc(100% - 3rem); 86 | } 87 | .chips:empty ~ label { 88 | font-size: 0.8rem; 89 | transform: translateY(-140%); 90 | } 91 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_collapsible.scss: -------------------------------------------------------------------------------- 1 | .collapsible { 2 | border-top: 1px solid $collapsible-border-color; 3 | border-right: 1px solid $collapsible-border-color; 4 | border-left: 1px solid $collapsible-border-color; 5 | margin: $element-top-margin 0 $element-bottom-margin 0; 6 | @extend .z-depth-1; 7 | } 8 | 9 | .collapsible-header { 10 | &:focus { 11 | outline: 0 12 | } 13 | 14 | display: flex; 15 | cursor: pointer; 16 | -webkit-tap-highlight-color: transparent; 17 | line-height: 1.5; 18 | padding: 1rem; 19 | background-color: $collapsible-header-color; 20 | border-bottom: 1px solid $collapsible-border-color; 21 | 22 | i { 23 | width: 2rem; 24 | font-size: 1.6rem; 25 | display: inline-block; 26 | text-align: center; 27 | margin-right: 1rem; 28 | } 29 | } 30 | .keyboard-focused .collapsible-header:focus { 31 | background-color: #eee; 32 | } 33 | 34 | .collapsible-body { 35 | display: none; 36 | border-bottom: 1px solid $collapsible-border-color; 37 | box-sizing: border-box; 38 | padding: 2rem; 39 | } 40 | 41 | // Sidenav collapsible styling 42 | .sidenav, 43 | .sidenav.fixed { 44 | 45 | .collapsible { 46 | border: none; 47 | box-shadow: none; 48 | 49 | li { padding: 0; } 50 | } 51 | 52 | .collapsible-header { 53 | background-color: transparent; 54 | border: none; 55 | line-height: inherit; 56 | height: inherit; 57 | padding: 0 $sidenav-padding; 58 | 59 | &:hover { background-color: rgba(0,0,0,.05); } 60 | i { line-height: inherit; } 61 | } 62 | 63 | .collapsible-body { 64 | border: 0; 65 | background-color: $collapsible-header-color; 66 | 67 | li a { 68 | padding: 0 (7.5px + $sidenav-padding) 69 | 0 (15px + $sidenav-padding); 70 | } 71 | } 72 | 73 | } 74 | 75 | // Popout Collapsible 76 | 77 | .collapsible.popout { 78 | border: none; 79 | box-shadow: none; 80 | > li { 81 | box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12); 82 | // transform: scaleX(.92); 83 | margin: 0 24px; 84 | transition: margin .35s cubic-bezier(0.250, 0.460, 0.450, 0.940); 85 | } 86 | > li.active { 87 | box-shadow: 0 5px 11px 0 rgba(0, 0, 0, 0.18), 0 4px 15px 0 rgba(0, 0, 0, 0.15); 88 | margin: 16px 0; 89 | // transform: scaleX(1); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_color-classes.scss: -------------------------------------------------------------------------------- 1 | // Color Classes 2 | 3 | @each $color_name, $color in $colors { 4 | @each $color_type, $color_value in $color { 5 | @if $color_type == "base" { 6 | .#{$color_name} { 7 | background-color: $color_value !important; 8 | } 9 | .#{$color_name}-text { 10 | color: $color_value !important; 11 | } 12 | } 13 | @else if $color_name != "shades" { 14 | .#{$color_name}.#{$color_type} { 15 | background-color: $color_value !important; 16 | } 17 | .#{$color_name}-text.text-#{$color_type} { 18 | color: $color_value !important; 19 | } 20 | } 21 | } 22 | } 23 | 24 | // Shade classes 25 | @each $color, $color_value in $shades { 26 | .#{$color} { 27 | background-color: $color_value !important; 28 | } 29 | .#{$color}-text { 30 | color: $color_value !important; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_dropdown.scss: -------------------------------------------------------------------------------- 1 | .dropdown-content { 2 | &:focus { 3 | outline: 0; 4 | } 5 | 6 | 7 | @extend .z-depth-1; 8 | background-color: $dropdown-bg-color; 9 | margin: 0; 10 | display: none; 11 | min-width: 100px; 12 | overflow-y: auto; 13 | opacity: 0; 14 | position: absolute; 15 | left: 0; 16 | top: 0; 17 | z-index: 9999; // TODO: Check if this doesn't break other things 18 | transform-origin: 0 0; 19 | 20 | 21 | li { 22 | &:hover, &.active { 23 | background-color: $dropdown-hover-bg-color; 24 | } 25 | 26 | &:focus { 27 | outline: none; 28 | } 29 | 30 | &.divider { 31 | min-height: 0; 32 | height: 1px; 33 | } 34 | 35 | & > a, & > span { 36 | font-size: 16px; 37 | color: $dropdown-color; 38 | display: block; 39 | line-height: 22px; 40 | padding: (($dropdown-item-height - 22) / 2) 16px; 41 | } 42 | 43 | & > span > label { 44 | top: 1px; 45 | left: 0; 46 | height: 18px; 47 | } 48 | 49 | // Icon alignment override 50 | & > a > i { 51 | height: inherit; 52 | line-height: inherit; 53 | float: left; 54 | margin: 0 24px 0 0; 55 | width: 24px; 56 | } 57 | 58 | 59 | clear: both; 60 | color: $off-black; 61 | cursor: pointer; 62 | min-height: $dropdown-item-height; 63 | line-height: 1.5rem; 64 | width: 100%; 65 | text-align: left; 66 | } 67 | } 68 | 69 | body.keyboard-focused { 70 | .dropdown-content li:focus { 71 | background-color: darken($dropdown-hover-bg-color, 8%); 72 | } 73 | } 74 | 75 | // Input field specificity bugfix 76 | .input-field.col .dropdown-content [type="checkbox"] + label { 77 | top: 1px; 78 | left: 0; 79 | height: 18px; 80 | transform: none; 81 | } 82 | 83 | .dropdown-trigger { 84 | cursor: pointer; 85 | } -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_icons-material-design.scss: -------------------------------------------------------------------------------- 1 | /* This is needed for some mobile phones to display the Google Icon font properly */ 2 | .material-icons { 3 | text-rendering: optimizeLegibility; 4 | font-feature-settings: 'liga'; 5 | } 6 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_materialbox.scss: -------------------------------------------------------------------------------- 1 | .materialboxed { 2 | &:hover { 3 | &:not(.active) { 4 | opacity: .8; 5 | } 6 | } 7 | 8 | display: block; 9 | cursor: zoom-in; 10 | position: relative; 11 | transition: opacity .4s; 12 | -webkit-backface-visibility: hidden; 13 | 14 | &.active { 15 | cursor: zoom-out; 16 | } 17 | } 18 | 19 | #materialbox-overlay { 20 | position:fixed; 21 | top: 0; 22 | right: 0; 23 | bottom: 0; 24 | left: 0; 25 | background-color: #292929; 26 | z-index: 1000; 27 | will-change: opacity; 28 | } 29 | 30 | .materialbox-caption { 31 | position: fixed; 32 | display: none; 33 | color: #fff; 34 | line-height: 50px; 35 | bottom: 0; 36 | left: 0; 37 | width: 100%; 38 | text-align: center; 39 | padding: 0% 15%; 40 | height: 50px; 41 | z-index: 1000; 42 | -webkit-font-smoothing: antialiased; 43 | } -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_modal.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | &:focus { 3 | outline: none; 4 | } 5 | 6 | @extend .z-depth-5; 7 | 8 | display: none; 9 | position: fixed; 10 | left: 0; 11 | right: 0; 12 | background-color: #fafafa; 13 | padding: 0; 14 | max-height: 70%; 15 | width: 55%; 16 | margin: auto; 17 | overflow-y: auto; 18 | 19 | border-radius: 2px; 20 | will-change: top, opacity; 21 | 22 | @media #{$medium-and-down} { 23 | width: 80%; 24 | } 25 | 26 | h1,h2,h3,h4 { 27 | margin-top: 0; 28 | } 29 | 30 | .modal-content { 31 | padding: 24px; 32 | } 33 | .modal-close { 34 | cursor: pointer; 35 | } 36 | 37 | .modal-footer { 38 | border-radius: 0 0 2px 2px; 39 | background-color: #fafafa; 40 | padding: 4px 6px; 41 | height: 56px; 42 | width: 100%; 43 | text-align: right; 44 | 45 | .btn, .btn-flat { 46 | margin: 6px 0; 47 | } 48 | } 49 | } 50 | .modal-overlay { 51 | position: fixed; 52 | z-index: 999; 53 | top: -25%; 54 | left: 0; 55 | bottom: 0; 56 | right: 0; 57 | height: 125%; 58 | width: 100%; 59 | background: #000; 60 | display: none; 61 | 62 | will-change: opacity; 63 | } 64 | 65 | // Modal with fixed action footer 66 | .modal.modal-fixed-footer { 67 | padding: 0; 68 | height: 70%; 69 | 70 | .modal-content { 71 | position: absolute; 72 | height: calc(100% - 56px); 73 | max-height: 100%; 74 | width: 100%; 75 | overflow-y: auto; 76 | } 77 | 78 | .modal-footer { 79 | border-top: 1px solid rgba(0,0,0,.1); 80 | position: absolute; 81 | bottom: 0; 82 | } 83 | } 84 | 85 | // Modal Bottom Sheet Style 86 | .modal.bottom-sheet { 87 | top: auto; 88 | bottom: -100%; 89 | margin: 0; 90 | width: 100%; 91 | max-height: 45%; 92 | border-radius: 0; 93 | will-change: bottom, opacity; 94 | } 95 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_pulse.scss: -------------------------------------------------------------------------------- 1 | .pulse { 2 | &::before { 3 | content: ''; 4 | display: block; 5 | position: absolute; 6 | width: 100%; 7 | height: 100%; 8 | top: 0; 9 | left: 0; 10 | background-color: inherit; 11 | border-radius: inherit; 12 | transition: opacity .3s, transform .3s; 13 | animation: pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite; 14 | z-index: -1; 15 | } 16 | 17 | overflow: visible; 18 | position: relative; 19 | } 20 | 21 | @keyframes pulse-animation { 22 | 0% { 23 | opacity: 1; 24 | transform: scale(1); 25 | } 26 | 50% { 27 | opacity: 0; 28 | transform: scale(1.5); 29 | } 30 | 100% { 31 | opacity: 0; 32 | transform: scale(1.5); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_slider.scss: -------------------------------------------------------------------------------- 1 | .slider { 2 | position: relative; 3 | height: 400px; 4 | width: 100%; 5 | 6 | // Fullscreen slider 7 | &.fullscreen { 8 | height: 100%; 9 | width: 100%; 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | right: 0; 14 | bottom: 0; 15 | 16 | ul.slides { 17 | height: 100%; 18 | } 19 | 20 | ul.indicators { 21 | z-index: 2; 22 | bottom: 30px; 23 | } 24 | } 25 | 26 | .slides { 27 | background-color: $slider-bg-color; 28 | margin: 0; 29 | height: 400px; 30 | 31 | li { 32 | opacity: 0; 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | z-index: 1; 37 | width: 100%; 38 | height: inherit; 39 | overflow: hidden; 40 | 41 | img { 42 | height: 100%; 43 | width: 100%; 44 | background-size: cover; 45 | background-position: center; 46 | } 47 | 48 | .caption { 49 | color: #fff; 50 | position: absolute; 51 | top: 15%; 52 | left: 15%; 53 | width: 70%; 54 | opacity: 0; 55 | 56 | p { color: $slider-bg-color-light; } 57 | } 58 | 59 | &.active { 60 | z-index: 2; 61 | } 62 | } 63 | } 64 | 65 | 66 | .indicators { 67 | position: absolute; 68 | text-align: center; 69 | left: 0; 70 | right: 0; 71 | bottom: 0; 72 | margin: 0; 73 | 74 | .indicator-item { 75 | display: inline-block; 76 | position: relative; 77 | cursor: pointer; 78 | height: 16px; 79 | width: 16px; 80 | margin: 0 12px; 81 | background-color: $slider-bg-color-light; 82 | 83 | transition: background-color .3s; 84 | border-radius: 50%; 85 | 86 | &.active { 87 | background-color: $slider-indicator-color; 88 | } 89 | } 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_table_of_contents.scss: -------------------------------------------------------------------------------- 1 | /*************** 2 | Nav List 3 | ***************/ 4 | .table-of-contents { 5 | &.fixed { 6 | position: fixed; 7 | } 8 | 9 | li { 10 | padding: 2px 0; 11 | } 12 | a { 13 | display: inline-block; 14 | font-weight: 300; 15 | color: #757575; 16 | padding-left: 16px; 17 | height: 1.5rem; 18 | line-height: 1.5rem; 19 | letter-spacing: .4; 20 | display: inline-block; 21 | 22 | &:hover { 23 | color: lighten(#757575, 20%); 24 | padding-left: 15px; 25 | border-left: 1px solid $primary-color; 26 | } 27 | &.active { 28 | font-weight: 500; 29 | padding-left: 14px; 30 | border-left: 2px solid $primary-color; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_tabs.scss: -------------------------------------------------------------------------------- 1 | .tabs { 2 | &.tabs-transparent { 3 | background-color: transparent; 4 | 5 | .tab a, 6 | .tab.disabled a, 7 | .tab.disabled a:hover { 8 | color: rgba(255,255,255,0.7); 9 | } 10 | 11 | .tab a:hover, 12 | .tab a.active { 13 | color: #fff; 14 | } 15 | 16 | .indicator { 17 | background-color: #fff; 18 | } 19 | } 20 | 21 | &.tabs-fixed-width { 22 | display: flex; 23 | 24 | .tab { 25 | flex-grow: 1; 26 | } 27 | } 28 | 29 | position: relative; 30 | overflow-x: auto; 31 | overflow-y: hidden; 32 | height: 48px; 33 | width: 100%; 34 | background-color: $tabs-bg-color; 35 | margin: 0 auto; 36 | white-space: nowrap; 37 | 38 | .tab { 39 | display: inline-block; 40 | text-align: center; 41 | line-height: 48px; 42 | height: 48px; 43 | padding: 0; 44 | margin: 0; 45 | text-transform: uppercase; 46 | 47 | a { 48 | &:focus, 49 | &:focus.active { 50 | background-color: transparentize($tabs-underline-color, .8); 51 | outline: none; 52 | } 53 | 54 | &:hover, 55 | &.active { 56 | background-color: transparent; 57 | color: $tabs-text-color; 58 | } 59 | 60 | color: rgba($tabs-text-color, .7); 61 | display: block; 62 | width: 100%; 63 | height: 100%; 64 | padding: 0 24px; 65 | font-size: 14px; 66 | text-overflow: ellipsis; 67 | overflow: hidden; 68 | transition: color .28s ease, background-color .28s ease; 69 | } 70 | 71 | &.disabled a, 72 | &.disabled a:hover { 73 | color: rgba($tabs-text-color, .4); 74 | cursor: default; 75 | } 76 | } 77 | .indicator { 78 | position: absolute; 79 | bottom: 0; 80 | height: 2px; 81 | background-color: $tabs-underline-color; 82 | will-change: left, right; 83 | } 84 | } 85 | 86 | // Fixed Sidenav hide on smaller 87 | @media #{$medium-and-down} { 88 | .tabs { 89 | display: flex; 90 | 91 | .tab { 92 | flex-grow: 1; 93 | 94 | a { 95 | padding: 0 12px; 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_tapTarget.scss: -------------------------------------------------------------------------------- 1 | .tap-target-wrapper { 2 | width: 800px; 3 | height: 800px; 4 | position: fixed; 5 | z-index: 1000; 6 | visibility: hidden; 7 | transition: visibility 0s .3s; 8 | } 9 | 10 | .tap-target-wrapper.open { 11 | visibility: visible; 12 | transition: visibility 0s; 13 | 14 | .tap-target { 15 | transform: scale(1); 16 | opacity: .95; 17 | transition: 18 | transform .3s cubic-bezier(.42,0,.58,1), 19 | opacity .3s cubic-bezier(.42,0,.58,1); 20 | } 21 | 22 | .tap-target-wave::before { 23 | transform: scale(1); 24 | } 25 | .tap-target-wave::after { 26 | visibility: visible; 27 | animation: pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite; 28 | transition: 29 | opacity .3s, 30 | transform .3s, 31 | visibility 0s 1s; 32 | } 33 | } 34 | 35 | .tap-target { 36 | position: absolute; 37 | font-size: 1rem; 38 | border-radius: 50%; 39 | background-color: $primary-color; 40 | box-shadow: 0 20px 20px 0 rgba(0,0,0,0.14), 0 10px 50px 0 rgba(0,0,0,0.12), 0 30px 10px -20px rgba(0,0,0,0.2); 41 | width: 100%; 42 | height: 100%; 43 | opacity: 0; 44 | transform: scale(0); 45 | transition: 46 | transform .3s cubic-bezier(.42,0,.58,1), 47 | opacity .3s cubic-bezier(.42,0,.58,1); 48 | } 49 | 50 | .tap-target-content { 51 | position: relative; 52 | display: table-cell; 53 | } 54 | 55 | .tap-target-wave { 56 | &::before, 57 | &::after { 58 | content: ''; 59 | display: block; 60 | position: absolute; 61 | width: 100%; 62 | height: 100%; 63 | border-radius: 50%; 64 | background-color: #ffffff; 65 | } 66 | &::before { 67 | transform: scale(0); 68 | transition: transform .3s; 69 | } 70 | &::after { 71 | visibility: hidden; 72 | transition: 73 | opacity .3s, 74 | transform .3s, 75 | visibility 0s; 76 | z-index: -1; 77 | } 78 | 79 | position: absolute; 80 | border-radius: 50%; 81 | z-index: 10001; 82 | } 83 | 84 | .tap-target-origin { 85 | &:not(.btn), 86 | &:not(.btn):hover { 87 | background: none; 88 | } 89 | 90 | top: 50%; 91 | left: 50%; 92 | transform: translate(-50%,-50%); 93 | 94 | z-index: 10002; 95 | position: absolute !important; 96 | } 97 | 98 | @media only screen and (max-width: 600px) { 99 | .tap-target, .tap-target-wrapper { 100 | width: 600px; 101 | height: 600px; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_toast.scss: -------------------------------------------------------------------------------- 1 | #toast-container { 2 | display:block; 3 | position: fixed; 4 | z-index: 10000; 5 | 6 | @media #{$small-and-down} { 7 | min-width: 100%; 8 | bottom: 0%; 9 | } 10 | @media #{$medium-only} { 11 | left: 5%; 12 | bottom: 7%; 13 | max-width: 90%; 14 | } 15 | @media #{$large-and-up} { 16 | top: 10%; 17 | right: 7%; 18 | max-width: 86%; 19 | } 20 | } 21 | 22 | .toast { 23 | @extend .z-depth-1; 24 | border-radius: 2px; 25 | top: 35px; 26 | width: auto; 27 | margin-top: 10px; 28 | position: relative; 29 | max-width:100%; 30 | height: auto; 31 | min-height: $toast-height; 32 | line-height: 1.5em; 33 | background-color: $toast-color; 34 | padding: 10px 25px; 35 | font-size: 1.1rem; 36 | font-weight: 300; 37 | color: $toast-text-color; 38 | display: flex; 39 | align-items: center; 40 | justify-content: space-between; 41 | cursor: default; 42 | 43 | .toast-action { 44 | color: $toast-action-color; 45 | font-weight: 500; 46 | margin-right: -25px; 47 | margin-left: 3rem; 48 | } 49 | 50 | &.rounded{ 51 | border-radius: 24px; 52 | } 53 | 54 | @media #{$small-and-down} { 55 | width: 100%; 56 | border-radius: 0; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_tooltip.scss: -------------------------------------------------------------------------------- 1 | .material-tooltip { 2 | padding: 10px 8px; 3 | font-size: 1rem; 4 | z-index: 2000; 5 | background-color: transparent; 6 | border-radius: 2px; 7 | color: #fff; 8 | min-height: 36px; 9 | line-height: 120%; 10 | opacity: 0; 11 | position: absolute; 12 | text-align: center; 13 | max-width: calc(100% - 4px); 14 | overflow: hidden; 15 | left: 0; 16 | top: 0; 17 | pointer-events: none; 18 | visibility: hidden; 19 | background-color: #323232; 20 | } 21 | 22 | .backdrop { 23 | position: absolute; 24 | opacity: 0; 25 | height: 7px; 26 | width: 14px; 27 | border-radius: 0 0 50% 50%; 28 | background-color: #323232; 29 | z-index: -1; 30 | transform-origin: 50% 0%; 31 | visibility: hidden; 32 | } 33 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_transitions.scss: -------------------------------------------------------------------------------- 1 | // Scale transition 2 | .scale-transition { 3 | &.scale-out { 4 | transform: scale(0); 5 | transition: transform .2s !important; 6 | } 7 | 8 | &.scale-in { 9 | transform: scale(1); 10 | } 11 | 12 | transition: transform .3s cubic-bezier(0.53, 0.01, 0.36, 1.63) !important; 13 | } -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_typography.scss: -------------------------------------------------------------------------------- 1 | 2 | a { 3 | text-decoration: none; 4 | } 5 | 6 | html{ 7 | line-height: 1.5; 8 | 9 | @media only screen and (min-width: 0) { 10 | font-size: 14px; 11 | } 12 | 13 | @media only screen and (min-width: $medium-screen) { 14 | font-size: 14.5px; 15 | } 16 | 17 | @media only screen and (min-width: $large-screen) { 18 | font-size: 15px; 19 | } 20 | 21 | font-family: $font-stack; 22 | font-weight: normal; 23 | color: $off-black; 24 | } 25 | h1, h2, h3, h4, h5, h6 { 26 | font-weight: 400; 27 | line-height: 1.3; 28 | } 29 | 30 | // Header Styles 31 | h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { font-weight: inherit; } 32 | h1 { font-size: $h1-fontsize; line-height: 110%; margin: ($h1-fontsize / 1.5) 0 ($h1-fontsize / 2.5) 0;} 33 | h2 { font-size: $h2-fontsize; line-height: 110%; margin: ($h2-fontsize / 1.5) 0 ($h2-fontsize / 2.5) 0;} 34 | h3 { font-size: $h3-fontsize; line-height: 110%; margin: ($h3-fontsize / 1.5) 0 ($h3-fontsize / 2.5) 0;} 35 | h4 { font-size: $h4-fontsize; line-height: 110%; margin: ($h4-fontsize / 1.5) 0 ($h4-fontsize / 2.5) 0;} 36 | h5 { font-size: $h5-fontsize; line-height: 110%; margin: ($h5-fontsize / 1.5) 0 ($h5-fontsize / 2.5) 0;} 37 | h6 { font-size: $h6-fontsize; line-height: 110%; margin: ($h6-fontsize / 1.5) 0 ($h6-fontsize / 2.5) 0;} 38 | 39 | // Text Styles 40 | em { font-style: italic; } 41 | strong { font-weight: 500; } 42 | small { font-size: 75%; } 43 | .light { font-weight: 300; } 44 | .thin { font-weight: 200; } 45 | 46 | 47 | .flow-text{ 48 | $i: 0; 49 | @while $i <= $intervals { 50 | @media only screen and (min-width : 360 + ($i * $interval-size)) { 51 | font-size: 1.2rem * (1 + (.02 * $i)); 52 | } 53 | $i: $i + 1; 54 | } 55 | 56 | // Handle below 360px screen 57 | @media only screen and (max-width: 360px) { 58 | font-size: 1.2rem; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/_waves.scss: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Waves v0.6.0 4 | * http://fian.my.id/Waves 5 | * 6 | * Copyright 2014 Alfiana E. Sibuea and other contributors 7 | * Released under the MIT license 8 | * https://github.com/fians/Waves/blob/master/LICENSE 9 | */ 10 | 11 | 12 | .waves-effect { 13 | position: relative; 14 | cursor: pointer; 15 | display: inline-block; 16 | overflow: hidden; 17 | user-select: none; 18 | -webkit-tap-highlight-color: transparent; 19 | vertical-align: middle; 20 | z-index: 1; 21 | transition: .3s ease-out; 22 | 23 | .waves-ripple { 24 | position: absolute; 25 | border-radius: 50%; 26 | width: 20px; 27 | height: 20px; 28 | margin-top:-10px; 29 | margin-left:-10px; 30 | opacity: 0; 31 | 32 | background: rgba(0,0,0,0.2); 33 | transition: all 0.7s ease-out; 34 | transition-property: transform, opacity; 35 | transform: scale(0); 36 | pointer-events: none; 37 | } 38 | 39 | // Waves Colors 40 | &.waves-light .waves-ripple { 41 | background-color: rgba(255, 255, 255, 0.45); 42 | } 43 | &.waves-red .waves-ripple { 44 | background-color: rgba(244, 67, 54, .70); 45 | } 46 | &.waves-yellow .waves-ripple { 47 | background-color: rgba(255, 235, 59, .70); 48 | } 49 | &.waves-orange .waves-ripple { 50 | background-color: rgba(255, 152, 0, .70); 51 | } 52 | &.waves-purple .waves-ripple { 53 | background-color: rgba(156, 39, 176, 0.70); 54 | } 55 | &.waves-green .waves-ripple { 56 | background-color: rgba(76, 175, 80, 0.70); 57 | } 58 | &.waves-teal .waves-ripple { 59 | background-color: rgba(0, 150, 136, 0.70); 60 | } 61 | 62 | // Style input button bug. 63 | input[type="button"], input[type="reset"], input[type="submit"] { 64 | border: 0; 65 | font-style: normal; 66 | font-size: inherit; 67 | text-transform: inherit; 68 | background: none; 69 | } 70 | 71 | img { 72 | position: relative; 73 | z-index: -1; 74 | } 75 | } 76 | 77 | .waves-notransition { 78 | transition: none #{"!important"}; 79 | } 80 | 81 | .waves-circle { 82 | transform: translateZ(0); 83 | -webkit-mask-image: -webkit-radial-gradient(circle, white 100%, black 100%); 84 | } 85 | 86 | .waves-input-wrapper { 87 | border-radius: 0.2em; 88 | vertical-align: bottom; 89 | 90 | .waves-button-input { 91 | position: relative; 92 | top: 0; 93 | left: 0; 94 | z-index: 1; 95 | } 96 | } 97 | 98 | .waves-circle { 99 | text-align: center; 100 | width: 2.5em; 101 | height: 2.5em; 102 | line-height: 2.5em; 103 | border-radius: 50%; 104 | -webkit-mask-image: none; 105 | } 106 | 107 | .waves-block { 108 | display: block; 109 | } 110 | 111 | /* Firefox Bug: link not triggered */ 112 | .waves-effect .waves-ripple { 113 | z-index: -1; 114 | } -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/forms/_file-input.scss: -------------------------------------------------------------------------------- 1 | /* File Input 2 | ========================================================================== */ 3 | 4 | .file-field { 5 | position: relative; 6 | 7 | .file-path-wrapper { 8 | overflow: hidden; 9 | padding-left: 10px; 10 | } 11 | 12 | input.file-path { width: 100%; } 13 | 14 | .btn { 15 | float: left; 16 | height: $input-height; 17 | line-height: $input-height; 18 | } 19 | 20 | span { 21 | cursor: pointer; 22 | } 23 | 24 | input[type=file] { 25 | 26 | // Needed to override webkit button 27 | &::-webkit-file-upload-button { 28 | display: none; 29 | } 30 | 31 | position: absolute; 32 | top: 0; 33 | right: 0; 34 | left: 0; 35 | bottom: 0; 36 | width: 100%; 37 | margin: 0; 38 | padding: 0; 39 | font-size: 20px; 40 | cursor: pointer; 41 | opacity: 0; 42 | filter: alpha(opacity=0); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/forms/_forms.scss: -------------------------------------------------------------------------------- 1 | // Remove Focus Boxes 2 | select:focus { 3 | outline: $select-focus; 4 | } 5 | 6 | button:focus { 7 | outline: none; 8 | background-color: $button-background-focus; 9 | } 10 | 11 | label { 12 | font-size: $label-font-size; 13 | color: $input-border-color; 14 | } 15 | 16 | @import 'input-fields'; 17 | @import 'radio-buttons'; 18 | @import 'checkboxes'; 19 | @import 'switches'; 20 | @import 'select'; 21 | @import 'file-input'; 22 | @import 'range'; 23 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/forms/_radio-buttons.scss: -------------------------------------------------------------------------------- 1 | /* Radio Buttons 2 | ========================================================================== */ 3 | 4 | // Remove default Radio Buttons 5 | [type="radio"]:not(:checked), 6 | [type="radio"]:checked { 7 | position: absolute; 8 | opacity: 0; 9 | pointer-events: none; 10 | } 11 | 12 | [type="radio"]:not(:checked) + span, 13 | [type="radio"]:checked + span { 14 | position: relative; 15 | padding-left: 35px; 16 | cursor: pointer; 17 | display: inline-block; 18 | height: 25px; 19 | line-height: 25px; 20 | font-size: 1rem; 21 | transition: .28s ease; 22 | user-select: none; 23 | } 24 | 25 | [type="radio"] + span:before, 26 | [type="radio"] + span:after { 27 | content: ''; 28 | position: absolute; 29 | left: 0; 30 | top: 0; 31 | margin: 4px; 32 | width: 16px; 33 | height: 16px; 34 | z-index: 0; 35 | transition: .28s ease; 36 | } 37 | 38 | /* Unchecked styles */ 39 | [type="radio"]:not(:checked) + span:before, 40 | [type="radio"]:not(:checked) + span:after, 41 | [type="radio"]:checked + span:before, 42 | [type="radio"]:checked + span:after, 43 | [type="radio"].with-gap:checked + span:before, 44 | [type="radio"].with-gap:checked + span:after { 45 | border-radius: 50%; 46 | } 47 | 48 | [type="radio"]:not(:checked) + span:before, 49 | [type="radio"]:not(:checked) + span:after { 50 | border: 2px solid $radio-empty-color; 51 | } 52 | 53 | [type="radio"]:not(:checked) + span:after { 54 | transform: scale(0); 55 | } 56 | 57 | /* Checked styles */ 58 | [type="radio"]:checked + span:before { 59 | border: 2px solid transparent; 60 | } 61 | 62 | [type="radio"]:checked + span:after, 63 | [type="radio"].with-gap:checked + span:before, 64 | [type="radio"].with-gap:checked + span:after { 65 | border: $radio-border; 66 | } 67 | 68 | [type="radio"]:checked + span:after, 69 | [type="radio"].with-gap:checked + span:after { 70 | background-color: $radio-fill-color; 71 | } 72 | 73 | [type="radio"]:checked + span:after { 74 | transform: scale(1.02); 75 | } 76 | 77 | /* Radio With gap */ 78 | [type="radio"].with-gap:checked + span:after { 79 | transform: scale(.5); 80 | } 81 | 82 | /* Focused styles */ 83 | [type="radio"].tabbed:focus + span:before { 84 | box-shadow: 0 0 0 10px rgba(0,0,0,.1); 85 | } 86 | 87 | /* Disabled Radio With gap */ 88 | [type="radio"].with-gap:disabled:checked + span:before { 89 | border: 2px solid $input-disabled-color; 90 | } 91 | 92 | [type="radio"].with-gap:disabled:checked + span:after { 93 | border: none; 94 | background-color: $input-disabled-color; 95 | } 96 | 97 | /* Disabled style */ 98 | [type="radio"]:disabled:not(:checked) + span:before, 99 | [type="radio"]:disabled:checked + span:before { 100 | background-color: transparent; 101 | border-color: $input-disabled-color; 102 | } 103 | 104 | [type="radio"]:disabled + span { 105 | color: $input-disabled-color; 106 | } 107 | 108 | [type="radio"]:disabled:not(:checked) + span:before { 109 | border-color: $input-disabled-color; 110 | } 111 | 112 | [type="radio"]:disabled:checked + span:after { 113 | background-color: $input-disabled-color; 114 | border-color: $input-disabled-solid-color; 115 | } 116 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/components/forms/_switches.scss: -------------------------------------------------------------------------------- 1 | /* Switch 2 | ========================================================================== */ 3 | 4 | .switch, 5 | .switch * { 6 | -webkit-tap-highlight-color: transparent; 7 | user-select: none; 8 | } 9 | 10 | .switch label { 11 | cursor: pointer; 12 | } 13 | 14 | .switch label input[type=checkbox] { 15 | opacity: 0; 16 | width: 0; 17 | height: 0; 18 | 19 | &:checked + .lever { 20 | background-color: $switch-checked-lever-bg; 21 | 22 | &:before, &:after { 23 | left: 18px; 24 | } 25 | 26 | &:after { 27 | background-color: $switch-bg-color; 28 | } 29 | } 30 | } 31 | 32 | .switch label .lever { 33 | content: ""; 34 | display: inline-block; 35 | position: relative; 36 | width: 36px; 37 | height: 14px; 38 | background-color: $switch-unchecked-lever-bg; 39 | border-radius: $switch-radius; 40 | margin-right: 10px; 41 | transition: background 0.3s ease; 42 | vertical-align: middle; 43 | margin: 0 16px; 44 | 45 | &:before, &:after { 46 | content: ""; 47 | position: absolute; 48 | display: inline-block; 49 | width: 20px; 50 | height: 20px; 51 | border-radius: 50%; 52 | left: 0; 53 | top: -3px; 54 | transition: left 0.3s ease, background .3s ease, box-shadow 0.1s ease, transform .1s ease; 55 | } 56 | 57 | &:before { 58 | background-color: transparentize($switch-bg-color, .85); 59 | } 60 | 61 | &:after { 62 | background-color: $switch-unchecked-bg; 63 | box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12); 64 | } 65 | } 66 | 67 | // Switch active style 68 | input[type=checkbox]:checked:not(:disabled) ~ .lever:active::before, 69 | input[type=checkbox]:checked:not(:disabled).tabbed:focus ~ .lever::before { 70 | transform: scale(2.4); 71 | background-color: transparentize($switch-bg-color, .85); 72 | } 73 | 74 | input[type=checkbox]:not(:disabled) ~ .lever:active:before, 75 | input[type=checkbox]:not(:disabled).tabbed:focus ~ .lever::before { 76 | transform: scale(2.4); 77 | background-color: rgba(0,0,0,.08); 78 | } 79 | 80 | // Disabled Styles 81 | .switch input[type=checkbox][disabled] + .lever { 82 | cursor: default; 83 | background-color: rgba(0,0,0,.12); 84 | } 85 | 86 | .switch label input[type=checkbox][disabled] + .lever:after, 87 | .switch label input[type=checkbox][disabled]:checked + .lever:after { 88 | background-color: $input-disabled-solid-color; 89 | } 90 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/materializecss/materialize.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | // Color 4 | @import "components/color-variables"; 5 | @import "components/color-classes"; 6 | 7 | // Variables; 8 | @import "components/variables"; 9 | 10 | // Reset 11 | @import "components/normalize"; 12 | 13 | // components 14 | @import "components/global"; 15 | @import "components/badges"; 16 | @import "components/icons-material-design"; 17 | @import "components/grid"; 18 | @import "components/navbar"; 19 | @import "components/typography"; 20 | @import "components/transitions"; 21 | @import "components/cards"; 22 | @import "components/toast"; 23 | @import "components/tabs"; 24 | @import "components/tooltip"; 25 | @import "components/buttons"; 26 | @import "components/dropdown"; 27 | @import "components/waves"; 28 | @import "components/modal"; 29 | @import "components/collapsible"; 30 | @import "components/chips"; 31 | @import "components/materialbox"; 32 | @import "components/forms/forms"; 33 | @import "components/table_of_contents"; 34 | @import "components/sidenav"; 35 | @import "components/preloader"; 36 | @import "components/slider"; 37 | @import "components/carousel"; 38 | @import "components/tapTarget"; 39 | @import "components/pulse"; 40 | @import "components/datepicker"; 41 | @import "components/timepicker"; 42 | -------------------------------------------------------------------------------- /tubesync/common/static/styles/tubesync.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | @import "materializecss/materialize"; 4 | @import "fontawesome/fontawesome"; 5 | @import "fontawesome/regular"; 6 | @import "fontawesome/solid"; 7 | @import "fontawesome/brands"; 8 | 9 | @import "fonts"; 10 | @import "variables"; 11 | @import "colours"; 12 | @import "helpers"; 13 | @import "forms"; 14 | @import "template"; 15 | 16 | html { 17 | visibility: visible; 18 | opacity: 1; 19 | } 20 | 21 | .flex-collection-container { 22 | display: flex !important; 23 | align-items: center; 24 | } 25 | 26 | .flex-grow { 27 | flex-grow: 1; 28 | } 29 | 30 | .help-text > i { 31 | padding-right: 6px; 32 | } 33 | 34 | .issue-641 { 35 | display: block !important; 36 | overflow-wrap: anywhere; 37 | } 38 | -------------------------------------------------------------------------------- /tubesync/common/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %}{% load sass_tags %} 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | TubeSync - {% block headtitle %}Synchronize YouTube to your local media server{% endblock %} 15 | 16 | 17 | 18 | 19 |
20 | 21 |
22 | 28 |
29 | 30 | 41 | 42 |
43 |
44 | {% block content %}{% endblock %} 45 |
46 |
47 | 48 |
49 | 50 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /tubesync/common/templates/error403.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 403 - Forbidden 4 | 5 | 6 |

403 - Forbidden

7 |

Your request was denied. You do not have access to the requested resource.

8 | 9 | 10 | -------------------------------------------------------------------------------- /tubesync/common/templates/error404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 404 - Page Not Found 4 | 5 | 6 |

404 - Page Not Found

7 |

The resource you have requested does not exist.

8 | 9 | 10 | -------------------------------------------------------------------------------- /tubesync/common/templates/error500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 500 - Internal Server Error 4 | 5 | 6 |

500 - Internal Server Error

7 |

Your request caused an internal server error. This has been logged and our developers will implement a fix shortly.

8 | 9 | 10 | -------------------------------------------------------------------------------- /tubesync/common/templates/errorbox.html: -------------------------------------------------------------------------------- 1 | {% if message %} 2 |
3 |
4 |
5 |
6 | {{ message|safe }} 7 |
8 |
9 |
10 |
11 | {% endif %} 12 | -------------------------------------------------------------------------------- /tubesync/common/templates/infobox.html: -------------------------------------------------------------------------------- 1 | {% if message %} 2 |
3 |
4 |
5 |
6 | {{ message|safe }} 7 |
8 |
9 |
10 |
11 | {% endif %} 12 | -------------------------------------------------------------------------------- /tubesync/common/templates/pagination.html: -------------------------------------------------------------------------------- 1 | {% if paginator.num_pages > 1 %} 2 |
3 |
4 | 9 |
10 |
11 | {% endif %} 12 | -------------------------------------------------------------------------------- /tubesync/common/templates/simpleform.html: -------------------------------------------------------------------------------- 1 | {% if form %} 2 | {% if form.errors %} 3 | 8 | {% endif %} 9 | {% for field in form %} 10 | {% if field.field.widget.input_type == 'hidden' %}{{ field }}{% else %} 11 |
12 |
13 | {% if field.field.widget.input_type == 'checkbox' %} 14 | 18 | {% else %} 19 | {{ field.label_tag }} 20 | {{ field }} 21 | {% endif %} 22 | {% if field.help_text %} {{ field.help_text }}{% endif %} 23 |
24 |
25 | {% endif %} 26 | {% endfor %} 27 | {% endif %} 28 | -------------------------------------------------------------------------------- /tubesync/common/templates/tubesync.svg: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /tubesync/common/testutils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def prevent_request_warnings(original_function): 5 | ''' 6 | Suppress errors from views that raise legitimate errors, such as 7 | testing that a page does indeed 404 or a non-authenticated user 8 | cannot access page requiring authentication which raises a 403. You 9 | can wrap test methods with this to drop the error logging down a notch. 10 | ''' 11 | 12 | def new_function(*args, **kwargs): 13 | logger = logging.getLogger('django.request') 14 | previous_logging_level = logger.getEffectiveLevel() 15 | logger.setLevel(logging.CRITICAL) 16 | original_function(*args, **kwargs) 17 | logger.setLevel(previous_logging_level) 18 | 19 | return new_function 20 | -------------------------------------------------------------------------------- /tubesync/common/third_party_versions.py: -------------------------------------------------------------------------------- 1 | from yt_dlp import version as yt_dlp_version 2 | 3 | 4 | yt_dlp_version = str(yt_dlp_version.__version__) 5 | ffmpeg_version = '(shared install)' 6 | 7 | 8 | # This file may contain data dynamically written during the container build process 9 | # that replaces the above versions. Do not edit below this line 10 | -------------------------------------------------------------------------------- /tubesync/common/timestamp.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | utc_tz = datetime.timezone.utc 5 | posix_epoch = datetime.datetime.fromtimestamp(0, utc_tz) 6 | 7 | 8 | def add_epoch(seconds): 9 | assert seconds is not None 10 | assert seconds >= 0, 'seconds must be a positive number' 11 | 12 | return datetime.timedelta(seconds=seconds) + posix_epoch 13 | 14 | def subtract_epoch(arg_dt, /): 15 | assert isinstance(arg_dt, datetime.datetime) 16 | utc_dt = arg_dt.astimezone(utc_tz) 17 | 18 | return utc_dt - posix_epoch 19 | 20 | def datetime_to_timestamp(arg_dt, /, *, integer=True): 21 | timestamp = subtract_epoch(arg_dt).total_seconds() 22 | 23 | if not integer: 24 | return timestamp 25 | return round(timestamp) 26 | 27 | def timestamp_to_datetime(seconds, /): 28 | return add_epoch(seconds=seconds).astimezone(utc_tz) 29 | 30 | -------------------------------------------------------------------------------- /tubesync/common/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import path 3 | from django.views.generic.base import RedirectView 4 | from django.http import HttpResponse 5 | from .views import error403, error404, error500, HealthCheckView 6 | 7 | 8 | app_name = 'common' 9 | robots_view = HttpResponse(settings.ROBOTS, content_type='text/plain') 10 | favicon_uri = settings.STATIC_URL + 'images/favicon.ico' 11 | favicon_view = RedirectView.as_view(url=favicon_uri, permanent=False) 12 | 13 | 14 | urlpatterns = [ 15 | 16 | path('error403', 17 | error403, 18 | name='error403'), 19 | 20 | path('error404', 21 | error404, 22 | name='error404'), 23 | 24 | path('error500', 25 | error500, 26 | name='error500'), 27 | 28 | path('robots.txt', 29 | lambda r: robots_view, 30 | name='robots'), 31 | 32 | path('favicon.ico', 33 | favicon_view, 34 | name='favicon'), 35 | 36 | path('healthcheck', 37 | HealthCheckView.as_view(), 38 | name='healthcheck'), 39 | 40 | ] 41 | -------------------------------------------------------------------------------- /tubesync/common/views.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | from django.conf import settings 3 | from django.shortcuts import render 4 | from django.views.generic import View 5 | from django.http import HttpResponse, HttpResponseServerError 6 | from django.core.exceptions import PermissionDenied 7 | from django.db import connection 8 | from .utils import get_client_ip 9 | 10 | 11 | def error403(request, *args, **kwargs): 12 | return render(request, 'error403.html', status=403) 13 | 14 | 15 | def error404(request, *args, **kwargs): 16 | return render(request, 'error404.html', status=404) 17 | 18 | 19 | def error500(request, *args, **kwargs): 20 | return render(request, 'error500.html', status=500) 21 | 22 | 23 | class HealthCheckView(View): 24 | ''' 25 | A basic healthcheck view. SELECTs a random int via the database connection 26 | and verifies it matches. This checks that the application server, django and 27 | the database connection are all working correctly. 28 | ''' 29 | 30 | ALLOWED_IPS = settings.HEALTHCHECK_ALLOWED_IPS 31 | 32 | def get(self, request, *args, **kwargs): 33 | if settings.HEALTHCHECK_FIREWALL: 34 | client_ip = get_client_ip(request) 35 | if client_ip not in self.ALLOWED_IPS: 36 | raise PermissionDenied 37 | randomint = int(random() * (10 ** 10)) 38 | with connection.cursor() as cursor: 39 | cursor.execute('select {}'.format(randomint)) 40 | row = cursor.fetchone() 41 | try: 42 | pong = row[0] 43 | except IndexError: 44 | pong = False 45 | if str(pong) != str(randomint): 46 | err = 'Failed healtcheck, expected "{}" got "{}"' 47 | return HttpResponseServerError(err.format(randomint, pong)) 48 | else: 49 | return HttpResponse('ok') 50 | -------------------------------------------------------------------------------- /tubesync/full_playlist.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | playlist_id="${1}" 4 | total_entries="${2}" 5 | 6 | # select YOUTUBE_*DIR settings 7 | # convert None to '' 8 | # convert PosixPath('VALUE') to 'VALUE' 9 | # assign a shell variable with the setting name and value 10 | _awk_prog='$2 == "=" && $1 ~ /^YOUTUBE_/ && $1 ~ /DIR$/ { 11 | sub(/^None$/, "'\'\''", $3); 12 | r = sub(/^PosixPath[(]/, "", $3); 13 | NF--; 14 | if(r) {sub(/[)]$/, "", $NF);}; 15 | $3=$1 $2 $3; $1=$2=""; sub("^" OFS "+", ""); 16 | print; 17 | }' 18 | . <(python3 /app/manage.py diffsettings --output hash | awk "${_awk_prog}") 19 | WHERE="${YOUTUBE_DL_CACHEDIR:-/dev/shm}" 20 | 21 | downloaded_entries="$( find /dev/shm "${WHERE}" \ 22 | -path '*/infojson/playlist/postprocessor_*_temp\.info\.json' \ 23 | -name "postprocessor_[[]${playlist_id}[]]_*_${total_entries}_temp\.info\.json" \ 24 | -exec basename '{}' ';' | \ 25 | sed -e 's/^postprocessor_[[].*[]]_//;s/_temp.*\.json$//;' | \ 26 | cut -d '_' -f 1 )" 27 | 28 | find /dev/shm "${WHERE}" \ 29 | -path '*/infojson/playlist/postprocessor_*_temp\.info\.json' \ 30 | -name "postprocessor_[[]${playlist_id}[]]_*_temp\.info\.json" \ 31 | -type f -delete 32 | 33 | if [ 'NA' != "${downloaded_entries:=${3:-NA}}" ] && 34 | [ 'NA' != "${total_entries:-NA}" ] && 35 | [ "${downloaded_entries}" != "${total_entries}" ] 36 | then 37 | exit 1 38 | fi 39 | 40 | exit 0 41 | -------------------------------------------------------------------------------- /tubesync/healthcheck.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | ''' 3 | 4 | Perform an HTTP request to a URL and exit with an exit code of 1 if the 5 | request did not return an HTTP/200 status code. 6 | 7 | Usage: 8 | $ ./healthcheck.py http://some.url.here/healthcheck/resource 9 | 10 | ''' 11 | 12 | 13 | import os 14 | import sys 15 | import requests 16 | 17 | 18 | TIMEOUT = 5 # Seconds 19 | HTTP_USER = os.getenv('HTTP_USER') 20 | HTTP_PASS = os.getenv('HTTP_PASS') 21 | # never use proxy for healthcheck requests 22 | os.environ['no_proxy'] = '*' 23 | 24 | 25 | def do_heatlhcheck(url): 26 | headers = {'User-Agent': 'healthcheck'} 27 | auth = None 28 | if HTTP_USER and HTTP_PASS: 29 | auth = (HTTP_USER, HTTP_PASS) 30 | response = requests.get(url, headers=headers, auth=auth, timeout=TIMEOUT) 31 | return response.status_code == 200 32 | 33 | 34 | if __name__ == '__main__': 35 | # if it is marked as intentionally down, nothing else matters 36 | if os.path.exists('/run/service/gunicorn/down'): 37 | sys.exit(0) 38 | try: 39 | url = sys.argv[1] 40 | except IndexError: 41 | sys.stderr.write('URL must be supplied\n') 42 | sys.exit(1) 43 | if do_heatlhcheck(url): 44 | sys.exit(0) 45 | else: 46 | sys.exit(1) 47 | -------------------------------------------------------------------------------- /tubesync/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tubesync.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError('Unable to import django, is it installed?') from exc 14 | execute_from_command_line(sys.argv) 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /tubesync/restart_services.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | dir='/run/service' 4 | svc_path() ( 5 | cd "${dir}" 6 | realpath -e -s "$@" 7 | ) 8 | 9 | if [ 0 -eq $# ] 10 | then 11 | set -- \ 12 | $( cd "${dir}" && svc_path tubesync*-worker ) \ 13 | "$( svc_path gunicorn )" \ 14 | "$( svc_path nginx )" 15 | fi 16 | 17 | for service in $( svc_path "$@" ) 18 | do 19 | printf -- 'Restarting %-28s' "${service#${dir}/}..." 20 | _began="$( date '+%s' )" 21 | /command/s6-svc -wr -r "${service}" 22 | _ended="$( date '+%s' )" 23 | printf -- '\tcompleted (in %2.1d seconds).\n' \ 24 | "$( expr "${_ended}" - "${_began}" )" 25 | done 26 | unset -v _began _ended service 27 | -------------------------------------------------------------------------------- /tubesync/sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/sync/__init__.py -------------------------------------------------------------------------------- /tubesync/sync/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import ( 3 | Source, 4 | Media, 5 | Metadata, 6 | MetadataFormat, 7 | MediaServer 8 | ) 9 | 10 | 11 | @admin.register(Source) 12 | class SourceAdmin(admin.ModelAdmin): 13 | 14 | ordering = ('-created',) 15 | list_display = ('uuid', 'name', 'source_type', 'last_crawl', 16 | 'download_media', 'has_failed') 17 | readonly_fields = ('uuid', 'created') 18 | search_fields = ('uuid', 'key', 'name') 19 | 20 | 21 | @admin.register(Media) 22 | class MediaAdmin(admin.ModelAdmin): 23 | 24 | ordering = ('-created',) 25 | list_display = ('uuid', 'key', 'source', 'can_download', 'skip', 'downloaded') 26 | readonly_fields = ('uuid', 'created') 27 | search_fields = ('uuid', 'source__key', 'key') 28 | 29 | 30 | @admin.register(Metadata) 31 | class MetadataAdmin(admin.ModelAdmin): 32 | 33 | ordering = ('-retrieved', '-created', '-uploaded') 34 | list_display = ('uuid', 'key', 'retrieved', 'uploaded', 'created', 'site') 35 | readonly_fields = ('uuid', 'created', 'retrieved') 36 | search_fields = ('uuid', 'media__uuid', 'key') 37 | 38 | 39 | @admin.register(MetadataFormat) 40 | class MetadataFormatAdmin(admin.ModelAdmin): 41 | 42 | ordering = ('site', 'key', 'number') 43 | list_display = ('uuid', 'key', 'site', 'number', 'metadata') 44 | readonly_fields = ('uuid', 'metadata', 'site', 'key', 'number') 45 | search_fields = ('uuid', 'metadata__uuid', 'metadata__media__uuid', 'key') 46 | 47 | 48 | @admin.register(MediaServer) 49 | class MediaServerAdmin(admin.ModelAdmin): 50 | 51 | ordering = ('host', 'port') 52 | list_display = ('pk', 'server_type', 'host', 'port', 'use_https', 'verify_https') 53 | search_fields = ('host',) 54 | -------------------------------------------------------------------------------- /tubesync/sync/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SyncConfig(AppConfig): 5 | 6 | name = 'sync' 7 | -------------------------------------------------------------------------------- /tubesync/sync/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/sync/management/__init__.py -------------------------------------------------------------------------------- /tubesync/sync/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/sync/management/commands/__init__.py -------------------------------------------------------------------------------- /tubesync/sync/management/commands/delete-source.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.core.management.base import BaseCommand, CommandError 3 | from django.db.transaction import atomic 4 | from django.utils.translation import gettext_lazy as _ 5 | from common.logger import log 6 | from sync.models import Source 7 | from sync.tasks import schedule_media_servers_update 8 | 9 | 10 | class Command(BaseCommand): 11 | 12 | help = 'Deletes a source by UUID' 13 | 14 | def add_arguments(self, parser): 15 | parser.add_argument('--source', action='store', required=True, help=_('Source UUID')) 16 | 17 | def handle(self, *args, **options): 18 | source_uuid_str = options.get('source', '') 19 | try: 20 | source_uuid = uuid.UUID(source_uuid_str) 21 | except Exception as e: 22 | raise CommandError(f'Failed to parse source UUID: {e}') 23 | log.info(f'Deleting source with UUID: {source_uuid}') 24 | # Fetch the source by UUID 25 | try: 26 | source = Source.objects.get(uuid=source_uuid) 27 | except Source.DoesNotExist: 28 | raise CommandError(f'Source does not exist with ' 29 | f'UUID: {source_uuid}') 30 | # Reconfigure the source to not update the disk or media servers 31 | with atomic(durable=True): 32 | source.deactivate() 33 | # Delete the source, triggering pre-delete signals for each media item 34 | log.info(f'Found source with UUID "{source.uuid}" with name ' 35 | f'"{source.name}" and deleting it, this may take some time!') 36 | log.info(f'Source directory: {source.directory_path}') 37 | with atomic(durable=True): 38 | source.delete() 39 | # Update any media servers 40 | schedule_media_servers_update() 41 | # All done 42 | log.info('Done') 43 | -------------------------------------------------------------------------------- /tubesync/sync/management/commands/list-sources.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError # noqa 2 | from common.logger import log 3 | from sync.models import Source 4 | 5 | 6 | class Command(BaseCommand): 7 | 8 | help = ('Lists sources') 9 | 10 | def handle(self, *args, **options): 11 | log.info('Listing sources...') 12 | for source in Source.objects.all(): 13 | log.info(f' - {source.uuid}: {source.name}') 14 | log.info('Done') 15 | -------------------------------------------------------------------------------- /tubesync/sync/management/commands/reset-metadata.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from common.utils import django_queryset_generator as qs_gen 3 | from sync.models import Media, Metadata 4 | 5 | 6 | from common.logger import log 7 | 8 | 9 | class Command(BaseCommand): 10 | 11 | help = 'Resets all media item metadata' 12 | 13 | def handle(self, *args, **options): 14 | log.info('Resetting all media metadata...') 15 | # Delete all metadata 16 | Metadata.objects.all().delete() 17 | # Trigger the save signal on each media item 18 | for media in qs_gen(Media.objects.filter(metadata__isnull=False)): 19 | media.metadata_clear(save=True) 20 | log.info('Done') 21 | -------------------------------------------------------------------------------- /tubesync/sync/management/commands/reset-tasks.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError # noqa 2 | from django.db.transaction import atomic 3 | from django.utils.translation import gettext_lazy as _ 4 | from background_task.models import Task 5 | from common.logger import log 6 | from sync.models import Source 7 | from sync.tasks import index_source_task, check_source_directory_exists 8 | 9 | 10 | class Command(BaseCommand): 11 | 12 | help = 'Resets all tasks' 13 | 14 | def handle(self, *args, **options): 15 | log.info('Resettings all tasks...') 16 | with atomic(durable=True): 17 | # Delete all tasks 18 | Task.objects.all().delete() 19 | # Iter all sources, creating new tasks 20 | for source in Source.objects.all(): 21 | verbose_name = _('Check download directory exists for source "{}"') 22 | check_source_directory_exists( 23 | str(source.pk), 24 | verbose_name=verbose_name.format(source.name), 25 | ) 26 | # Recreate the initial indexing task 27 | log.info(f'Resetting tasks for source: {source}') 28 | verbose_name = _('Index media from source "{}"') 29 | index_source_task( 30 | str(source.pk), 31 | repeat=source.index_schedule, 32 | schedule=source.task_run_at_dt, 33 | verbose_name=verbose_name.format(source.name), 34 | ) 35 | # This also chains down to call each Media objects .save() as well 36 | source.save() 37 | 38 | log.info('Done') 39 | -------------------------------------------------------------------------------- /tubesync/sync/management/commands/sync-missing-metadata.py: -------------------------------------------------------------------------------- 1 | from shutil import copyfile 2 | from django.core.management.base import BaseCommand, CommandError # noqa 3 | from django.db.models import Q 4 | from common.logger import log 5 | from sync.models import Source, Media 6 | from sync.utils import write_text_file 7 | 8 | 9 | class Command(BaseCommand): 10 | 11 | help = 'Syncs missing metadata (such as nfo files) if source settings are updated' 12 | 13 | def handle(self, *args, **options): 14 | log.info('Syncing missing metadata...') 15 | sources = Source.objects.filter(Q(copy_thumbnails=True) | Q(write_nfo=True)) 16 | for source in sources.order_by('name'): 17 | log.info(f'Finding media for source: {source}') 18 | for item in Media.objects.filter(source=source, downloaded=True): 19 | log.info(f'Checking media for missing metadata: {source} / {item}') 20 | thumbpath = item.thumbpath 21 | if not thumbpath.is_file(): 22 | if item.thumb: 23 | log.info(f'Copying missing thumbnail from: {item.thumb.path} ' 24 | f'to: {thumbpath}') 25 | copyfile(item.thumb.path, thumbpath) 26 | else: 27 | log.error(f'Tried to copy missing thumbnail for {item} but ' 28 | f'the thumbnail has not been downloaded') 29 | nfopath = item.nfopath 30 | if not nfopath.is_file(): 31 | log.info(f'Writing missing NFO file: {nfopath}') 32 | write_text_file(nfopath, item.nfoxml) 33 | log.info('Done') 34 | -------------------------------------------------------------------------------- /tubesync/sync/management/commands/youtube-dl-info.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.core.management.base import BaseCommand, CommandError # noqa 3 | from sync.youtube import get_media_info 4 | from common.json import JSONEncoder 5 | 6 | 7 | class Command(BaseCommand): 8 | 9 | help = 'Displays information obtained by youtube-dl in JSON to the console' 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument('url', type=str) 13 | 14 | def handle(self, *args, **options): 15 | url = options['url'] 16 | self.stdout.write(f'Showing information for URL: {url}') 17 | info = get_media_info(url) 18 | d = json.dumps(info, indent=4, sort_keys=True, cls=JSONEncoder) 19 | self.stdout.write(d) 20 | self.stdout.write('Done') 21 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0002_auto_20201213_0817.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-13 08:17 2 | 3 | import django.core.files.storage 4 | from django.db import migrations, models 5 | import sync.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('sync', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='media', 17 | name='media_file', 18 | field=models.FileField(blank=True, help_text='Media file', max_length=200, null=True, storage=django.core.files.storage.FileSystemStorage(location='/downloads'), upload_to=sync.models.get_media_file_path, verbose_name='media file'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0003_source_copy_thumbnails.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-18 01:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0002_auto_20201213_0817'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='source', 15 | name='copy_thumbnails', 16 | field=models.BooleanField(default=False, help_text='Copy thumbnails with the media, these may be detected and used by some media servers', verbose_name='copy thumbnails'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0004_source_media_format.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-18 01:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0003_source_copy_thumbnails'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='source', 15 | name='media_format', 16 | field=models.CharField(default='{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}', help_text='File format to use for saving files', max_length=200, verbose_name='media format'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0005_auto_20201219_0312.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-19 03:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0004_source_media_format'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='source', 15 | name='source_type', 16 | field=models.CharField(choices=[('c', 'YouTube channel'), ('i', 'YouTube channel by ID'), ('p', 'YouTube playlist')], db_index=True, default='c', help_text='Source type', max_length=1, verbose_name='source type'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0006_source_write_nfo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-19 03:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0005_auto_20201219_0312'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='source', 15 | name='write_nfo', 16 | field=models.BooleanField(default=False, help_text='Write an NFO file with the media, these may be detected and used by some media servers', verbose_name='write nfo'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0007_auto_20201219_0645.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-19 06:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0006_source_write_nfo'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='source', 15 | name='write_nfo', 16 | field=models.BooleanField(default=False, help_text='Write an NFO file in XML with the media info, these may be detected and used by some media servers', verbose_name='write nfo'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0008_source_download_cap.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-19 06:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0007_auto_20201219_0645'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='source', 15 | name='download_cap', 16 | field=models.IntegerField(choices=[(0, 'No cap'), (604800, '1 week (7 days)'), (2592000, '1 month (30 days)'), (7776000, '3 months (90 days)'), (15552000, '6 months (180 days)'), (31536000, '1 year (365 days)'), (63072000, '2 years (730 days)'), (94608000, '3 years (1095 days)'), (157680000, '5 years (1825 days)'), (315360000, '10 years (3650 days)')], default=0, help_text='Do not download media older than this capped date', verbose_name='download cap'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0009_auto_20210218_0442.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-18 04:42 2 | 3 | import django.core.files.storage 4 | from django.db import migrations, models 5 | import sync.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('sync', '0008_source_download_cap'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='source', 17 | name='download_media', 18 | field=models.BooleanField(default=True, help_text='Download media from this source, if not selected the source will only be indexed', verbose_name='download media'), 19 | ), 20 | migrations.AlterField( 21 | model_name='media', 22 | name='media_file', 23 | field=models.FileField(blank=True, help_text='Media file', max_length=200, null=True, storage=django.core.files.storage.FileSystemStorage(location='/home/meeb/Repos/github.com/meeb/tubesync/tubesync/downloads'), upload_to=sync.models.get_media_file_path, verbose_name='media file'), 24 | ), 25 | migrations.AlterField( 26 | model_name='source', 27 | name='media_format', 28 | field=models.CharField(default='{yyyymmdd}_{source}_{title}_{key}_{format}.{ext}', help_text='File format to use for saving files, detailed options at bottom of page.', max_length=200, verbose_name='media format'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0010_auto_20210924_0554.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-24 05:54 2 | 3 | import django.core.files.storage 4 | from django.db import migrations, models 5 | import sync.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('sync', '0009_auto_20210218_0442'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='media', 17 | name='media_file', 18 | field=models.FileField(blank=True, help_text='Media file', max_length=255, null=True, storage=django.core.files.storage.FileSystemStorage(location='/home/meeb/Repos/github.com/meeb/tubesync/tubesync/downloads'), upload_to=sync.models.get_media_file_path, verbose_name='media file'), 19 | ), 20 | migrations.AlterField( 21 | model_name='source', 22 | name='index_schedule', 23 | field=models.IntegerField(choices=[(3600, 'Every hour'), (7200, 'Every 2 hours'), (10800, 'Every 3 hours'), (14400, 'Every 4 hours'), (18000, 'Every 5 hours'), (21600, 'Every 6 hours'), (43200, 'Every 12 hours'), (86400, 'Every 24 hours'), (259200, 'Every 3 days'), (604800, 'Every 7 days'), (0, 'Never')], db_index=True, default=86400, help_text='Schedule of how often to index the source for new media', verbose_name='index schedule'), 24 | ), 25 | migrations.AlterField( 26 | model_name='source', 27 | name='media_format', 28 | field=models.CharField(default='{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}', help_text='File format to use for saving files, detailed options at bottom of page.', max_length=200, verbose_name='media format'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0011_auto_20220201_1654.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-01 16:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0010_auto_20210924_0554'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='source', 15 | name='write_json', 16 | field=models.BooleanField( 17 | default=False, help_text='Write a JSON file with the media info, these may be detected and used by some media servers', verbose_name='write json'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0012_alter_media_downloaded_format.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-04-06 06:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0011_auto_20220201_1654'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='media', 15 | name='downloaded_format', 16 | field=models.CharField(blank=True, help_text='Video format (resolution) of the downloaded media', max_length=30, null=True, verbose_name='downloaded format'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0013_fix_elative_media_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-04-06 06:19 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | def fix_media_file(apps, schema_editor): 8 | Media = apps.get_model('sync', 'Media') 9 | for media in Media.objects.filter(downloaded=True): 10 | download_dir = str(settings.DOWNLOAD_ROOT) 11 | 12 | if media.media_file.name.startswith(download_dir): 13 | media.media_file.name = media.media_file.name[len(download_dir) + 1:] 14 | media.save() 15 | 16 | 17 | class Migration(migrations.Migration): 18 | 19 | dependencies = [ 20 | ('sync', '0012_alter_media_downloaded_format'), 21 | ] 22 | 23 | operations = [ 24 | migrations.RunPython(fix_media_file) 25 | ] 26 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0014_alter_media_media_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-12-28 20:33 2 | 3 | import django.core.files.storage 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import sync.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('sync', '0013_fix_elative_media_file'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='media', 18 | name='media_file', 19 | field=models.FileField(blank=True, help_text='Media file', max_length=255, null=True, storage=django.core.files.storage.FileSystemStorage(base_url='/media-data/', location=str(settings.DOWNLOAD_ROOT)), upload_to=sync.models.get_media_file_path, verbose_name='media file'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0015_auto_20230213_0603.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.17 on 2023-02-13 06:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0014_alter_media_media_file'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='media', 15 | name='manual_skip', 16 | field=models.BooleanField(db_index=True, default=False, help_text='Media marked as "skipped", won\' be downloaded', verbose_name='manual_skip'), 17 | ), 18 | migrations.AlterField( 19 | model_name='media', 20 | name='skip', 21 | field=models.BooleanField(db_index=True, default=False, help_text='INTERNAL FLAG - Media will be skipped and not downloaded', verbose_name='skip'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0016_auto_20230214_2052.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-02-14 20:52 2 | 3 | from django.db import migrations, models 4 | import sync.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sync', '0015_auto_20230213_0603'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='source', 16 | name='embed_metadata', 17 | field=models.BooleanField(default=False, help_text='Embed metadata from source into file', verbose_name='embed metadata'), 18 | ), 19 | migrations.AddField( 20 | model_name='source', 21 | name='embed_thumbnail', 22 | field=models.BooleanField(default=False, help_text='Embed thumbnail into the file', verbose_name='embed thumbnail'), 23 | ), 24 | migrations.AddField( 25 | model_name='source', 26 | name='enable_sponsorblock', 27 | field=models.BooleanField(default=True, help_text='Use SponsorBlock?', verbose_name='enable sponsorblock'), 28 | ), 29 | migrations.AddField( 30 | model_name='source', 31 | name='sponsorblock_categories', 32 | field=sync.fields.CommaSepChoiceField(default='all', possible_choices=(('all', 'All'), ('sponsor', 'Sponsor'), ('intro', 'Intermission/Intro Animation'), ('outro', 'Endcards/Credits'), ('selfpromo', 'Unpaid/Self Promotion'), ('preview', 'Preview/Recap'), ('filler', 'Filler Tangent'), ('interaction', 'Interaction Reminder'), ('music_offtopic', 'Non-Music Section'))), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0017_alter_source_sponsorblock_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-02-20 02:23 2 | 3 | from django.db import migrations 4 | import sync.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sync', '0016_auto_20230214_2052'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='source', 16 | name='sponsorblock_categories', 17 | field=sync.fields.CommaSepChoiceField(default='all', help_text='Select the sponsorblocks you want to enforce', separator=''), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0018_source_subtitles.py: -------------------------------------------------------------------------------- 1 | # Generated by pac 2 | 3 | from django.db import migrations, models 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('sync', '0017_alter_source_sponsorblock_categories'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='source', 14 | name='write_subtitles', 15 | field=models.BooleanField(default=False, help_text='Download video subtitles', verbose_name='write subtitles'), 16 | ), 17 | migrations.AddField( 18 | model_name='source', 19 | name='auto_subtitles', 20 | field=models.BooleanField(default=False, help_text='Accept auto-generated subtitles', verbose_name='accept auto subtitles'), 21 | ), 22 | migrations.AddField( 23 | model_name='source', 24 | name='sub_langs', 25 | field=models.CharField(default='en', help_text='List of subtitles langs to download comma-separated. Example: en,fr',max_length=30), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0019_add_delete_removed_media.py: -------------------------------------------------------------------------------- 1 | # Generated by pac 2 | 3 | from django.db import migrations, models 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('sync', '0018_source_subtitles'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='source', 14 | name='delete_removed_media', 15 | field=models.BooleanField(default=False, help_text='Delete media that is no longer on this playlist', verbose_name='delete removed media'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0020_auto_20231024_1825.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.22 on 2023-10-24 17:25 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sync', '0019_add_delete_removed_media'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='source', 16 | name='filter_text', 17 | field=models.CharField(blank=True, default='', help_text='Regex compatible filter string for video titles', max_length=100, verbose_name='filter string'), 18 | ), 19 | migrations.AlterField( 20 | model_name='source', 21 | name='auto_subtitles', 22 | field=models.BooleanField(default=False, help_text='Accept auto-generated subtitles', verbose_name='accept auto-generated subs'), 23 | ), 24 | migrations.AlterField( 25 | model_name='source', 26 | name='sub_langs', 27 | field=models.CharField(default='en', help_text='List of subtitles langs to download, comma-separated. Example: en,fr or all,-fr,-live_chat', max_length=30, validators=[django.core.validators.RegexValidator(message='Subtitle langs must be a comma-separated list of langs. example: en,fr or all,-fr,-live_chat', regex='^(\\-?[\\_\\.a-zA-Z]+,)*(\\-?[\\_\\.a-zA-Z]+){1}$')], verbose_name='subs langs'), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0021_source_copy_channel_images.py: -------------------------------------------------------------------------------- 1 | # Generated by nothing. Done manually by InterN0te on 2023-12-10 16:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0020_auto_20231024_1825'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='source', 15 | name='copy_channel_images', 16 | field=models.BooleanField(default=False, help_text='Copy channel banner and avatar. These may be detected and used by some media servers', verbose_name='copy channel images'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0022_add_delete_files_on_disk.py: -------------------------------------------------------------------------------- 1 | # Generated by pac 2 | 3 | from django.db import migrations, models 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('sync', '0021_source_copy_channel_images'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='source', 14 | name='delete_files_on_disk', 15 | field=models.BooleanField(default=False, help_text='Delete files on disk when they are removed from TubeSync', verbose_name='delete files on disk'), 16 | ), 17 | ] -------------------------------------------------------------------------------- /tubesync/sync/migrations/0023_media_duration_filter.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("sync", "0022_add_delete_files_on_disk"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="media", 12 | name="title", 13 | field=models.CharField( 14 | verbose_name="title", 15 | max_length=100, 16 | blank=True, 17 | null=False, 18 | default="", 19 | help_text="Video title", 20 | ), 21 | ), 22 | migrations.AddField( 23 | model_name="media", 24 | name="duration", 25 | field=models.PositiveIntegerField( 26 | verbose_name="duration", 27 | blank=True, 28 | null=True, 29 | help_text="Duration of media in seconds", 30 | ), 31 | ), 32 | migrations.AddField( 33 | model_name="source", 34 | name="filter_seconds", 35 | field=models.PositiveIntegerField( 36 | verbose_name="filter seconds", 37 | blank=True, 38 | null=True, 39 | help_text="Filter Media based on Min/Max duration. Leave blank or 0 to disable filtering", 40 | ), 41 | ), 42 | migrations.AddField( 43 | model_name="source", 44 | name="filter_seconds_min", 45 | field=models.BooleanField( 46 | verbose_name="filter seconds min/max", 47 | choices=[(True, "Minimum Length"), (False, "Maximum Length")], 48 | default=True, 49 | help_text="When Filter Seconds is > 0, do we skip on minimum (video shorter than limit) or maximum (" 50 | "video greater than maximum) video duration", 51 | ), 52 | ), 53 | migrations.AddField( 54 | model_name="source", 55 | name="filter_text_invert", 56 | field=models.BooleanField( 57 | verbose_name="invert filter text matching", 58 | default=False, 59 | help_text="Invert filter string regex match, skip any matching titles when selected", 60 | ), 61 | ), 62 | ] 63 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0024_auto_20240717_1535.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2024-07-17 15:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0023_media_duration_filter'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='media', 15 | name='manual_skip', 16 | field=models.BooleanField(db_index=True, default=False, help_text='Media marked as "skipped", won\'t be downloaded', verbose_name='manual_skip'), 17 | ), 18 | migrations.AlterField( 19 | model_name='media', 20 | name='title', 21 | field=models.CharField(blank=True, default='', help_text='Video title', max_length=200, verbose_name='title'), 22 | ), 23 | migrations.AlterField( 24 | model_name='source', 25 | name='filter_text', 26 | field=models.CharField(blank=True, default='', help_text='Regex compatible filter string for video titles', max_length=200, verbose_name='filter string'), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0025_add_video_type_support.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | class Migration(migrations.Migration): 4 | 5 | dependencies = [ 6 | ('sync', '0024_auto_20240717_1535'), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name='source', 12 | name='index_videos', 13 | field=models.BooleanField(default=True, help_text='Index video media from this source', verbose_name='index videos'), 14 | ), 15 | migrations.AddField( 16 | model_name='source', 17 | name='index_streams', 18 | field=models.BooleanField(default=False, help_text='Index live stream media from this source', verbose_name='index streams'), 19 | ), 20 | ] -------------------------------------------------------------------------------- /tubesync/sync/migrations/0026_alter_source_sub_langs.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2024-12-11 12:43 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sync', '0025_add_video_type_support'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='source', 16 | name='sub_langs', 17 | field=models.CharField(default='en', help_text='List of subtitles langs to download, comma-separated. Example: en,fr or all,-fr,-live_chat', max_length=30, validators=[django.core.validators.RegexValidator(message='Subtitle langs must be a comma-separated list of langs. example: en,fr or all,-fr,-live_chat', regex='^(\\-?[\\_\\.a-zA-Z-]+(,|$))+')], verbose_name='subs langs'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0027_alter_source_sponsorblock_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2025-01-29 06:14 2 | 3 | from django.db import migrations 4 | import sync.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sync', '0026_alter_source_sub_langs'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='source', 16 | name='sponsorblock_categories', 17 | field=sync.fields.CommaSepChoiceField(all_choice='all', all_label='(All Categories)', allow_all=True, default='all', help_text='Select the SponsorBlock categories that you wish to be removed from downloaded videos.', max_length=128, possible_choices=[('sponsor', 'Sponsor'), ('intro', 'Intermission/Intro Animation'), ('outro', 'Endcards/Credits'), ('selfpromo', 'Unpaid/Self Promotion'), ('preview', 'Preview/Recap'), ('filler', 'Filler Tangent'), ('interaction', 'Interaction Reminder'), ('music_offtopic', 'Non-Music Section')], verbose_name=''), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0028_alter_source_source_resolution.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2025-02-12 18:31 2 | 3 | from django.db import migrations, models 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ('sync', '0027_alter_source_sponsorblock_categories'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='source', 13 | name='source_resolution', 14 | field=models.CharField(choices=[('audio', 'Audio only'), ('360p', '360p (SD)'), ('480p', '480p (SD)'), ('720p', '720p (HD)'), ('1080p', '1080p (Full HD)'), ('1440p', '1440p (2K)'), ('2160p', '2160p (4K)'), ('4320p', '4320p (8K)')], db_index=True, default='1080p', help_text='Source resolution, desired video resolution to download', max_length=8, verbose_name='source resolution'), 15 | ), 16 | ] 17 | 18 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0029_alter_mediaserver_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2025-02-22 03:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0028_alter_source_source_resolution'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='mediaserver', 15 | name='options', 16 | field=models.TextField(help_text='JSON encoded options for the media server', null=True, verbose_name='options'), 17 | ), 18 | migrations.AlterField( 19 | model_name='mediaserver', 20 | name='server_type', 21 | field=models.CharField(choices=[('j', 'Jellyfin'), ('p', 'Plex')], db_index=True, default='p', help_text='Server type', max_length=1, verbose_name='server type'), 22 | ), 23 | migrations.AlterField( 24 | model_name='mediaserver', 25 | name='use_https', 26 | field=models.BooleanField(default=False, help_text='Connect to the media server over HTTPS', verbose_name='use https'), 27 | ), 28 | migrations.AlterField( 29 | model_name='mediaserver', 30 | name='verify_https', 31 | field=models.BooleanField(default=True, help_text='If connecting over HTTPS, verify the SSL certificate is valid', verbose_name='verify https'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0030_alter_source_source_vcodec.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.8 on 2025-04-07 18:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sync', '0029_alter_mediaserver_fields'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='source', 15 | name='source_vcodec', 16 | field=models.CharField(choices=[('AVC1', 'AVC1 (H.264)'), ('VP9', 'VP9'), ('AV1', 'AV1')], db_index=True, default='VP9', help_text='Source video codec, desired video encoding format to download (ignored if "resolution" is audio only)', max_length=8, verbose_name='source video codec'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0032_metadata_transfer.py: -------------------------------------------------------------------------------- 1 | # Hand-crafted data migration 2 | 3 | from django.db import migrations 4 | from common.utils import django_queryset_generator as qs_gen 5 | from sync.models import Media 6 | 7 | 8 | def use_tables(apps, schema_editor): 9 | #Media = apps.get_model('sync', 'Media') 10 | qs = Media.objects.filter(metadata__isnull=False) 11 | for media in qs_gen(qs): 12 | media.save_to_metadata('migrated', True) 13 | 14 | def restore_metadata_column(apps, schema_editor): 15 | #Media = apps.get_model('sync', 'Media') 16 | qs = Media.objects.filter(metadata__isnull=False) 17 | for media in qs_gen(qs): 18 | metadata = media.loaded_metadata 19 | for key in {'migrated', '_using_table'}: 20 | metadata.pop(key, None) 21 | media.metadata = media.metadata_dumps(arg_dict=metadata) 22 | media.save() 23 | 24 | 25 | class Migration(migrations.Migration): 26 | 27 | dependencies = [ 28 | ('sync', '0031_squashed_metadata_metadataformat'), 29 | ] 30 | 31 | operations = [ 32 | migrations.RunPython( 33 | code=use_tables, 34 | reverse_code=restore_metadata_column, 35 | ), 36 | ] 37 | 38 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0033_alter_mediaserver_options_alter_source_source_acodec_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.9 on 2025-05-10 06:18 2 | 3 | import common.json 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sync', '0032_metadata_transfer'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='mediaserver', 16 | name='options', 17 | field=models.JSONField(encoder=common.json.JSONEncoder, help_text='Options for the media server', null=True, verbose_name='options'), 18 | ), 19 | migrations.AlterField( 20 | model_name='source', 21 | name='source_acodec', 22 | field=models.CharField(choices=[('OPUS', 'OPUS'), ('MP4A', 'MP4A')], db_index=True, default='OPUS', help_text='Source audio codec, desired audio encoding format to download', max_length=8, verbose_name='source audio codec'), 23 | ), 24 | migrations.AlterField( 25 | model_name='source', 26 | name='source_vcodec', 27 | field=models.CharField(choices=[('AV1', 'AV1'), ('VP9', 'VP9'), ('AVC1', 'AVC1 (H.264)')], db_index=True, default='VP9', help_text='Source video codec, desired video encoding format to download (ignored if "resolution" is audio only)', max_length=8, verbose_name='source video codec'), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/0034_source_target_schedule_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.1 on 2025-05-26 04:43 2 | 3 | import django.utils.timezone 4 | import sync.fields 5 | from django.db import migrations, models 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sync', '0033_alter_mediaserver_options_alter_source_source_acodec_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='source', 16 | name='target_schedule', 17 | field=models.DateTimeField( 18 | blank=True, db_index=True, default=django.utils.timezone.now, 19 | help_text='Date and time when the task to index the source should begin', 20 | verbose_name='target schedule', 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name='source', 25 | name='sponsorblock_categories', 26 | field=sync.fields.CommaSepChoiceField( 27 | all_choice='all', all_label='(All Categories)', allow_all=True, default='all', 28 | help_text='Select the SponsorBlock categories that you wish to be removed from downloaded videos.', 29 | max_length=128, possible_choices=[ 30 | ('sponsor', 'Sponsor'), ('intro', 'Intermission/Intro Animation'), ('outro', 'Endcards/Credits'), 31 | ('selfpromo', 'Unpaid/Self Promotion'), ('preview', 'Preview/Recap'), ('filler', 'Filler Tangent'), 32 | ('interaction', 'Interaction Reminder'), ('music_offtopic', 'Non-Music Section'), 33 | ], 34 | verbose_name='removed categories', 35 | ), 36 | ), 37 | ] 38 | 39 | -------------------------------------------------------------------------------- /tubesync/sync/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meeb/tubesync/4dc7243972e9b999053eae1d29c4e07a575f0960/tubesync/sync/migrations/__init__.py -------------------------------------------------------------------------------- /tubesync/sync/models/__init__.py: -------------------------------------------------------------------------------- 1 | # These are referenced from the migration files 2 | 3 | from ._migrations import ( 4 | get_media_file_path, 5 | get_media_thumb_path, 6 | media_file_storage, 7 | ) 8 | 9 | # The actual model classes 10 | # The order starts with independent classes 11 | # then the classes that depend on them follow. 12 | 13 | from .media_server import MediaServer 14 | 15 | from .source import Source 16 | from .media import Media 17 | from .metadata import Metadata 18 | from .metadata_format import MetadataFormat 19 | 20 | __all__ = [ 21 | 'get_media_file_path', 'get_media_thumb_path', 22 | 'media_file_storage', 'MediaServer', 'Source', 23 | 'Media', 'Metadata', 'MetadataFormat', 24 | ] 25 | 26 | -------------------------------------------------------------------------------- /tubesync/sync/models/_migrations.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from django.conf import settings 3 | from django.core.files.storage import FileSystemStorage 4 | 5 | 6 | media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT), base_url='/media-data/') 7 | 8 | 9 | def get_media_file_path(instance, filename): 10 | return instance.filepath 11 | 12 | 13 | def get_media_thumb_path(instance, filename): 14 | # we don't want to use alternate names for thumb files 15 | if instance.thumb: 16 | instance.thumb.delete(save=False) 17 | fileid = str(instance.uuid).lower() 18 | filename = f'{fileid}.jpg' 19 | prefix = fileid[:2] 20 | return Path('thumbs') / prefix / filename 21 | 22 | -------------------------------------------------------------------------------- /tubesync/sync/models/_private.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from ..choices import Val, YouTube_SourceType # noqa 3 | 4 | 5 | _srctype_dict = lambda n: dict(zip( YouTube_SourceType.values, (n,) * len(YouTube_SourceType.values) )) 6 | 7 | 8 | def _nfo_element(nfo, label, text, /, *, attrs={}, tail='\n', char=' ', indent=2): 9 | element = nfo.makeelement(label, attrs) 10 | element.text = text 11 | element.tail = tail + (char * indent) 12 | return element 13 | 14 | def directory_and_stem(arg_path, /, all_suffixes=False): 15 | filepath = Path(arg_path) 16 | stem = Path(filepath.stem) 17 | while all_suffixes and stem.suffixes and '' != stem.suffix: 18 | stem = Path(stem.stem) 19 | stem = str(stem) 20 | return (filepath.parent, stem,) 21 | 22 | -------------------------------------------------------------------------------- /tubesync/sync/models/media_server.py: -------------------------------------------------------------------------------- 1 | from common.json import JSONEncoder 2 | from django import db 3 | from django.utils.translation import gettext_lazy as _ 4 | from ..choices import Val, MediaServerType 5 | 6 | 7 | class MediaServer(db.models.Model): 8 | ''' 9 | A remote media server, such as a Plex server. 10 | ''' 11 | 12 | ICONS = { 13 | Val(MediaServerType.JELLYFIN): '', 14 | Val(MediaServerType.PLEX): '', 15 | } 16 | HANDLERS = MediaServerType.handlers_dict() 17 | 18 | server_type = db.models.CharField( 19 | _('server type'), 20 | max_length=1, 21 | db_index=True, 22 | choices=MediaServerType.choices, 23 | default=MediaServerType.PLEX, 24 | help_text=_('Server type'), 25 | ) 26 | host = db.models.CharField( 27 | _('host'), 28 | db_index=True, 29 | max_length=200, 30 | help_text=_('Hostname or IP address of the media server'), 31 | ) 32 | port = db.models.PositiveIntegerField( 33 | _('port'), 34 | db_index=True, 35 | help_text=_('Port number of the media server'), 36 | ) 37 | use_https = db.models.BooleanField( 38 | _('use https'), 39 | default=False, 40 | help_text=_('Connect to the media server over HTTPS'), 41 | ) 42 | verify_https = db.models.BooleanField( 43 | _('verify https'), 44 | default=True, 45 | help_text=_('If connecting over HTTPS, verify the SSL certificate is valid'), 46 | ) 47 | options = db.models.JSONField( 48 | _('options'), 49 | encoder=JSONEncoder, 50 | blank=False, 51 | null=True, 52 | help_text=_('Options for the media server'), 53 | ) 54 | 55 | def __str__(self): 56 | return f'{self.get_server_type_display()} server at {self.url}' 57 | 58 | class Meta: 59 | verbose_name = _('Media Server') 60 | verbose_name_plural = _('Media Servers') 61 | unique_together = ( 62 | ('host', 'port'), 63 | ) 64 | 65 | @property 66 | def url(self): 67 | scheme = 'https' if self.use_https else 'http' 68 | return f'{scheme}://{self.host.strip()}:{self.port}' 69 | 70 | @property 71 | def icon(self): 72 | return self.ICONS.get(self.server_type) 73 | 74 | @property 75 | def handler(self): 76 | handler_class = self.HANDLERS.get(self.server_type) 77 | return handler_class(self) 78 | 79 | def validate(self): 80 | return self.handler.validate() 81 | 82 | def update(self): 83 | return self.handler.update() 84 | 85 | def get_help_html(self): 86 | return self.handler.HELP 87 | -------------------------------------------------------------------------------- /tubesync/sync/models/metadata_format.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from common.json import JSONEncoder 3 | from django import db 4 | from django.utils.translation import gettext_lazy as _ 5 | from .metadata import Metadata 6 | 7 | class MetadataFormat(db.models.Model): 8 | ''' 9 | A format from the Metadata for an indexed `Media` item. 10 | ''' 11 | class Meta: 12 | db_table = f'{Metadata._meta.db_table}_format' 13 | verbose_name = _('Format from Media Metadata') 14 | verbose_name_plural = _('Formats from Media Metadata') 15 | unique_together = ( 16 | ('metadata', 'site', 'key', 'number'), 17 | ) 18 | ordering = ['site', 'key', 'number'] 19 | 20 | uuid = db.models.UUIDField( 21 | _('uuid'), 22 | primary_key=True, 23 | editable=False, 24 | default=uuid.uuid4, 25 | help_text=_('UUID of the format'), 26 | ) 27 | metadata = db.models.ForeignKey( 28 | Metadata, 29 | # on_delete=models.DO_NOTHING, 30 | on_delete=db.models.CASCADE, 31 | related_name='format', 32 | help_text=_('Metadata the format belongs to'), 33 | null=False, 34 | ) 35 | site = db.models.CharField( 36 | _('site'), 37 | max_length=256, 38 | blank=True, 39 | db_index=True, 40 | null=False, 41 | default='Youtube', 42 | help_text=_('Site from which the format is available'), 43 | ) 44 | key = db.models.CharField( 45 | _('key'), 46 | max_length=256, 47 | blank=True, 48 | db_index=True, 49 | null=False, 50 | default='', 51 | help_text=_('Media identifier at the site from which this format is available'), 52 | ) 53 | number = db.models.PositiveIntegerField( 54 | _('number'), 55 | blank=False, 56 | null=False, 57 | help_text=_('Ordering number for this format'), 58 | ) 59 | value = db.models.JSONField( 60 | _('value'), 61 | encoder=JSONEncoder, 62 | null=False, 63 | default=dict, 64 | help_text=_('JSON metadata format object'), 65 | ) 66 | 67 | 68 | def __str__(self): 69 | template = '#{:n} "{}" from {}: {}' 70 | return template.format( 71 | self.number, 72 | self.key, 73 | self.site, 74 | self.value.get('format') or self.value.get('format_id'), 75 | ) 76 | -------------------------------------------------------------------------------- /tubesync/sync/overrides/custom_filter.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file can be overridden with a docker volume to allow specifying a custom filter function to call. This allows 3 | for higher order filtering for those that really want advanced controls, without exposing the web interface to 4 | potential RCE issues. 5 | 6 | You are simply provided with an instance of Media, and need to return True to skip it, or False to allow it to be 7 | downloaded. 8 | 9 | To use this custom file, download this file and modify the function to do your check for skipping a media item. 10 | Then use docker volumes to override /app/sync/overrides/ with your custom file (it must be called 11 | `custom_filter.py`) 12 | e.g. your `docker run` could have `-v /some/directory/tubesync-overrides:/app/sync/overrides` 13 | or docker-compose could have 14 | volumes: 15 | - /some/directory/tubesync-overrides:/app/sync/overrides 16 | 17 | 18 | The logic is that if any condition marks an item to be skipped, it will be skipped. To save resources, this 19 | custom filter won't be called if any other filter as already marked it to be skipped 20 | """ 21 | 22 | from ..models import Media 23 | from common.logger import log 24 | 25 | 26 | def filter_custom(instance: Media) -> bool: 27 | # Return True to skip, or False to allow the media item to be downloaded 28 | 29 | # Put your conditional logic here 30 | if False: 31 | # It's in your best interest to log when skipping, so you can look at the logs and see why your media isn't 32 | # downloading 33 | log.info( 34 | f"Media: {instance.source} / {instance} has met some custom condition. Marking to be skipped" 35 | ) 36 | return True 37 | 38 | # Return False if we aren't skipping the media 39 | return False 40 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/_mediaformatvars.html: -------------------------------------------------------------------------------- 1 |

Available media name variables

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
NameDescriptionOutput example
{yyyymmdd}Media publish date in YYYYMMDD20210131
{yyyy_mm_dd}Media publish date in YYYY-MM-DD2021-01-31
{yyyy}Media publish year in YYYY2021
{mm}Media publish month in MM01
{dd}Media publish day in DD31
{source}Lower case source name, max 80 charsmy-source
{source_full}Full source nameMy Source
{uploader}Uploader nameSome Channel Name
{title}Lower case media title, max 80 charsmy-video
{title_full}Full media titleMy Video
{key}Media unique key or video IDSoMeUnIqUeId
{format}Media format string720p-avc1-mp4a
{playlist_title}Playlist title of media, if it's in a playlistSome Playlist
{video_order}Episode order in playlist, if in playlist (can cause issues if playlist is changed after adding)01
{ext}File extensionmkv
{resolution}Resolution720p
{height}Media height in pixels720
{width}Media width in pixels1280
{vcodec}Media video codecavc1
{acodec}Media audio codecopus
{fps}Media fps60fps
{flag_hdr}Media has HDR flaghdr
-------------------------------------------------------------------------------- /tubesync/sync/templates/sync/media-enable.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Enable (unskip) media - {{ media }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Enable (unskip) {{ media }}

9 |

10 | You can enable your previously skipped media {{ media }}. This 11 | will re-enable the media to be downloaded. 12 |

13 |
14 |
15 |
16 |
17 | {% csrf_token %} 18 | {% include 'simpleform.html' with form=form %} 19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/media-redownload.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Redownload media - {{ media }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Redownload media {{ media }}

9 |

10 | You can delete the downloaded file for your media {{ media }} and 11 | schedule it to be redownloaded. You might want to use this if you moved the original 12 | file on disk and want to download it again, or, if you changed your source settings 13 | such as changed the desired resolution and want to redownload the media in a different 14 | format. 15 |

16 |
17 |
18 |
19 |
20 | {% csrf_token %} 21 | {% include 'simpleform.html' with form=form %} 22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/media-skip.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Skip media - {{ media }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Delete and skip media {{ media }}

9 |

10 | You can delete the downloaded file for your media {{ media }} and 11 | mark it to never be downloaded. You might want to do this if you don't want a local 12 | copy of some media or want to skip a single video from a source. 13 |

14 |
15 |
16 |
17 |
18 | {% csrf_token %} 19 | {% include 'simpleform.html' with form=form %} 20 |
21 |
22 | 23 |
24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/media.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %}{% load static %} 2 | 3 | {% block headtitle %}Media{% if source %} - {{ source }}{% endif %}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Media

9 |
10 |
11 | {% if show_skipped %} 12 | Hide skipped media 13 | {% else %} 14 | Show skipped media 15 | {% endif %} 16 |
17 |
18 | {% if only_skipped %} 19 | Only skipped media 20 | {% else %} 21 | Only skipped media 22 | {% endif %} 23 |
24 |
25 | {% include 'infobox.html' with message=message %} 26 |
27 | {% for m in media %} 28 |
29 | 58 |
59 | {% empty %} 60 |
61 |
62 | No media has been indexed{% if source %} that matches the specified source filter{% endif %}. 63 |
64 |
65 | {% endfor %} 66 |
67 | {% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped only_skipped=only_skipped%} 68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/mediaserver-add.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Add a new {{ server_type_name }} media server{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Add a {{ server_type_name }} media server

9 |

10 | You can use this form to add a new {{ server_type_name }} media server. All media 11 | servers added will be updated or refreshed every time some media is downloaded. 12 |

13 | {% if server_help %}{{ server_help|safe }}{% endif %} 14 |
15 |
16 |
17 |
18 | {% csrf_token %} 19 | {% include 'simpleform.html' with form=form %} 20 |
21 |
22 | 23 |
24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/mediaserver-delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Delete media server - {{ mediaserver }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Delete {{ mediaserver }}

9 |

10 | Deleting a media server will stop it from being updated by TubeSync. This action 11 | is permanent. You will have to manually re-add the media server details again if 12 | you want to update it automatically in future again. 13 |

14 |
15 |
16 |
17 |
18 | {% csrf_token %} 19 | {% include 'simpleform.html' with form=form %} 20 |
21 |
22 | 23 |
24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/mediaserver-update.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Update media server - {{ mediaserver }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Update {{ mediaserver }}

9 |

10 | You can use this form to update your media server details. The details will be 11 | validated when you save the form. 12 |

13 | {% if server_help %}{{ server_help|safe }}{% endif %} 14 |
15 |
16 |
17 |
18 | {% csrf_token %} 19 | {% include 'simpleform.html' with form=form %} 20 |
21 |
22 | 23 |
24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/mediaserver.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Media server - {{ mediaserver }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

{{ mediaserver.get_server_type_display }} server at {{ mediaserver.url }}

9 |
10 |
11 | {% include 'infobox.html' with message=message %} 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for name, value in mediaserver.options.items %} 32 | 33 | 34 | 35 | 36 | {% endfor %} 37 |
TypeType
{{ mediaserver.get_server_type_display }}
LocationLocation
{{ mediaserver.url }}
Use HTTPSUse HTTPS
{% if mediaserver.use_https %}{% else %}{% endif %}
Verify HTTPSVerify HTTPS
{% if mediaserver.verify_https %}{% else %}{% endif %}
{{ name|title }}{{ name|title }}
{% if name in private_options %}{{ value|truncatechars:6 }} (hidden){% else %}{{ value }}{% endif %}
38 |
39 |
40 |
41 |
42 | Edit media server 43 |
44 |
45 | Delete media server 46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/mediaservers.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Media servers{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Media servers

9 |

10 | Media servers are services like Plex which you may be running on your network. If 11 | you add your media server TubeSync will notify your media server to rescan or 12 | refresh its libraries every time media is successfully downloaded. Currently, 13 | TubeSync only supports Jellyfin and Plex. 14 |

15 |
16 |
17 | {% include 'infobox.html' with message=message %} 18 | {% for mst in media_server_types %} 19 |
20 |
21 | Add a {{ mst.label }} media server 22 |
23 |
24 | {% endfor %} 25 |
26 |
27 |
28 | {% for mediaserver in mediaservers %} 29 | 30 | {{ mediaserver.icon|safe }} {{ mediaserver.get_server_type_display }} server at {{ mediaserver.url }} 31 | 32 | {% empty %} 33 | You haven't added any media servers. 34 | {% endfor %} 35 |
36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/source-add.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Add a new source{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Add a source

9 |

10 | You can use this form to add a new source. A source is what's polled on regular 11 | basis to find new media to download, such as a channel or playlist. 12 |

13 |
14 |
15 |
16 |
17 | {% csrf_token %} 18 | {% include 'simpleform.html' with form=form %} 19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 |
27 |
28 | {% include 'sync/_mediaformatvars.html' %} 29 |
30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/source-delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Delete source - {{ source.name }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Delete source {{ source.name }}

9 |

10 | Are you sure you want to delete this source? Deleting a source is permanent. 11 | By default, deleting a source does not delete any saved media files. You can 12 | tick the "also delete downloaded media" checkbox to also remove directory {{ source.directory_path }} 13 | when you delete the source. Deleting a source cannot be undone. 14 |

15 |
16 |
17 |
18 |
19 | {% csrf_token %} 20 | {% include 'simpleform.html' with form=form %} 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/source-update.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Update source - {{ source.name }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Update source {{ source.name }}

9 |

10 | You can use this form to update your source. A source is what's polled on regular 11 | basis to find new media to download, such as a channel or playlist. Any changes 12 | to a source will only apply to new media and will not update media previously 13 | downloaded. 14 |

15 |
16 |
17 |
18 |
19 | {% csrf_token %} 20 | {% include 'simpleform.html' with form=form %} 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | {% include 'sync/_mediaformatvars.html' %} 31 |
32 |
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/source-validate.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Validate a {{ help_item }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Validate a {{ help_item }}

9 |

{{ help_text|safe }}

10 |

Example: {{ help_example }}

11 |
12 |
13 |
14 |
15 | {% csrf_token %} 16 | {% include 'simpleform.html' with form=form %} 17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/sources.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Sources{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Sources

9 |
10 |
11 | {% include 'infobox.html' with message=message %} 12 |
13 |
14 | Add a YouTube channel 15 |
16 |
17 | Add a YouTube channel by ID 18 |
19 |
20 | Add a YouTube playlist 21 |
22 |
23 |
24 |
25 | 43 |
44 |
45 | {% include 'pagination.html' with pagination=sources.paginator %} 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/task-schedule.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Schedule task{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Schedule task

9 |

10 | If you don't want to wait for the existing schedule to be triggered, 11 | you can use this to change when the task will be scheduled to run. 12 | It is not guaranteed to run at any exact time, because when a task 13 | requests to run and when a slot to execute it, in the appropriate 14 | queue and with the priority level assigned, is dependent on how long 15 | other tasks are taking to complete the assigned work. 16 |

17 |

18 | This will change the time that the task is requesting to be the 19 | current time, or a chosen future time. 20 |

21 |
22 |
23 |
24 |
25 | {% csrf_token %} 26 | {% include 'simpleform.html' with form=form %} 27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/tasks-completed.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Tasks - Completed{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Completed tasks

9 |
10 |
11 | {% include 'infobox.html' with message=message %} 12 |
13 |
14 |
15 | {% for task in tasks %} 16 | 17 | {% if task.has_error %} 18 | 19 | {{ task.verbose_name }}
20 | Queue: "{{ task.queue }}"
21 | Error: "{{ task.error_message }}"
22 | Task ran at {{ task.run_at|date:'Y-m-d H:i:s' }} 23 |
24 | {% else %} 25 | 26 | {{ task.verbose_name }}
27 | Queue: "{{ task.queue }}"
28 | Task ran at {{ task.run_at|date:'Y-m-d H:i:s' }} 29 |
30 | {% endif %} 31 | 32 | {% empty %} 33 | There have been no completed tasks{% if source %} that match the specified source filter{% endif %}. 34 | {% endfor %} 35 |
36 |
37 |
38 | {% include 'pagination.html' with pagination=sources.paginator filter=source.pk %} 39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /tubesync/sync/templates/sync/tasks-reset.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block headtitle %}Reset tasks{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Reset tasks

9 |

10 | If your TubeSync installation has gotten itself into any form of synchronisation 11 | state issues (such you moved then restored files on disk) and the state in 12 | TubeSync isn't up to date you can use this button to force a state reset. 13 |

14 |

15 | This will delete all current tasks then all souces will be checked for their 16 | states and new tasks to be created where required. 17 |

18 |
19 |
20 |
21 |
22 | {% csrf_token %} 23 | {% include 'simpleform.html' with form=form %} 24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /tubesync/sync/templates/widgets/checkbox_option.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /tubesync/sync/templates/widgets/checkbox_select.html: -------------------------------------------------------------------------------- 1 | 2 | {% for option in widget.multipleChoiceProperties %} 3 | {% include option.template_name with option=option %} 4 | {% endfor %} 5 |