System snapshots, #build

Changed:
- Added: Dedublicated snapshots, read docs/Settings#snapshots first
- Added: Actions for better Python and JS linting
- Changed: Clean up JS
- Changed: Use patched ffmpeg builds for arm64
- API: Added endpoints to interact with snapshots
- Fixed: mobile layout for channel filter dopdown on downloads
This commit is contained in:
simon 2022-10-29 21:10:04 +07:00
commit d69460bf98
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
27 changed files with 3355 additions and 1048 deletions

17
.eslintrc.js Normal file
View File

@ -0,0 +1,17 @@
'use strict';
module.exports = {
extends: ['eslint:recommended', 'eslint-config-prettier'],
parserOptions: {
ecmaVersion: 2020,
},
env: {
browser: true,
},
rules: {
strict: ['error', 'global'],
'no-unused-vars': ['error', { vars: 'local' }],
eqeqeq: ['error', 'always', { null: 'ignore' }],
curly: ['error', 'multi-line'],
'no-var': 'error',
},
};

16
.github/workflows/lint_js.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: lint_js
on: [pull_request, push]
jobs:
check:
name: lint_js
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: '16'
- run: npm ci
- run: npm run lint
- run: npm run format -- --check

View File

@ -4,16 +4,21 @@ jobs:
lint_python: lint_python:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-python@v2 - uses: actions/setup-python@v4
with:
python-version: '3.10'
# note: this logic is duplicated in the `validate` function in ./deploy.sh
# if you update this file, you should update that as well
- run: pip install --upgrade pip wheel - run: pip install --upgrade pip wheel
- run: pip install bandit black codespell flake8 flake8-bugbear - run: pip install bandit black codespell flake8 flake8-bugbear
flake8-comprehensions isort flake8-comprehensions isort requests
- run: black --check --diff --line-length 79 . - run: ./deploy.sh validate
- run: codespell # - run: black --check --diff --line-length 79 .
- run: flake8 . --count --max-complexity=10 --max-line-length=79 # - run: codespell --skip="./.git,./package.json,./package-lock.json,./node_modules"
--show-source --statistics # - run: flake8 . --count --max-complexity=10 --max-line-length=79
- run: isort --check-only --line-length 79 --profile black . # --show-source --statistics
# - run: isort --check-only --line-length 79 --profile black .
# - run: pip install -r tubearchivist/requirements.txt # - run: pip install -r tubearchivist/requirements.txt
# - run: mkdir --parents --verbose .mypy_cache # - run: mkdir --parents --verbose .mypy_cache
# - run: mypy --ignore-missing-imports --install-types --non-interactive . # - run: mypy --ignore-missing-imports --install-types --non-interactive .

5
.gitignore vendored
View File

@ -5,4 +5,7 @@ __pycache__
db.sqlite3 db.sqlite3
# vscode custom conf # vscode custom conf
.vscode .vscode
# JavaScript stuff
node_modules

View File

@ -76,6 +76,10 @@ Do you see anything on the roadmap that you would like to take a closer look at
To fix a bug or implement a feature, fork the repository and make all changes to the testing branch. When ready, create a pull request. To fix a bug or implement a feature, fork the repository and make all changes to the testing branch. When ready, create a pull request.
## Making changes to the JavaScript
The JavaScript does not require any build step; you just edit the files directly. However, there is config for eslint and prettier (a linter and formatter respectively); their use is recommended but not required. To use them, install `node`, run `npm i` from the root directory of this repository to install dependencies, then run `npm run lint` and `npm run format` to run eslint and prettier respectively.
## Releases ## Releases
There are three different docker tags: There are three different docker tags:

View File

@ -195,7 +195,7 @@ Similar to that, TubeArchivist will become all sorts of messed up when running o
## Getting Started ## Getting Started
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example: 1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
``` ```
bestvideo[VCODEC=avc1]+bestaudio[ACODEC=mp4a]/mp4 bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
``` ```
2. Subscribe to some of your favorite YouTube channels on the **channels** page. 2. Subscribe to some of your favorite YouTube channels on the **channels** page.
3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists. 3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists.

View File

@ -82,10 +82,12 @@ function validate {
echo "run validate on $check_path" echo "run validate on $check_path"
# note: this logic is duplicated in the `./github/workflows/lint_python.yml` config
# if you update this file, you should update that as well
echo "running black" echo "running black"
black --diff --color --check -l 79 "$check_path" black --diff --color --check -l 79 "$check_path"
echo "running codespell" echo "running codespell"
codespell --skip="./.git" "$check_path" codespell --skip="./.git,./package.json,./package-lock.json,./node_modules" "$check_path"
echo "running flake8" echo "running flake8"
flake8 "$check_path" --count --max-complexity=10 --max-line-length=79 \ flake8 "$check_path" --count --max-complexity=10 --max-line-length=79 \
--show-source --statistics --show-source --statistics

View File

@ -72,6 +72,15 @@ Requirements:
You need the following headers: Content-Type, Accept-Encoding, and Range. Note that the last two headers, Accept-Encoding and Range, are additional headers that you may not have needed previously. You need the following headers: Content-Type, Accept-Encoding, and Range. Note that the last two headers, Accept-Encoding and Range, are additional headers that you may not have needed previously.
Wildcards "*" cannot be used for the Access-Control-Allow-Origin header. If the page has protected media content, it must use a domain instead of a wildcard. Wildcards "*" cannot be used for the Access-Control-Allow-Origin header. If the page has protected media content, it must use a domain instead of a wildcard.
## Snapshots
System snapshots will automatically make daily snapshots of the Elasticsearch index. Snapshots are deduplicated, meaning that each snapshot will only have to backup changes since the last snapshot. There is also a cleanup function implemented, that will remove snapshots older than 30 days. Due to this improvements compared to our previous solution, system snapshots will replace the current backup system in a future version.
Before activating system snapshots, you'll have to add two additional environment variables to the *archivist-es* container:
```
path.repo=/usr/share/elasticsearch/data/snapshot
TZ=America/New_York
```
The variable `path.repo` will set folder where the snapshots will go inside the Elasticsearch container, you can't change the folder, but the variable needs to be set. For the `TZ` variable, set the same as you have for the Tube Archivist container. Rebuild the container for changes to take effect, e.g `docker compose up -d`.
# Scheduler Setup # Scheduler Setup
Schedule settings expect a cron like format, where the first value is minute, second is hour and third is day of the week. Day 0 is Sunday, day 1 is Monday etc. Schedule settings expect a cron like format, where the first value is minute, second is hour and third is day of the week. Day 0 is Sunday, day 1 is Monday etc.

1761
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"private": true,
"scripts": {
"lint": "eslint 'tubearchivist/static/**/*.js'",
"format": "prettier --write 'tubearchivist/static/**/*.js'"
},
"devDependencies": {
"eslint": "^8.26.0",
"prettier": "^2.7.1",
"eslint-config-prettier": "^8.5.0"
},
"prettier": {
"singleQuote": true,
"arrowParens": "avoid",
"printWidth": 100
}
}

View File

@ -29,6 +29,10 @@ Note:
- [Download Queue List](#download-queue-list-view) - [Download Queue List](#download-queue-list-view)
- [Download Queue Single](#download-queue-item-view) - [Download Queue Single](#download-queue-item-view)
**Snapshot management**
- [Snapshot List](#snapshot-list-view)
- [Snapshot Single](#snapshot-item-view)
**Additional** **Additional**
- [Login](#login-view) - [Login](#login-view)
- [Task](#task-view) WIP - [Task](#task-view) WIP
@ -207,6 +211,48 @@ Add to queue previously ignored video:
DELETE /api/download/\<video_id>/ DELETE /api/download/\<video_id>/
Forget or delete from download queue Forget or delete from download queue
## Snapshot List View
GET /api/snapshot/
Return snapshot config and a list of available snapshots.
```json
{
"next_exec": epoch,
"next_exec_str": "date_str",
"expire_after": "30d",
"snapshots": []
}
```
POST /api/snapshot/
Create new snapshot now, will return immediately, task will run async in the background, will return snapshot name:
```json
{
"snapshot_name": "ta_daily_<random-id>
}
```
## Snapshot Item View
GET /api/snapshot/\<snapshot-id>/
Return metadata of a single snapshot
```json
{
"id": "ta_daily_<random-id>,
"state": "SUCCESS",
"es_version": "0.0.0",
"start_date": "date_str",
"end_date": "date_str",
"end_stamp": epoch,
"duration_s": 0
}
```
GET /api/snapshot/\<snapshot-id>/
Restore this snapshot
DELETE /api/snapshot/\<snapshot-id>/
Remove this snapshot from index
## Login View ## Login View
Return token and user ID for username and password: Return token and user ID for username and password:
POST /api/login POST /api/login

View File

@ -13,6 +13,8 @@ from api.views import (
PlaylistApiVideoView, PlaylistApiVideoView,
PlaylistApiView, PlaylistApiView,
SearchView, SearchView,
SnapshotApiListView,
SnapshotApiView,
TaskApiView, TaskApiView,
VideoApiListView, VideoApiListView,
VideoApiView, VideoApiView,
@ -89,6 +91,16 @@ urlpatterns = [
TaskApiView.as_view(), TaskApiView.as_view(),
name="api-task", name="api-task",
), ),
path(
"snapshot/",
SnapshotApiListView.as_view(),
name="api-snapshot-list",
),
path(
"snapshot/<slug:snapshot_id>/",
SnapshotApiView.as_view(),
name="api-snapshot",
),
path( path(
"cookie/", "cookie/",
CookieView.as_view(), CookieView.as_view(),

View File

@ -5,6 +5,7 @@ from api.src.task_processor import TaskHandler
from home.src.download.queue import PendingInteract from home.src.download.queue import PendingInteract
from home.src.download.yt_dlp_base import CookieHandler from home.src.download.yt_dlp_base import CookieHandler
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.es.snapshot import ElasticSnapshot
from home.src.frontend.searching import SearchForm from home.src.frontend.searching import SearchForm
from home.src.index.generic import Pagination from home.src.index.generic import Pagination
from home.src.index.video import SponsorBlock from home.src.index.video import SponsorBlock
@ -485,6 +486,70 @@ class TaskApiView(ApiBaseView):
return Response(response) return Response(response)
class SnapshotApiListView(ApiBaseView):
"""resolves to /api/snapshot/
GET: returns snashot config plus list of existing snapshots
POST: take snapshot now
"""
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
snapshots = ElasticSnapshot().get_snapshot_stats()
return Response(snapshots)
@staticmethod
def post(request):
"""take snapshot now with post request"""
# pylint: disable=unused-argument
response = ElasticSnapshot().take_snapshot_now()
return Response(response)
class SnapshotApiView(ApiBaseView):
"""resolves to /api/snapshot/<snapshot-id>/
GET: return a single snapshot
POST: restore snapshot
DELETE: delete a snapshot
"""
@staticmethod
def get(request, snapshot_id):
"""handle get request"""
# pylint: disable=unused-argument
snapshot = ElasticSnapshot().get_single_snapshot(snapshot_id)
if not snapshot:
return Response({"message": "snapshot not found"}, status=404)
return Response(snapshot)
@staticmethod
def post(request, snapshot_id):
"""restore snapshot with post request"""
# pylint: disable=unused-argument
response = ElasticSnapshot().restore_all(snapshot_id)
if not response:
message = {"message": "failed to restore snapshot"}
return Response(message, status=400)
return Response(response)
@staticmethod
def delete(request, snapshot_id):
"""delete snapshot from index"""
# pylint: disable=unused-argument
response = ElasticSnapshot().delete_single_snapshot(snapshot_id)
if not response:
message = {"message": "failed to delete snapshot"}
return Response(message, status=400)
return Response(response)
class CookieView(ApiBaseView): class CookieView(ApiBaseView):
"""resolves to /api/cookie/ """resolves to /api/cookie/
GET: check if cookie is enabled GET: check if cookie is enabled

View File

@ -6,6 +6,7 @@ import sys
from django.apps import AppConfig from django.apps import AppConfig
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.es.index_setup import index_check from home.src.es.index_setup import index_check
from home.src.es.snapshot import ElasticSnapshot
from home.src.ta.config import AppConfig as ArchivistConfig from home.src.ta.config import AppConfig as ArchivistConfig
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
@ -30,6 +31,7 @@ class StartupCheck:
self.sync_redis_state() self.sync_redis_state()
self.set_redis_conf() self.set_redis_conf()
self.make_folders() self.make_folders()
self.snapshot_check()
self.set_has_run() self.set_has_run()
def get_has_run(self): def get_has_run(self):
@ -63,10 +65,7 @@ class StartupCheck:
cache_dir = self.config_handler.config["application"]["cache_dir"] cache_dir = self.config_handler.config["application"]["cache_dir"]
for folder in folders: for folder in folders:
folder_path = os.path.join(cache_dir, folder) folder_path = os.path.join(cache_dir, folder)
try: os.makedirs(folder_path, exist_ok=True)
os.makedirs(folder_path)
except FileExistsError:
continue
def release_lock(self): def release_lock(self):
"""make sure there are no leftover locks set in redis""" """make sure there are no leftover locks set in redis"""
@ -84,6 +83,14 @@ class StartupCheck:
if response: if response:
print("deleted leftover key from redis: " + lock) print("deleted leftover key from redis: " + lock)
def snapshot_check(self):
"""setup snapshot config, create if needed"""
active = self.config_handler.config["application"]["enable_snapshot"]
if not active:
return
ElasticSnapshot().setup()
def is_invalid(self, version): def is_invalid(self, version):
"""return true if es version is invalid, false if ok""" """return true if es version is invalid, false if ok"""
major, minor = [int(i) for i in version.split(".")[:2]] major, minor = [int(i) for i in version.split(".")[:2]]

View File

@ -37,7 +37,8 @@
"cache_dir": "/cache", "cache_dir": "/cache",
"videos": "/youtube", "videos": "/youtube",
"colors": "dark", "colors": "dark",
"enable_cast": false "enable_cast": false,
"enable_snapshot": false
}, },
"scheduler": { "scheduler": {
"update_subscribed": false, "update_subscribed": false,

View File

@ -0,0 +1,254 @@
"""
functionality:
- handle snapshots in ES
"""
from datetime import datetime
from os import environ
from zoneinfo import ZoneInfo
from home.src.es.connect import ElasticWrap
from home.src.es.index_setup import get_mapping
class ElasticSnapshot:
"""interact with snapshots on ES"""
REPO = "ta_snapshot"
REPO_SETTINGS = {
"compress": "true",
"chunk_size": "1g",
"location": "/usr/share/elasticsearch/data/snapshot",
}
POLICY = "ta_daily"
def __init__(self):
self.all_indices = self._get_all_indices()
def _get_all_indices(self):
"""return all indices names managed by TA"""
mapping = get_mapping()
all_indices = [f"ta_{i['index_name']}" for i in mapping]
return all_indices
def setup(self):
"""setup the snapshot in ES, create or update if needed"""
print("snapshot: run setup")
repo_exists = self._check_repo_exists()
if not repo_exists:
self.create_repo()
policy_exists = self._check_policy_exists()
if not policy_exists:
self.create_policy()
is_outdated = self._needs_startup_snapshot()
if is_outdated:
_ = self.take_snapshot_now()
def _check_repo_exists(self):
"""check if expected repo already exists"""
path = f"_snapshot/{self.REPO}"
response, statuscode = ElasticWrap(path).get()
if statuscode == 200:
print(f"snapshot: repo {self.REPO} already created")
matching = response[self.REPO]["settings"] == self.REPO_SETTINGS
if not matching:
print(f"snapshot: update repo settings {self.REPO_SETTINGS}")
return matching
print(f"snapshot: setup repo {self.REPO} config {self.REPO_SETTINGS}")
return False
def create_repo(self):
"""create filesystem repo"""
path = f"_snapshot/{self.REPO}"
data = {
"type": "fs",
"settings": self.REPO_SETTINGS,
}
response, statuscode = ElasticWrap(path).post(data=data)
if statuscode == 200:
print(f"snapshot: repo setup correctly: {response}")
def _check_policy_exists(self):
"""check if snapshot policy is set correctly"""
policy = self._get_policy()
expected_policy = self._build_policy_data()
if not policy:
print(f"snapshot: create policy {self.POLICY} {expected_policy}")
return False
if policy["policy"] != expected_policy:
print(f"snapshot: update policy settings {expected_policy}")
return False
print("snapshot: policy is set.")
return True
def _get_policy(self):
"""get policy from es"""
path = f"_slm/policy/{self.POLICY}"
response, statuscode = ElasticWrap(path).get()
if statuscode != 200:
return False
return response[self.POLICY]
def create_policy(self):
"""create snapshot lifetime policy"""
path = f"_slm/policy/{self.POLICY}"
data = self._build_policy_data()
response, statuscode = ElasticWrap(path).put(data)
if statuscode == 200:
print(f"snapshot: policy setup correctly: {response}")
def _build_policy_data(self):
"""build policy dict from config"""
return {
"schedule": "0 30 1 * * ?",
"name": f"<{self.POLICY}_>",
"repository": self.REPO,
"config": {
"indices": self.all_indices,
"include_global_state": True,
},
"retention": {
"expire_after": "30d",
"min_count": 5,
"max_count": 50,
},
}
def _needs_startup_snapshot(self):
"""check if last snapshot is expired"""
snap_dicts = self._get_all_snapshots()
if not snap_dicts:
print("snapshot: create initial snapshot")
return True
last_stamp = snap_dicts[0]["end_stamp"]
now = int(datetime.now().strftime("%s"))
outdated = (now - last_stamp) / 60 / 60 > 24
if outdated:
print("snapshot: is outdated, create new now")
print("snapshot: last snapshot is up-to-date")
return outdated
def take_snapshot_now(self):
"""execute daily snapshot now"""
path = f"_slm/policy/{self.POLICY}/_execute"
response, statuscode = ElasticWrap(path).post()
if statuscode == 200:
print(f"snapshot: executing now: {response}")
return response
def get_snapshot_stats(self):
"""get snapshot info for frontend"""
snapshot_info = self._build_policy_details()
if snapshot_info:
snapshot_info.update({"snapshots": self._get_all_snapshots()})
return snapshot_info
def get_single_snapshot(self, snapshot_id):
"""get single snapshot metadata"""
path = f"_snapshot/{self.REPO}/{snapshot_id}"
response, statuscode = ElasticWrap(path).get()
if statuscode == 404:
print(f"snapshots: not found: {snapshot_id}")
return False
snapshot = response["snapshots"][0]
return self._parse_single_snapshot(snapshot)
def _get_all_snapshots(self):
"""get a list of all registered snapshots"""
path = f"_snapshot/{self.REPO}/*?sort=start_time&order=desc"
response, statuscode = ElasticWrap(path).get()
if statuscode == 404:
print("snapshots: not configured")
return False
all_snapshots = response["snapshots"]
if not all_snapshots:
print("snapshots: no snapshots found")
return False
snap_dicts = []
for snapshot in all_snapshots:
snap_dict = self._parse_single_snapshot(snapshot)
snap_dicts.append(snap_dict)
return snap_dicts
def _parse_single_snapshot(self, snapshot):
"""extract relevant metadata from single snapshot"""
snap_dict = {
"id": snapshot["snapshot"],
"state": snapshot["state"],
"es_version": snapshot["version"],
"start_date": self._date_converter(snapshot["start_time"]),
"end_date": self._date_converter(snapshot["end_time"]),
"end_stamp": snapshot["end_time_in_millis"] // 1000,
"duration_s": snapshot["duration_in_millis"] // 1000,
}
return snap_dict
def _build_policy_details(self):
"""get additional policy details"""
policy = self._get_policy()
if not policy:
return False
next_exec = policy["next_execution_millis"] // 1000
next_exec_date = datetime.fromtimestamp(next_exec)
next_exec_str = next_exec_date.strftime("%Y-%m-%d %H:%M")
expire_after = policy["policy"]["retention"]["expire_after"]
policy_metadata = {
"next_exec": next_exec,
"next_exec_str": next_exec_str,
"expire_after": expire_after,
}
return policy_metadata
@staticmethod
def _date_converter(date_utc):
"""convert datetime string"""
expected_format = "%Y-%m-%dT%H:%M:%S.%fZ"
date = datetime.strptime(date_utc, expected_format)
local_datetime = date.replace(tzinfo=ZoneInfo("localtime"))
converted = local_datetime.astimezone(ZoneInfo(environ.get("TZ")))
converted_str = converted.strftime("%Y-%m-%d %H:%M")
return converted_str
def restore_all(self, snapshot_name):
"""restore snapshot by name"""
for index in self.all_indices:
_, _ = ElasticWrap(index).delete()
path = f"_snapshot/{self.REPO}/{snapshot_name}/_restore"
data = {"indices": "*"}
response, statuscode = ElasticWrap(path).post(data=data)
if statuscode == 200:
print(f"snapshot: executing now: {response}")
return response
print(f"snapshot: failed to restore, {statuscode} {response}")
return False
def delete_single_snapshot(self, snapshot_id):
"""delete single snapshot from index"""
path = f"_snapshot/{self.REPO}/{snapshot_id}"
response, statuscode = ElasticWrap(path).delete()
if statuscode == 200:
print(f"snapshot: deleting {snapshot_id} {response}")
return response
print(f"snapshot: failed to delete, {statuscode} {response}")
return False

View File

@ -74,6 +74,12 @@ class ApplicationSettingsForm(forms.Form):
("1", "enable Cast"), ("1", "enable Cast"),
] ]
SNAPSHOT_CHOICES = [
("", "-- change snapshot settings --"),
("0", "disable system snapshots"),
("1", "enable system snapshots"),
]
SUBTITLE_SOURCE_CHOICES = [ SUBTITLE_SOURCE_CHOICES = [
("", "-- change subtitle source settings"), ("", "-- change subtitle source settings"),
("user", "only download user created"), ("user", "only download user created"),
@ -124,6 +130,9 @@ class ApplicationSettingsForm(forms.Form):
application_enable_cast = forms.ChoiceField( application_enable_cast = forms.ChoiceField(
widget=forms.Select, choices=CAST_CHOICES, required=False widget=forms.Select, choices=CAST_CHOICES, required=False
) )
application_enable_snapshot = forms.ChoiceField(
widget=forms.Select, choices=SNAPSHOT_CHOICES, required=False
)
class SchedulerSettingsForm(forms.Form): class SchedulerSettingsForm(forms.Form):

View File

@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}"> <div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
<div class="view-controls"> <div class="view-controls three">
<div class="toggle"> <div class="toggle">
<span>Hide watched videos:</span> <span>Hide watched videos:</span>
<div class="toggleBox"> <div class="toggleBox">

View File

@ -28,7 +28,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="view-controls"> <div class="view-controls three">
<div class="toggle"> <div class="toggle">
<span>Show only ignored videos:</span> <span>Show only ignored videos:</span>
<div class="toggleBox"> <div class="toggleBox">
@ -42,7 +42,6 @@
</div> </div>
<div class="view-icons"> <div class="view-icons">
{% if channel_agg_list|length > 1 %} {% if channel_agg_list|length > 1 %}
<span>Filter:</span>
<select name="channel_filter" id="channel_filter" onchange="channelFilterDownload(this.value)"> <select name="channel_filter" id="channel_filter" onchange="channelFilterDownload(this.value)">
<option value="all" {% if not channel_filter_id %}selected{% endif %}>all</option> <option value="all" {% if not channel_filter_id %}selected{% endif %}>all</option>
{% for channel in channel_agg_list %} {% for channel in channel_agg_list %}

View File

@ -45,7 +45,7 @@
<div class="title-bar"> <div class="title-bar">
<h1>Recent Videos</h1> <h1>Recent Videos</h1>
</div> </div>
<div class="view-controls"> <div class="view-controls three">
<div class="toggle"> <div class="toggle">
<span>Hide watched:</span> <span>Hide watched:</span>
<div class="toggleBox"> <div class="toggleBox">

View File

@ -77,7 +77,7 @@
<ul> <ul>
<li><span class="settings-current">bestvideo[height<=720]+bestaudio/best[height<=720]</span>: best audio and max video height of 720p.</li> <li><span class="settings-current">bestvideo[height<=720]+bestaudio/best[height<=720]</span>: best audio and max video height of 720p.</li>
<li><span class="settings-current">bestvideo[height<=1080]+bestaudio/best[height<=1080]</span>: best audio and max video height of 1080p.</li> <li><span class="settings-current">bestvideo[height<=1080]+bestaudio/best[height<=1080]</span>: best audio and max video height of 1080p.</li>
<li><span class="settings-current">bestvideo[height<=1080][VCODEC=avc1]+bestaudio[ACODEC=mp4a]/mp4</span>: Max 1080p video height with iOS compatible video and audio codecs.</li> <li><span class="settings-current">bestvideo[height<=1080][VCODEC*=avc1]+bestaudio[ACODEC*=mp4a]/mp4</span>: Max 1080p video height with iOS compatible video and audio codecs.</li>
<li><span class="settings-current">0</span>: deactivate and download the best quality possible as decided by yt-dlp.</li> <li><span class="settings-current">0</span>: deactivate and download the best quality possible as decided by yt-dlp.</li>
</ul> </ul>
<i>Make sure your custom format gets merged into a single file. Check out the <a href="https://github.com/yt-dlp/yt-dlp#format-selection" target="_blank">documentation</a> for valid configurations.</i><br> <i>Make sure your custom format gets merged into a single file. Check out the <a href="https://github.com/yt-dlp/yt-dlp#format-selection" target="_blank">documentation</a> for valid configurations.</i><br>
@ -153,6 +153,23 @@
{{ app_form.application_enable_cast }} {{ app_form.application_enable_cast }}
</div> </div>
</div> </div>
<div class="settings-group">
<h2 id="snapshots">Snapshots</h2>
<div class="settings-item">
<p>Current system snapshot: <span class="settings-current">{{ config.application.enable_snapshot }}</span></p>
<i>Automatically create daily deduplicated snapshots of the index, stored in Elasticsearch. Read first before activating: Wiki.</i><br>
{{ app_form.application_enable_snapshot }}
</div>
<div>
{% if snapshots %}
<p>Create next snapshot: <span class="settings-current">{{ snapshots.next_exec_str }}</span>, snapshots expire after <span class="settings-current">{{ snapshots.expire_after }}</span></p>
<br>
{% for snapshot in snapshots.snapshots %}
<p><button id="{{ snapshot.id }}" onclick="restoreSnapshot(id)">Restore</button> Snapshot created on: <span class="settings-current">{{ snapshot.start_date }}</span>, took <span class="settings-current">{{ snapshot.duration_s }}s</span> to create.</p>
{% endfor %}
{% endif %}
</div>
</div>
<button type="submit" name="application-settings">Update Application Configurations</button> <button type="submit" name="application-settings">Update Application Configurations</button>
</form> </form>
<div class="title-bar"> <div class="title-bar">

View File

@ -18,6 +18,7 @@ from django.views import View
from home.src.download.yt_dlp_base import CookieHandler from home.src.download.yt_dlp_base import CookieHandler
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.es.index_setup import get_available_backups from home.src.es.index_setup import get_available_backups
from home.src.es.snapshot import ElasticSnapshot
from home.src.frontend.api_calls import PostData from home.src.frontend.api_calls import PostData
from home.src.frontend.forms import ( from home.src.frontend.forms import (
AddToQueueForm, AddToQueueForm,
@ -942,6 +943,7 @@ class SettingsView(View):
user_form = UserSettingsForm() user_form = UserSettingsForm()
app_form = ApplicationSettingsForm() app_form = ApplicationSettingsForm()
scheduler_form = SchedulerSettingsForm() scheduler_form = SchedulerSettingsForm()
snapshots = ElasticSnapshot().get_snapshot_stats()
token = self.get_token(request) token = self.get_token(request)
context = { context = {
@ -953,6 +955,7 @@ class SettingsView(View):
"user_form": user_form, "user_form": user_form,
"app_form": app_form, "app_form": app_form,
"scheduler_form": scheduler_form, "scheduler_form": scheduler_form,
"snapshots": snapshots,
"version": settings.TA_VERSION, "version": settings.TA_VERSION,
} }
@ -1000,6 +1003,8 @@ class SettingsView(View):
for config_value, updated_value in updated: for config_value, updated_value in updated:
if config_value == "cookie_import": if config_value == "cookie_import":
self.process_cookie(config, updated_value) self.process_cookie(config, updated_value)
if config_value == "enable_snapshot":
ElasticSnapshot().setup()
def process_cookie(self, config, updated_value): def process_cookie(self, config, updated_value):
"""import and validate cookie""" """import and validate cookie"""

View File

@ -8,6 +8,6 @@ Pillow==9.2.0
redis==4.3.4 redis==4.3.4
requests==2.28.1 requests==2.28.1
ryd-client==0.0.6 ryd-client==0.0.6
uWSGI==2.0.20 uWSGI==2.0.21
whitenoise==6.2.0 whitenoise==6.2.0
yt_dlp==2022.10.4 yt_dlp==2022.10.4

View File

@ -1,148 +1,157 @@
'use strict';
/* global cast chrome getVideoPlayerVideoId postVideoProgress setProgressBar getVideoPlayer getVideoPlayerWatchStatus watchedThreshold isWatched getVideoData getURL getVideoPlayerCurrentTime */
function initializeCastApi() { function initializeCastApi() {
cast.framework.CastContext.getInstance().setOptions({ cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee. receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee.
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
}); });
var player = new cast.framework.RemotePlayer(); let player = new cast.framework.RemotePlayer();
var playerController = new cast.framework.RemotePlayerController(player); let playerController = new cast.framework.RemotePlayerController(player);
// Add event listerner to check if a connection to a cast device is initiated // Add event listerner to check if a connection to a cast device is initiated
playerController.addEventListener( playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, function() { cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
castConnectionChange(player) function () {
} castConnectionChange(player);
); }
playerController.addEventListener( );
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED, function() { playerController.addEventListener(
castVideoProgress(player) cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
} function () {
); castVideoProgress(player);
playerController.addEventListener( }
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED, function() { );
castVideoPaused(player) playerController.addEventListener(
} cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
); function () {
castVideoPaused(player);
}
);
} }
function castConnectionChange(player) { function castConnectionChange(player) {
// If cast connection is initialized start cast // If cast connection is initialized start cast
if (player.isConnected) { if (player.isConnected) {
// console.log("Cast Connected."); // console.log("Cast Connected.");
castStart(); castStart();
} else if (!player.isConnected) { } else if (!player.isConnected) {
// console.log("Cast Disconnected."); // console.log("Cast Disconnected.");
} }
} }
function castVideoProgress(player) { function castVideoProgress(player) {
var videoId = getVideoPlayerVideoId(); let videoId = getVideoPlayerVideoId();
if (player.mediaInfo.contentId.includes(videoId)) { if (player.mediaInfo.contentId.includes(videoId)) {
var currentTime = player.currentTime; let currentTime = player.currentTime;
var duration = player.duration; let duration = player.duration;
if ((currentTime % 10) <= 1.0 && currentTime != 0 && duration != 0) { // Check progress every 10 seconds or else progress is checked a few times a second if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
postVideoProgress(videoId, currentTime); // Check progress every 10 seconds or else progress is checked a few times a second
setProgressBar(videoId, currentTime, duration); postVideoProgress(videoId, currentTime);
if (!getVideoPlayerWatchStatus()) { // Check if video is already marked as watched setProgressBar(videoId, currentTime, duration);
if (watchedThreshold(currentTime, duration)) { if (!getVideoPlayerWatchStatus()) {
isWatched(videoId); // Check if video is already marked as watched
} if (watchedThreshold(currentTime, duration)) {
} isWatched(videoId);
} }
}
} }
}
} }
function castVideoPaused(player) { function castVideoPaused(player) {
var videoId = getVideoPlayerVideoId(); let videoId = getVideoPlayerVideoId();
var currentTime = player.currentTime; let currentTime = player.currentTime;
var duration = player.duration; let duration = player.duration;
if (player.mediaInfo != null) { if (player.mediaInfo != null) {
if (player.mediaInfo.contentId.includes(videoId)) { if (player.mediaInfo.contentId.includes(videoId)) {
if (currentTime != 0 && duration != 0) { if (currentTime !== 0 && duration !== 0) {
postVideoProgress(videoId, currentTime); postVideoProgress(videoId, currentTime);
} }
}
} }
}
} }
function castStart() { function castStart() {
var castSession = cast.framework.CastContext.getInstance().getCurrentSession(); let castSession = cast.framework.CastContext.getInstance().getCurrentSession();
// Check if there is already media playing on the cast target to prevent recasting on page reload or switching to another video page // Check if there is already media playing on the cast target to prevent recasting on page reload or switching to another video page
if (!castSession.getMediaSession()) { if (!castSession.getMediaSession()) {
var videoId = getVideoPlayerVideoId(); let videoId = getVideoPlayerVideoId();
var videoData = getVideoData(videoId); let videoData = getVideoData(videoId);
var contentId = getURL() + videoData.data.media_url; let contentId = getURL() + videoData.data.media_url;
var contentTitle = videoData.data.title; let contentTitle = videoData.data.title;
var contentImage = getURL() + videoData.data.vid_thumb_url; let contentImage = getURL() + videoData.data.vid_thumb_url;
contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded let contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
contentCurrentTime = getVideoPlayerCurrentTime(); // Get video's current position let contentCurrentTime = getVideoPlayerCurrentTime(); // Get video's current position
contentActiveSubtitle = []; let contentActiveSubtitle = [];
// Check if a subtitle is turned on. // Check if a subtitle is turned on.
for (var i = 0; i < getVideoPlayer().textTracks.length; i++) { for (let i = 0; i < getVideoPlayer().textTracks.length; i++) {
if (getVideoPlayer().textTracks[i].mode == "showing") { if (getVideoPlayer().textTracks[i].mode === 'showing') {
contentActiveSubtitle =[i + 1]; contentActiveSubtitle = [i + 1];
} }
} }
contentSubtitles = []; let contentSubtitles = [];
var videoSubtitles = videoData.data.subtitles; // Array of subtitles let videoSubtitles = videoData.data.subtitles; // Array of subtitles
if (typeof(videoSubtitles) != 'undefined' && videoData.config.downloads.subtitle) { if (typeof videoSubtitles !== 'undefined' && videoData.config.downloads.subtitle) {
for (var i = 0; i < videoSubtitles.length; i++) { for (let i = 0; i < videoSubtitles.length; i++) {
subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT); let subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
subtitle.trackContentId = videoSubtitles[i].media_url; subtitle.trackContentId = videoSubtitles[i].media_url;
subtitle.trackContentType = 'text/vtt'; subtitle.trackContentType = 'text/vtt';
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES; subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
subtitle.name = videoSubtitles[i].name; subtitle.name = videoSubtitles[i].name;
subtitle.language = videoSubtitles[i].lang; subtitle.language = videoSubtitles[i].lang;
subtitle.customData = null; subtitle.customData = null;
contentSubtitles.push(subtitle); contentSubtitles.push(subtitle);
} }
} }
mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type let mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type
// mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER // mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo
mediaInfo.metadata.title = contentTitle.replace("&amp;", "&"); // Set the video title mediaInfo.metadata.title = contentTitle.replace('&amp;', '&'); // Set the video title
mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle(); // mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
mediaInfo.tracks = contentSubtitles; mediaInfo.tracks = contentSubtitles;
var request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo. let request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo.
// request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support. // request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support.
request.currentTime = shiftCurrentTime(contentCurrentTime); // Set video start position based on the browser video position request.currentTime = shiftCurrentTime(contentCurrentTime); // Set video start position based on the browser video position
request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player
// request.autoplay = false; // Set content to auto play, true by default // request.autoplay = false; // Set content to auto play, true by default
castSession.loadMedia(request).then( castSession.loadMedia(request).then(
function() { function () {
castSuccessful(); castSuccessful();
}, },
function() { function (error) {
castFailed(errorCode); castFailed(error.code);
} }
); // Send request to cast device ); // Send request to cast device
} }
} }
function shiftCurrentTime(contentCurrentTime) { // Shift media back 3 seconds to prevent missing some of the content function shiftCurrentTime(contentCurrentTime) {
if (contentCurrentTime > 5) { // Shift media back 3 seconds to prevent missing some of the content
return(contentCurrentTime - 3); if (contentCurrentTime > 5) {
} else { return contentCurrentTime - 3;
return(0); } else {
} return 0;
}
} }
function castSuccessful() { function castSuccessful() {
// console.log('Cast Successful.'); // console.log('Cast Successful.');
getVideoPlayer().pause(); // Pause browser video on successful cast getVideoPlayer().pause(); // Pause browser video on successful cast
} }
function castFailed(errorCode) { function castFailed(errorCode) {
console.log('Error code: ' + errorCode); console.log('Error code: ' + errorCode);
} }
window['__onGCastApiAvailable'] = function(isAvailable) { window['__onGCastApiAvailable'] = function (isAvailable) {
if (isAvailable) { if (isAvailable) {
initializeCastApi(); initializeCastApi();
} }
} };

View File

@ -1182,6 +1182,10 @@ video:-webkit-full-screen {
margin: 15px; margin: 15px;
text-align: center; text-align: center;
} }
.view-controls.three {
grid-template-columns: unset;
justify-content: center;
}
.sort { .sort {
display: block; display: block;
} }

View File

@ -1,106 +1,110 @@
/** /**
* Handle multi channel notifications * Handle multi channel notifications
* *
*/ */
checkMessages() 'use strict';
checkMessages();
// page map to notification status // page map to notification status
const messageTypes = { const messageTypes = {
"download": ["message:download", "message:add", "message:rescan", "message:playlistscan"], download: ['message:download', 'message:add', 'message:rescan', 'message:playlistscan'],
"channel": ["message:subchannel"], channel: ['message:subchannel'],
"channel_id": ["message:playlistscan"], channel_id: ['message:playlistscan'],
"playlist": ["message:subplaylist"], playlist: ['message:subplaylist'],
"setting": ["message:setting"] setting: ['message:setting'],
} };
// start to look for messages // start to look for messages
function checkMessages() { function checkMessages() {
var notifications = document.getElementById("notifications"); let notifications = document.getElementById('notifications');
if (notifications) { if (notifications) {
var dataOrigin = notifications.getAttribute("data"); let dataOrigin = notifications.getAttribute('data');
getMessages(dataOrigin); getMessages(dataOrigin);
} }
} }
// get messages for page on timer // get messages for page on timer
function getMessages(dataOrigin) { function getMessages(dataOrigin) {
fetch('/progress/').then(response => { fetch('/progress/')
return response.json(); .then(response => {
}).then(responseData => { return response.json();
var messages = buildMessage(responseData, dataOrigin); })
if (messages.length > 0) { .then(responseData => {
// restart itself let messages = buildMessage(responseData, dataOrigin);
setTimeout(function() { if (messages.length > 0) {
getMessages(dataOrigin); // restart itself
}, 3000); setTimeout(function () {
}; getMessages(dataOrigin);
}, 3000);
}
}); });
} }
// make div for all messages, return relevant // make div for all messages, return relevant
function buildMessage(responseData, dataOrigin) { function buildMessage(responseData, dataOrigin) {
// filter relevan messages // filter relevan messages
var allMessages = responseData["messages"]; let allMessages = responseData['messages'];
var messages = allMessages.filter(function(value) { let messages = allMessages.filter(function (value) {
return messageTypes[dataOrigin].includes(value["status"]) return messageTypes[dataOrigin].includes(value['status']);
}, dataOrigin); }, dataOrigin);
// build divs // build divs
var notificationDiv = document.getElementById("notifications"); let notificationDiv = document.getElementById('notifications');
var nots = notificationDiv.childElementCount; let nots = notificationDiv.childElementCount;
notificationDiv.innerHTML = ""; notificationDiv.innerHTML = '';
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
var messageData = messages[i]; let messageData = messages[i];
var messageStatus = messageData["status"]; let messageStatus = messageData['status'];
var messageBox = document.createElement("div"); let messageBox = document.createElement('div');
var title = document.createElement("h3"); let title = document.createElement('h3');
title.innerHTML = messageData["title"]; title.innerHTML = messageData['title'];
var message = document.createElement("p"); let message = document.createElement('p');
message.innerHTML = messageData["message"]; message.innerHTML = messageData['message'];
messageBox.appendChild(title); messageBox.appendChild(title);
messageBox.appendChild(message); messageBox.appendChild(message);
messageBox.classList.add(messageData["level"], "notification"); messageBox.classList.add(messageData['level'], 'notification');
notificationDiv.appendChild(messageBox); notificationDiv.appendChild(messageBox);
if (messageStatus === "message:download") { if (messageStatus === 'message:download') {
checkDownloadIcons(); checkDownloadIcons();
}; }
}; }
// reload page when no more notifications // reload page when no more notifications
if (nots > 0 && messages.length === 0) { if (nots > 0 && messages.length === 0) {
location.reload(); location.reload();
}; }
return messages return messages;
} }
// check if download icons are needed // check if download icons are needed
function checkDownloadIcons() { function checkDownloadIcons() {
var iconBox = document.getElementById("downloadControl"); let iconBox = document.getElementById('downloadControl');
if (iconBox.childElementCount === 0) { if (iconBox.childElementCount === 0) {
var downloadIcons = buildDownloadIcons(); let downloadIcons = buildDownloadIcons();
iconBox.appendChild(downloadIcons); iconBox.appendChild(downloadIcons);
}; }
} }
// add dl control icons // add dl control icons
function buildDownloadIcons() { function buildDownloadIcons() {
var downloadIcons = document.createElement('div'); let downloadIcons = document.createElement('div');
downloadIcons.classList = 'dl-control-icons'; downloadIcons.classList = 'dl-control-icons';
// stop icon // stop icon
var stopIcon = document.createElement('img'); let stopIcon = document.createElement('img');
stopIcon.setAttribute('id', "stop-icon"); stopIcon.setAttribute('id', 'stop-icon');
stopIcon.setAttribute('title', "Stop Download Queue"); stopIcon.setAttribute('title', 'Stop Download Queue');
stopIcon.setAttribute('src', "/static/img/icon-stop.svg"); stopIcon.setAttribute('src', '/static/img/icon-stop.svg');
stopIcon.setAttribute('alt', "stop icon"); stopIcon.setAttribute('alt', 'stop icon');
stopIcon.setAttribute('onclick', 'stopQueue()'); stopIcon.setAttribute('onclick', 'stopQueue()');
// kill icon // kill icon
var killIcon = document.createElement('img'); let killIcon = document.createElement('img');
killIcon.setAttribute('id', "kill-icon"); killIcon.setAttribute('id', 'kill-icon');
killIcon.setAttribute('title', "Kill Download Queue"); killIcon.setAttribute('title', 'Kill Download Queue');
killIcon.setAttribute('src', "/static/img/icon-close.svg"); killIcon.setAttribute('src', '/static/img/icon-close.svg');
killIcon.setAttribute('alt', "kill icon"); killIcon.setAttribute('alt', 'kill icon');
killIcon.setAttribute('onclick', 'killQueue()'); killIcon.setAttribute('onclick', 'killQueue()');
// stich together // stich together
downloadIcons.appendChild(stopIcon); downloadIcons.appendChild(stopIcon);
downloadIcons.appendChild(killIcon); downloadIcons.appendChild(killIcon);
return downloadIcons return downloadIcons;
} }

File diff suppressed because it is too large Load Diff