Compare commits

...

151 Commits

Author SHA1 Message Date
simon
232afee3f6 update for v0.0.7 2021-11-01 17:16:00 +07:00
simon
b2a4f88980 add re_sync_thumbs to frontend 2021-11-01 16:42:07 +07:00
simon
1f0e305527 add re_sync_thumbs backend functionality 2021-11-01 16:20:21 +07:00
simon
4d9cda3a24 better subscribe_to task with notification 2021-10-31 17:48:56 +07:00
simon
283a72308e handle deleting channel without videos 2021-10-31 17:30:29 +07:00
simon
164ee5c523 more valueerrors in url extraction 2021-10-31 17:05:13 +07:00
simon
bce1022677 userspace channelID view_style 2021-10-31 16:15:33 +07:00
simon
1ba3090db1 rewrite url_str extractor to convert channel names into channel ids, #40 2021-10-31 16:04:28 +07:00
simon
c92800701a be more specific about multi-single user 2021-10-30 19:32:47 +07:00
simon
caff9ce189 standardize parse_url_list to only return ids 2021-10-30 17:51:50 +07:00
simon
3375cbc767 cleanup logging 2021-10-30 17:51:07 +07:00
simon
cc90e437a9 show progress message for thumbnail download 2021-10-30 17:06:54 +07:00
simon
29fc5fbda2 improved progress message for frontend feedback 2021-10-30 17:06:22 +07:00
simon
20aab04fbf fix keyerror 2021-10-30 16:35:10 +07:00
simon
45c2215dc2 validate subscribe form and handover to scheduler, #75 2021-10-30 15:00:10 +07:00
simon
aebf44ee7e validate add to queue form 2021-10-30 14:14:16 +07:00
simon
266d3703cd validate search forms 2021-10-30 13:19:16 +07:00
simon
a8ded25b35 userspace color 2021-10-29 23:43:19 +07:00
simon
a4397b5204 validate settings form and userspace archive page_size 2021-10-29 22:37:31 +07:00
simon
f24a3dd1f5 userspace sort_order and sort_by 2021-10-29 14:42:12 +07:00
simon
ef13b9259e userspace hide_watched in home and channelid view 2021-10-29 14:02:21 +07:00
simon
180a63d6dd userspace show_subed_only in channel view 2021-10-29 13:35:37 +07:00
simon
88f28a7d1e userspace show_ignored_only on downloads view 2021-10-29 12:23:33 +07:00
simon
b2ca7710cd userspace gird-list view 2021-10-29 11:45:13 +07:00
simon
21d55561a0 extend sort and filter to channel_id view 2021-10-28 18:00:59 +07:00
simon
8255fe9e55 validate expected env vars before starting 2021-10-28 16:17:47 +07:00
simon
254c518505 basic auth for elasticsearch 2021-10-28 15:49:58 +07:00
simon
ebd8368856 namespace redis keys to ta: 2021-10-27 18:07:35 +07:00
simon
78561981ad extend sort_by and sort_order in home view 2021-10-27 15:29:17 +07:00
simon
d4e266f9c0 refactor home view to view_config dict 2021-10-27 11:01:09 +07:00
simon
bb909d866c consolidating elasticsearch details 2021-10-26 18:38:43 +07:00
simon
969b6bff0d add throttled-rate and user management docs 2021-10-26 18:21:36 +07:00
simon
5f3fc460c1 implementing throttled-rate option 2021-10-26 17:14:26 +07:00
simon
581314a8a4 bump yt_dlp version 2021-10-26 17:07:52 +07:00
simon
50396a666d use expose instead of publish 2021-10-26 16:23:41 +07:00
simon
3526d62540 custom user models for admin login 2021-10-25 19:28:05 +07:00
simon
6f58dc47cc add admin interface link 2021-10-25 14:41:21 +07:00
simon
1283f437c3 use admin pw hash as secret_key 2021-10-25 14:22:23 +07:00
simon
247c0845e0 put db on persistend volume 2021-10-25 13:40:09 +07:00
simon
66f184b41b increase timeout to wait for es 2021-10-25 13:23:54 +07:00
simon
0ce5183056 handle failed login 2021-10-24 15:34:00 +07:00
simon
dffe974f9e move redundant keywords from downlaod title to badge, #64 2021-10-22 18:24:18 +07:00
simon
05b25d004e fix linting 2021-10-22 18:23:06 +07:00
simon
825ebd874e handle next url parameter in login 2021-10-22 12:01:30 +07:00
simon
01ccca16e4 added logout functionality 2021-10-22 11:45:06 +07:00
simon
3d0859ceec add basic usermanagement and login functionality 2021-10-21 19:33:27 +07:00
simon
401aa6c2f8 Merge branch 'master' into testing 2021-10-21 11:24:26 +07:00
simon
aeb6f48ac4 add iOS compatible format example to settings page 2021-10-21 11:15:38 +07:00
borgmanJeremy
d03c45e580 Updated readme to specify how to operate with iOS and MacOS. (#66) issue #61 2021-10-21 11:13:39 +07:00
simon
e4352e5bec playsinline fix for mobile safari 2021-10-20 20:49:20 +07:00
simon
58efe64c5d make chown command optional by omitting HOST_UID and HOST_GID, #58 2021-10-20 18:41:39 +07:00
simon
6f4bab41d5 increase uwsgi buffer-size, #60 #48 2021-10-20 18:16:53 +07:00
simon
d250ed645a initial template for login route 2021-10-18 17:14:59 +07:00
Crocs
09584e43b8 Update README.md (#57)
Unraid installation instructions
2021-10-18 10:20:59 +07:00
simon
4ef98b7448 simplify docker deployment 2021-10-17 13:29:22 +07:00
simon
528938942a update docs for v0.0.6 take 2 2021-10-17 11:31:21 +07:00
simon
de2371c43c handle running empty dl queue, #55 2021-10-17 11:28:05 +07:00
simon
1a88c85436 skip video if is_live now 2021-10-17 10:49:02 +07:00
simon
63be6afcfe allow duration_sec extraction to fail cleanly for add to queue 2021-10-17 09:40:46 +07:00
simon
559a4c9a58 a note about multi arch builds 2021-10-16 18:12:41 +07:00
simon
08338f3270 add thumbnail download for manual import 2021-10-16 17:03:39 +07:00
simon
0c3c1b650a disable notifiation on rescan, fix keyerror issue in missing_thumbs build 2021-10-15 13:16:35 +07:00
simon
45d8b7e218 ignore missing video rating 2021-10-14 23:12:30 +07:00
simon
5edc78a826 update docs for v0.0.6 2021-10-14 22:48:43 +07:00
simon
fd2fa628e3 make sure to get both banner and icon 2021-10-14 22:48:23 +07:00
simon
ba12c2ad25 simple progress message for thumbnail bulk download 2021-10-14 21:28:27 +07:00
simon
0d061d8601 get art work for new subscribed channels 2021-10-14 17:47:07 +07:00
simon
0844f7b91a add delayed retry for non 404 thumb download error 2021-10-14 14:44:50 +07:00
simon
044b12d5a5 remove now unused thumbnail methods from searching module 2021-10-14 14:42:35 +07:00
simon
ce5f8b6a38 cleanup method for unused thumbs 2021-10-14 11:59:30 +07:00
simon
9ad42dfa38 add artwork refresh on rescan 2021-10-14 11:18:32 +07:00
simon
952bc4f97c add thumb dl after rescan 2021-10-14 10:03:54 +07:00
simon
bfd51e6804 extend test deploy to take platform argument 2021-10-13 19:34:55 +07:00
simon
72af95acd8 fallback channel and video artwork for 404 errors 2021-10-13 19:34:16 +07:00
simon
3473c79d63 use TARGETPLATFORM to install repo ffmpeg for arm64 and patched ffmpeg for amd64 builds 2021-10-12 23:00:25 +07:00
simon
a4ae6ca1ca Merge branch 'testing' of github.com:bbilly1/tubearchivist into testing 2021-10-12 17:49:18 +07:00
lamusmaser
48a4827ad5 Update deploy.sh to utilize buildx for multiarch builds. This is inline with Issue #6. (#51) 2021-10-12 17:47:48 +07:00
simon
29f17680bf consolidate delete thumbnails into ThumbManager class 2021-10-11 19:29:27 +07:00
simon
519c710e7d testing tests 2021-10-11 16:55:47 +07:00
simon
1b04c00ae7 standardizing on progress/ url 2021-10-11 16:55:08 +07:00
simon
9f12966ef7 bumping yt_dlp version, requires additional noprogress option 2021-10-11 16:34:19 +07:00
simon
76f49b4cf7 comparing with instance instead 2021-10-11 16:11:30 +07:00
simon
35a5eda36e downloading channel artwork after scraping 2021-10-11 16:03:25 +07:00
simon
567e9d473b fix killing task with missing task id 2021-10-11 15:54:36 +07:00
simon
e202265a5f extended validate_thumbnails for channel art 2021-10-11 14:27:48 +07:00
simon
bc84696792 new thumbnails module and new cache layout 2021-10-11 13:33:28 +07:00
simon
f5621954fb star icons for video rating 2021-10-10 16:09:02 +07:00
simon
ef75f6dd16 add delete channel to frontend 2021-10-09 20:33:32 +07:00
simon
b53a1763c8 better channel folder path builder with clean_string 2021-10-09 20:32:42 +07:00
simon
11780a0ff0 add delete channel backend 2021-10-09 19:54:36 +07:00
simon
8d3e2f14fc add delete video to frontend 2021-10-09 17:11:13 +07:00
simon
7405e960e5 delete video backend functionality 2021-10-08 16:18:01 +07:00
simon
8c97e6786a chown to fix permissions on new channel folders 2021-10-08 15:16:02 +07:00
simon
563222a26e fix linting 2021-10-08 15:10:44 +07:00
simon
64ccd3830e implementing filesystem rescan to clean index 2021-10-08 14:56:07 +07:00
simon
c2a6ac6f44 fix wiki link 2021-10-08 11:36:18 +07:00
simon
0b88fd8b1f implement watched-unwatched toggle, #39 2021-10-07 23:38:17 +07:00
simon
744780f4bd add optional EmbedThumbnail postprocessor 2021-10-07 22:28:32 +07:00
simon
4d152e1e54 bumping versions 2021-10-07 20:08:38 +07:00
simon
3500044b0d adding woff files and OFL license 2021-10-07 19:41:52 +07:00
simon
7e0abc0d20 fix run sync_redis_state on startup 2021-10-04 01:07:15 +07:00
simon
b0fb2bbb00 update roadmap 2021-10-03 20:25:35 +07:00
simon
4e7cba70d5 update to new ffmpeg naming convention 2021-10-03 20:25:23 +07:00
simon
28216e455e bumping versions for new release 2021-10-03 19:35:33 +07:00
simon
7df4233224 cleaning up leftover code from old vesions 2021-10-03 19:33:58 +07:00
simon
5c2a8286d3 cleanup and fixing some spacing issues 2021-10-03 18:54:04 +07:00
simon
f5f3617e96 implement toggle view to hide watched videos 2021-10-03 18:52:29 +07:00
simon
ccc8a658b7 implement toggle view to show subscribed only 2021-10-03 18:44:44 +07:00
simon
ed73bff8fa implement toggle view ignored only in downloads page 2021-10-03 18:17:07 +07:00
simon
26a0f8930a renaming 'download queue' button to 'start download' 2021-10-03 12:25:56 +07:00
simon
30d95315b2 consolidate documentation in wiki 2021-10-03 12:05:39 +07:00
simon
c95c16208d removed documentation with links to wiki and more 2021-10-03 11:47:29 +07:00
simon
18459e2486 about closing issues 2021-10-03 11:24:31 +07:00
simon
280e83bb2c kibana for testing ES queries 2021-10-02 19:48:17 +07:00
simon
95cdcbae9a grid and list view for channels overview page 2021-10-02 19:39:02 +07:00
simon
0b0502e246 grid and list view for downloads template 2021-10-02 18:37:22 +07:00
simon
99781290db grid and list view for home and channel id templates 2021-10-01 16:24:39 +07:00
simon
7c34ceb9f8 framework to switch between grid and list view 2021-10-01 14:42:17 +07:00
simon
21ad1fd832 use latest tag instead 2021-10-01 13:44:14 +07:00
simon
b12df3f312 using ffmpeg patched builds instead of repo version, #37 #26 2021-09-30 22:01:30 +07:00
simon
4d348955a3 allowing custom redis port 2021-09-30 18:03:23 +07:00
simon
2b89408ec1 ignore wiki from container 2021-09-30 17:58:56 +07:00
simon
e52becd50e a word about setting up your testing environment 2021-09-30 14:21:48 +07:00
simon
2b6a805c1d fix links and icons 2021-09-29 13:19:21 +07:00
simon
108b1bac1f fix links 2021-09-28 22:09:58 +07:00
simon
12ad8fcead documenting main pages 2021-09-28 21:58:43 +07:00
simon
2905a67ab9 show ignored videos, forget or download ids 2021-09-28 16:53:45 +07:00
simon
f5a56fca86 refactored redis functions into dedicated RedisArchivist class 2021-09-28 10:33:00 +07:00
simon
a242f744d5 moove startup functions to ready() method to avoid double execs 2021-09-27 17:45:44 +07:00
simon
3d79f08311 handle return value of del_message 2021-09-27 17:44:42 +07:00
simon
f22e02d2f1 hotfix to release leftover locks on restart 2021-09-27 10:38:57 +07:00
simon
9528e11ca2 updated roadmap 2021-09-26 12:59:58 +07:00
simon
85eaac0b57 bumping versions for new release 2021-09-26 12:05:41 +07:00
simon
fab918db79 setting limit_count default to false after implementing dynamic dl queue 2021-09-26 12:01:51 +07:00
simon
72e45bcf5a restructured, added comment about updating and donating 2021-09-26 11:34:54 +07:00
simon
ea2d0bcb6c initial wiki pages 2021-09-26 11:21:54 +07:00
simon
11067094b2 implement os.listdir sanitizer for hidden files, #30 2021-09-25 18:59:54 +07:00
simon
2de99d7e37 better implementation for dl icon directly in message builder 2021-09-25 18:54:37 +07:00
simon
c165f152a9 allowing to cancle download_now tasks 2021-09-25 17:40:33 +07:00
simon
51ec765433 add kill queue function to frontend 2021-09-25 15:35:36 +07:00
simon
47020e0cfa implement kill function for dl queue 2021-09-24 23:37:26 +07:00
simon
f53391c1bb add limit_queue setting back, make buttons only show up while downlaoding 2021-09-24 21:27:53 +07:00
simon
d0b54f8a88 add stop queue button to frontend 2021-09-24 18:03:22 +07:00
simon
91a57cc780 fix duplication issue with download_now 2021-09-24 16:58:48 +07:00
simon
fb1913f912 ignore now also removes itself from redis queue 2021-09-24 16:58:05 +07:00
simon
ea6d81102f a word about updating tube archivist 2021-09-23 22:40:38 +07:00
simon
8fc5926ce7 rewrote download_single and download_pending tasks for redis queue 2021-09-23 18:10:45 +07:00
simon
78d8bc9d24 changed dl queue to redis, changed sort order to represent actual queue order 2021-09-23 18:09:46 +07:00
simon
7f57cabbc6 initial dynamic dl queue in redis 2021-09-23 16:58:47 +07:00
simon
214a248821 better error handeling in add to download form 2021-09-23 10:50:44 +07:00
simon
ef3447cbfb fix read waiting for return issue 2021-09-22 18:11:05 +07:00
77 changed files with 3808 additions and 848 deletions

View File

@@ -17,5 +17,8 @@ venv/
# Unneeded graphics
assets/*
# Unneeded docs
docs/*
# for local testing only
testing.sh

3
.gitignore vendored
View File

@@ -3,6 +3,3 @@ __pycache__
# django testing db
db.sqlite3
# frontend fonts
*ttf.woff

View File

@@ -6,7 +6,37 @@ If you haven't already, the best place to start is the README. This will give yo
## Report a bug
If you notice something is not working as expected, check to see if it has been previously reported in the [open issues](https://github.com/bbilly1/tubearchivist/issues).
If it has not yet been disclosed, go ahead and create an issue.
If it has not yet been disclosed, go ahead and create an issue.
If the issue doesn't move forward due to a lack of response, I assume it's solved and will close it after some time to keep the list fresh.
## Wiki
The wiki is where all user functions are explained in detail. These pages are mirrored into the **docs** folder of the repo. This allows for pull requests and all other features like regular code. Make any changes there, and I'll sync them with the wiki tab.
## Development Environment
I have learned the hard way, that working on a dockerized application outside of docker is very error prone and in general not a good idea. So if you want to test your changes, it's best to run them in a docker testing environment.
This is my setup I have landed on, YMMV:
- Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is the where all the changes are happening, might be unstable and is WIP.
- Then I have a VM on KVM hypervisor running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate lowend environments by limiting CPU cores and memory. But you could also just run docker on your host system.
- The `Dockerfile` is structured in a way that the actual application code is in the last layer so rebuilding the image with only code changes utilizes the build cache for everything else and will take just 2-3 secs.
- Take a look at the `deploy.sh` file. I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run:
```bash
./deploy.sh test
```
- The command above will also copy the file `tubarchivist/testing.sh` into the working folder of the container. Running this script will install additional debugging tools I regularly use in testing.
- The `test` argument takes another optional argument to build for a specific architecture valid options are: `amd64`, `arm64` and `multi`, default is `amd64`.
- This `deploy.sh` file is not meant to be universally usable for every possible environment but could serve as an idea on how to automatically rebuild containers to test changes - customize to your liking.
## Working with Elasticsearch
Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries.
If you want to run queries in on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it.
## Implementing a new feature
Do you see anything on the roadmap that you would like to take a closer look at but you are not sure, what's the best way to tackle that? Or anything not on there yet you'd like to implement but are not sure how? Open up an issue and we try to find a solution together.
## Making changes

View File

@@ -1,16 +1,30 @@
# build the tube archivist image from default python slim image
FROM python:3.9.7-slim-bullseye
ARG TARGETPLATFORM
ENV PYTHONUNBUFFERED 1
# install distro packages needed
RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \
build-essential \
ffmpeg \
nginx \
curl && rm -rf /var/lib/apt/lists/*
# get newest patched ffmpeg and ffprobe builds for amd64 fall back to repo ffmpeg for arm64
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ] ; then \
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
| grep browser_download_url \
| grep linux64-gpl-4.4.tar.xz \
| cut -d '"' -f 4 \
| xargs curl -L --output ffmpeg.tar.xz && \
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe" && \
rm ffmpeg.tar.xz \
; elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
apt-get -y update && apt-get -y install --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* \
; fi
# copy config files
COPY nginx.conf /etc/nginx/conf.d/

106
README.md
View File

@@ -2,6 +2,19 @@
<center><h1>Your self hosted YouTube media server</h1></center>
## Table of contents:
* [Wiki](https://github.com/bbilly1/tubearchivist/wiki) for a detailed documentation
* [Core functionality](#core-functionality)
* [Screenshots](#screenshots)
* [Problem Tube Archivist tries to solve](#problem-tube-archivist-tries-to-solve)
* [Installing and updating](#installing-and-updating)
* [Getting Started](#getting-started)
* [Potential pitfalls](#potential-pitfalls)
* [Roadmap](#roadmap)
* [Known limitations](#known-limitations)
* [Donate](#donate)
------------------------
## Core functionality
* Subscribe to your favorite YouTube channels
@@ -29,16 +42,18 @@
## Problem Tube Archivist tries to solve
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface.
## Installation
## Installing and updating
Take a look at the example `docker-compose.yml` file provided. Tube Archivist depends on three main components split up into separate docker containers:
### Tube Archivist
The main Python application that displays and serves your video collection, built with Django.
- Serves the interface on port `8000`
- Needs a mandatory volume for the video archive at **/youtube**
- And another recommended volume to save the cache for thumbnails and artwork at **/cache**.
- Needs a volume for the video archive at **/youtube**
- And another volume to save application data at **/cache**.
- The environment variables `ES_URL` and `REDIS_HOST` are needed to tell Tube Archivist where Elasticsearch and Redis respectively are located.
- The environment variables `HOST_UID` and `HOST_GID` allows Tube Archivist to `chown` the video files to the main host system user instead of the container user.
- The environment variables `HOST_UID` and `HOST_GID` allows Tube Archivist to `chown` the video files to the main host system user instead of the container user. Those two variables are optional, not setting them will disable that functionality. That might be needed if the underlying filesystem doesn't support `chown` like *NFS*.
- Change the environment variables `TA_USERNAME` and `TA_PASSWORD` to create the initial credentials.
- `ELASTIC_PASSWORD` is for the password for Elasticsearch. The environment variable `ELASTIC_USER` is optional, should you want to change the username from the default *elastic*.
### Elasticsearch
Stores video meta data and makes everything searchable. Also keeps track of the download queue.
@@ -50,42 +65,25 @@ Follow the [documentation](https://www.elastic.co/guide/en/elasticsearch/referen
### Redis JSON
Functions as a cache and temporary link between the application and the file system. Used to store and display messages and configuration variables.
- Needs to be accessible over the default port `6379`
- Takes an optional volume at **/data** to make your configuration changes permanent.
- Needs a volume at **/data** to make your configuration changes permanent.
## 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.
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.
4. Click on *Download queue* and let Tube Archivist to it's thing.
5. Enjoy your archived collection!
## Import your existing library
So far this depends on the video you are trying to import to be still available on YouTube to get the metadata. Add the files you like to import to the */cache/import* folder. Then start the process from the settings page *Manual media files import*. Make sure to follow one of the two methods below.
### Redis on a custom port
For some architectures it might be required to run Redis JSON on a nonstandard port. To for example change the Redis port to **6380**, set the following values:
- Set the environment variable `REDIS_PORT=6380` to the *tubearchivist* service.
- For the *archivist-redis* service, change the ports to `6380:6380`
- Additionally set the following value to the *archivist-redis* service: `command: --port 6380 --loadmodule /usr/lib/redis/modules/rejson.so`
### Method 1:
Add a matching *.json* file with the media file. Both files need to have the same base name, for example:
- For the media file: \<base-name>.mp4
- For the JSON file: \<base-name>.info.json
- Alternate JSON file: \<base-name>.json
### Updating Tube Archivist
You will see the current version number of **Tube Archivist** in the footer of the interface so you can compare it with the latest release to make sure you are running the *latest and greatest*.
* There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there.
* All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned.
**Tube Archivist** then looks for the 'id' key within the JSON file to identify the video.
### Alternative installation instructions:
- **arm64**: Newest Tube Archivist container is multi arch, so is Elasticsearch. RedisJSON doesn't offer arm builds, you can use `bbilly1/rejson`, an unofficial rebuild for arm64.
- NOTE: This is untested, looking for feedback.
- **Synology**: There is a [discussion thread](https://github.com/bbilly1/tubearchivist/discussions/48) with Synology installation instructions.
- **Unraid**: The three containers needed are all in the Community Applications. First install `TubeArchivist RedisJSON` followed by `TubeArchivist ES`, and finally you can install `TubeArchivist`. If you have unraid specific issues, report those to the [support thread](https://forums.unraid.net/topic/114073-support-crocs-tube-archivist/ "support thread").
### Method 2:
Detect the YouTube ID from filename, this accepts the default yt-dlp naming convention for file names like:
- \<base-name>[\<youtube-id>].mp4
- The YouTube ID in square brackets at the end of the filename is the crucial part.
### Some notes:
- This will **consume** the files you put into the import folder: Files will get converted to mp4 if needed (this might take a long time...) and moved to the archive, *.json* files will get deleted upon completion to avoid having duplicates on the next run.
- Maybe start with a subset of your files to import to make sure everything goes well...
- Follow the logs to monitor progress and errors: `docker-compose logs -f tubearchivist`.
## Backup and restore
From the settings page you can backup your metadata into a zip file. The file will get stored at *cache/backup* and will contain the necessary files to restore the Elasticsearch index formatted **nd-json** files as well a complete export of the index in a set of conventional **json** files.
The restore functionality will expect the same zip file in *cache/backup* and will recreate the index from the snapshot.
BE AWARE: This will **replace** your current index with the one from the backup file.
## Potential pitfalls
### vm.max_map_count
@@ -109,30 +107,52 @@ chown 1000:0 /path/to/mount/point
```
This will match the permissions with the **UID** and **GID** of elasticsearch within the container and should fix the issue.
## 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 a compatible format must be specified. For example:
```
bestvideo[VCODEC=avc1]+bestaudio[ACODEC=mp4a]/mp4
```
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.
4. Click on *Start download* and let **Tube Archivist** to it's thing.
5. Enjoy your archived collection!
## Roadmap
This should be considered as a **minimal viable product**, there is an extensive list of future functions and improvements planned.
### Functionality
- [ ] Access control
- [ ] User roles
- [ ] Delete videos and channel
- [ ] Create playlists
- [ ] Podcast mode to serve channel as mp3
- [ ] Implement [PyFilesystem](https://github.com/PyFilesystem/pyfilesystem2) for flexible video storage
- [ ] Dynamic download queue
- [ ] Un-ignore videos
- [ ] Optional automatic deletion of watched items after a specified time
- [ ] Subtitle download & indexing
- [X] Access control [2021-11-01]
- [X] Delete videos and channel [2021-10-16]
- [X] Add thumbnail embed option [2021-10-16]
- [X] Un-ignore videos [2021-10-03]
- [X] Dynamic download queue [2021-09-26]
- [X] Backup and restore [2021-09-22]
- [X] Scan your file system to index already downloaded videos [2021-09-14]
### UI
- [ ] Create a github wiki for user documentation
- [ ] Show similar videos on video page
- [ ] Multi language support
- [ ] Grid and list view for both channel and video list pages
- [ ] Show total video downloaded vs total videos available in channel
- [X] Grid and list view for both channel and video list pages [2021-10-03]
- [X] Create a github wiki for user documentation [2021-10-03]
## Known limitations
- Video files created by Tube Archivist need to be **mp4** video files for best browser compatibility.
- Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either.
- For now this is meant to be run in a trusted network environment.
- For now this is meant to be run in a trusted network environment. Not everything is properly authenticated.
- There is currently no flexibility in naming of the media files.
## Donate
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
Second best way to support the development is to provide for caffeinated beverages:
* [Paypal.me](https://paypal.me/bbilly1) for a one time coffee
* [Paypal Subscription](https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-03770005GR991451KMFGVPMQ) for a monthly coffee
* [co-fi.com](https://ko-fi.com/bbilly1) for an alternative platform

View File

@@ -5,11 +5,19 @@
# blackhole for local production
# docker to publish
# create builder:
# docker buildx create --name tubearchivist
# docker buildx use tubearchivist
# docker buildx inspect --bootstrap
# more details:
# https://github.com/bbilly1/tubearchivist/issues/6
set -e
function sync_blackhole {
# docker commands need sudo
# docker commands need sudo, only build amd64
host="blackhole.local"
read -sp 'Password: ' remote_pw
@@ -23,7 +31,7 @@ function sync_blackhole {
--exclude "db.sqlite3" \
. -e ssh "$host":tubearchivist
echo "$PASS" | ssh "$host" 'sudo -S docker build -t bbilly1/tubearchivist:latest tubearchivist 2>/dev/null'
echo "$PASS" | ssh "$host" 'sudo -S docker buildx build --platform linux/amd64 -t bbilly1/tubearchivist:latest tubearchivist --load 2>/dev/null'
echo "$PASS" | ssh "$host" 'sudo -S docker-compose up -d 2>/dev/null'
}
@@ -31,6 +39,8 @@ function sync_blackhole {
function sync_test {
# docker commands don't need sudo in testing vm
# pass argument to build for specific platform
host="tubearchivist.local"
rsync -a --progress --delete-after \
@@ -43,7 +53,17 @@ function sync_test {
rsync -r --progress --delete docker-compose.yml -e ssh "$host":docker
ssh "$host" 'docker build -t bbilly1/tubearchivist:latest tubearchivist'
if [[ $1 = "amd64" ]]; then
platform="linux/amd64"
elif [[ $1 = "arm64" ]]; then
platform="linux/arm64"
elif [[ $1 = "multi" ]]; then
platform="linux/amd64,linux/arm64"
else
platform="linux/amd64"
fi
ssh "$host" "docker buildx build --platform $platform -t bbilly1/tubearchivist:latest tubearchivist --load"
ssh "$host" 'docker-compose -f docker/docker-compose.yml up -d'
ssh "$host" 'docker cp tubearchivist/tubearchivist/testing.sh tubearchivist:/app/testing.sh'
@@ -102,19 +122,14 @@ function sync_docker {
printf "\ncreate new version:\n"
read -r VERSION
echo "build and push $VERSION?"
read -rn 1
# start build
sudo docker build -t bbilly1/tubearchivist:latest -t bbilly1/tubearchivist:"$VERSION" .
printf "\nlatest images:\n"
sudo docker image ls bbilly1/tubearchivist
read -s "Push?"
# push to docker
echo "pushing latest:"
sudo docker push bbilly1/tubearchivist:latest
echo "pushing $VERSION"
sudo docker push bbilly1/tubearchivist:"$VERSION"
sudo docker buildx build \
--platform linux/amd64,linux/arm64 \
-t bbilly1/tubearchivist:latest \
-t bbilly1/tubearchivist:"$VERSION" --push .
# create release tag
echo "commits since last version:"
@@ -132,7 +147,7 @@ python version_check.py
if [[ $1 == "blackhole" ]]; then
sync_blackhole
elif [[ $1 == "test" ]]; then
sync_test
sync_test "$2"
elif [[ $1 == "validate" ]]; then
validate "$2"
elif [[ $1 == "docker" ]]; then

View File

@@ -15,6 +15,9 @@ services:
- REDIS_HOST=archivist-redis
- HOST_UID=1000
- HOST_GID=1000
- TA_USERNAME=tubearchivist
- TA_PASSWORD=verysecret
- ELASTIC_PASSWORD=verysecret
depends_on:
- archivist-es
- archivist-redis
@@ -22,17 +25,19 @@ services:
image: redislabs/rejson:latest
container_name: archivist-redis
restart: always
ports:
- 6379:6379
expose:
- "6379"
volumes:
- ./volumes/tubearchivist/redis:/data
depends_on:
- archivist-es
archivist-es:
image: docker.elastic.co/elasticsearch/elasticsearch:7.14.1
image: docker.elastic.co/elasticsearch/elasticsearch:7.15.1
container_name: archivist-es
restart: always
environment:
- "xpack.security.enabled=true"
- "ELASTIC_PASSWORD=verysecret"
- "discovery.type=single-node"
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
@@ -41,5 +46,5 @@ services:
hard: -1
volumes:
- ./volumes/tubearchivist/es:/usr/share/elasticsearch/data
ports:
- 9200:9200
expose:
- "9200"

24
docs/Channels.md Normal file
View File

@@ -0,0 +1,24 @@
# Channels Overview and Channel Detail Page
The channels are organized on two different levels:
## Channels Overview
Accessible at `/channel/` of your Tube Archivist, the **Overview Page** shows a list of all channels you have indexed.
- You can filter that list to show or hide subscribed channels from the drop down menu. Clicking on the channel banner or the channel name will direct you to the *Channel Detail Page*.
- If you are subscribed to a channel a *Unsubscribe* button will show.
The **Subscribe to Channels** button <img src="assets/icon-add.png?raw=true" alt="add icon" width="20px" style="margin:0 5px;"> opens a text field to subscribe to a channel. You have a few options:
- Enter the YouTube channel ID, a 25 character alphanumeric string. For example *UCBa659QWEk1AI4Tg--mrJ2A*
- Enter the URL to the channel page on YouTube. For example *https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A*
- Enter the channel name for example: *https://www.youtube.com/c/TomScottGo*.
- Enter the video URL for any video and let Tube Archivist extract the channel ID for you. For example *https://www.youtube.com/watch?v=2tdiKTSdE9Y*
- Add one per line.
The search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;"> opens a text box to search for indexed channel names. Possible matches will show as you type.
## Channel Detail
Each channel will get a dedicated channel detail page accessible at `/channel/<channel-id>/` of your Tube Archivist. This page shows all the videos you have downloaded from this channel plus additional metadata.
- If you are subscribed to the channel, an *Unsubscribe* button will show.
- You can *Show* the channel description, that matches with the *About* tab on YouTube.
- The **Mark as Watched** button will mark all videos of this channel as watched.
- The button **Delete Channel** will delete the channel plus all videos of this channel, both media files and metadata.

35
docs/Downloads.md Normal file
View File

@@ -0,0 +1,35 @@
# Downloads Page
Accessible at `/downloads/` of your Tube Archivist, this page handles all the download functionality.
## Rescan Subscriptions
The **Rescan Subscriptions** icon <img src="assets/icon-rescan.png?raw=true" alt="rescan icon" width="20px" style="margin:0 5px;"> will start a background task to look for new videos from the channels you are subscribed to. You can define the channel page size on the [settings page](Settings#subscriptions). With the default channel page size, expect this process to take around 2-3 seconds for each channel you are subscribed to. A status message will show the progress.
Then for every video found, **Tube Archivist** will skip the video if it has already been downloaded or if you added it to the *ignored* list before. All the other videos will get added to the download queue. Expect this to take around 1 second for each video as **Tube Archivist** needs to grab some additional metadata. New videos will get added at the bottom of the download queue.
## Download Queue
The **Start Download** icon <img src="assets/icon-download.png?raw=true" alt="download icon" width="20px" style="margin:0 5px;"> will start the download process starting from the top of the queue. Take a look at the relevant settings on the [Settings Page](Settings#downloads). Once the process started, a progress message will show with additional details and controls:
- The stop icon <img src="assets/icon-stop.png?raw=true" alt="stop icon" width="20px" style="margin:0 5px;"> will gracefully stop the download process, once the current video has been finished successfully.
- The cancel icon <img src="assets/icon-close-red.png?raw=true" alt="close icon" width="20px" style="margin:0 5px;"> is equivalent to killing the process and will stop the download immediately. Any leftover files will get deleted, the canceled video will still be available in the download queue.
## Add to Download Queue
The **Add to Download Queue** icon <img src="assets/icon-add.png?raw=true" alt="add icon" width="20px" style="margin:0 5px;"> opens a text field to manually add videos to the download queue. You have a few options:
- Add a link to a YouTube video. For example *https://www.youtube.com/watch?v=2tdiKTSdE9Y*.
- Add a YouTube video ID. For example *2tdiKTSdE9Y*.
- Add a link to a YouTube video by providing the shortened URL, for example *https://youtu.be/2tdiKTSdE9Y*.
- Add a Channel ID or Channel URL to add every available video to the download queue. This will ignore the channel page size as described before and is meant for an initial download of the whole channel. You can still ignore selected videos before starting the download.
- Add a channel name like for example *https://www.youtube.com/c/TomScottGo*.
- Add a playlist ID or URL to add every available video in the list to the download queue, for example *https://www.youtube.com/playlist?list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha* or *PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha*. Note that when you add a link to a video in a playlist, Tube Archivist assumes you want to download only the specific video and not the whole playlist, for example *https://www.youtube.com/watch?v=CINVwWHlzTY&list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha* will only add one video *CINVwWHlzTY* to the queue.
- Add one link per line.
## The Download Queue
Below the three buttons you find the download queue. New items will get added at the bottom of the queue, the next video to download once you click on **Start Download** will be the first in the list.
Every video in the download queue has two buttons:
- **Ignore**: This will remove that video from the download queue and this video will not get added again, even when you **Rescan Subscriptions**.
- **Download now**: This will give priority to this video. If the download process is already running, the prioritized video will get downloaded as soon as the current video is finished. If there is no download process running, this will start downloading this single video and stop after that.
You can flip the view by activating **Show Only Ignored Videos**. This will show all videos you have previously *ignored*.
Every video in the ignored list has two buttons:
- **Forget**: This will delete the item form the ignored list.
- **Add to Queue**: This will add the ignored video back to the download queue.

27
docs/Home.md Normal file
View File

@@ -0,0 +1,27 @@
# Tube Archivist Wiki
Welcome to the official Tube Archivist Wiki. This is an up-to-date documentation of user functionality.
Table of contents:
* [Main](Main): Tube Archivist landing page
* [Channels](Channels): Browse your channels, handle subscriptions
* [Downloads](Downloads): Scanning subscriptions, handle download queue
* [Settings](Settings): All the configuration options
* [Users](Users): User management admin interface
## Getting Started
1. [Subscribe](Channels#channels-overview) to some of your favourite YouTube channels.
2. [Scan](Downloads#rescan-subscriptions) subscriptions to add the latest videos to the download queue.
3. [Add](Downloads#add-to-download-queue) additional videos, channels or playlist - ignore the ones you don't want to download.
4. [Download](Downloads#download-queue) and let **Tube Archivist** do it's thing.
5. Sit back and enjoy your archived and indexed collection!
## General Navigation
* Clicking on the channel name or the channel icon brings you to the dedicated channel page to show videos from that channel.
* Clicking on a video title brings you to the dedicated video page and shows additional details.
* Clicking on a video thumbnail opens the video player and starts streaming the selected video.
* Hover over the playing video to show additional control options.
An empty checkbox icon <img src="assets/icon-unseen.png?raw=true" alt="unseen icon" width="20px" style="margin:0 5px;"> will show for videos you haven't marked as watched. Click on it and the icon will change to a filled checkbox <img src="assets/icon-seen.png?raw=true" alt="seen icon" width="20px" style="margin:0 5px;"> indicating it as watched - click again to revert.
When available the <img src="assets/icon-gridview.png?raw=true" alt="gridview icon" width="20px" style="margin:0 5px;"> gridview icon will display the list in a grid, the <img src="assets/icon-listview.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> listview icon will arrange the items in a list.

10
docs/Main.md Normal file
View File

@@ -0,0 +1,10 @@
# Tube Archivist Home Page Functionality
This is the landing page, when you first open **Tube Archivist**. You have a few options to sort and filter that view:
- With the **Sort Order** you can select how the "Recent Videos" are sorted:
- **Date Published**: Sorts the list by date when the video was published on YouTube, newest on top.
- **Date Downloaded**: Sorts the list based on when you have downloaded the video to your archive, newest on top.
- With **Hide Watched** you can filter out videos you have already marked as watched to only show unwatched videos.
- You can use those two options together to for example filter the list to *Hide Watched* videos **and** sort by date downloaded.
Additionally the search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;"> opens a text field to search your collection.

73
docs/Settings.md Normal file
View File

@@ -0,0 +1,73 @@
# Settings Page
Accessible at `/settings/` of your **Tube Archivist**, this page holds all the configurations and additional functionality related to the database.
Click on **Update Settings** at the bottom of the form to apply your configurations.
## Color scheme
Switch between the easy on the eyes dark theme and the burning bright theme.
## Archive View
- **Page Size**: Defines how many results get displayed on a given page. Same value goes for all archive views.
## Subscriptions
Settings related to the channel management.
- **Channel Page Size**: Defines how many pages will get analyzed by **Tube Archivist** each time you click on *Rescan Subscriptions*. The default page size used by yt-dlp is **50**, that's also the recommended value to set here. Any value higher will slow down the rescan process, for example if you set the value to 51, that means yt-dlp will have to go through 2 pages of results instead of 1 and by that doubling the time that process takes.
## Downloads
Settings related to the download process.
- **Download Limit**: Stop the download process after downloading the set quantity of videos.
- **Download Speed Limit**: Set your download speed limit in KB/s. This will pass the option `--limit-rate` to yt-dlp.
- **Throttled Rate Limit**: Restart download if the download speed drops below this value in KB/s. This will pass the option `--throttled-rate` to yt-dlp. Using this option might have a negative effect if you have an unstable or slow internet connection.
- **Sleep Interval**: Time in seconds to sleep between requests to YouTube. It's a good idea to set this to **3** seconds. Might be necessary to avoid throttling.
## Download Format
Additional settings passed to yt-dlp.
- **Format**: This controls which streams get downloaded and is equivalent to passing `--format` to yt-dlp. Use one of the recommended one or look at the documentation of [yt-dlp](https://github.com/yt-dlp/yt-dlp#format-selection). Please note: The option `--merge-output-format mp4` is automatically passed to yt-dlp to guarantee browser compatibility.
- **Embed Metadata**: This saves the available tags directly into the media file by passing `--embed-metadata` to yt-dlp.
- **Embed Thumbnail**: This will save the thumbnail into the media file by passing `--embed-thumbnail` to yt-dlp.
# Actions
Additional database functionality.
## Manual Media Files Import
So far this depends on the video you are trying to import to be still available on YouTube to get the metadata. Add the files you like to import to the */cache/import* folder. Then start the process from the settings page *Manual Media Files Import*. Make sure to follow one of the two methods below.
### Method 1:
Add a matching *.json* file with the media file. Both files need to have the same base name, for example:
- For the media file: \<base-name>.mp4
- For the JSON file: \<base-name>.info.json
- Alternate JSON file: \<base-name>.json
**Tube Archivist** then looks for the 'id' key within the JSON file to identify the video.
### Method 2:
Detect the YouTube ID from filename, this accepts the default yt-dlp naming convention for file names like:
- \<base-name>[\<youtube-id>].mp4
- The YouTube ID in square brackets at the end of the filename is the crucial part.
### Some notes:
- This will **consume** the files you put into the import folder: Files will get converted to mp4 if needed (this might take a long time...) and moved to the archive, *.json* files will get deleted upon completion to avoid having duplicates on the next run.
- Maybe start with a subset of your files to import to make sure everything goes well...
- Follow the logs to monitor progress and errors: `docker-compose logs -f tubearchivist`.
## Embed thumbnails into media file
This will write or overwrite all thumbnails in the media file using the downloaded thumbnail. This is only necessary if you didn't download the files with the option *Embed Thumbnail* enabled or want to make sure all media files get the newest thumbnail. Follow the docker-compose logs to monitor progress.
## Backup Database
This will backup your metadata into a zip file. The file will get stored at *cache/backup* and will contain the necessary files to restore the Elasticsearch index formatted **nd-json** files plus a complete export of the index in a set of conventional **json** files.
BE AWARE: This will **not** backup any media files, just the metadata from the Elasticsearch.
## Restore From Backup
The restore functionality will expect the same zip file in *cache/backup* as created from the **Backup database** function. This will recreate the index from the snapshot. If there are multiple backup files in the folder, the newest one will take priority.
BE AWARE: This will **replace** your current index with the one from the backup file. This won't restore any media files.
## Rescan Filesystem
This function will go through all your media files and looks at the whole index to try to find any issues:
- Should the filename not match with the indexed media url, this will rename the video files correctly and update the index with the new link.
- When you delete media files from the filesystem outside of the Tube Archivist interface, this will delete leftover metadata from the index.
- When you have media files that are not indexed yet, this will grab the metadata from YouTube like it was a newly downloaded video. This can be useful when restoring from an older backup file with missing metadata but already downloaded mediafiles. NOTE: This only works if the media files are named in the same convention as Tube Archivist does, particularly the YouTube ID needs to be at the same index in the filename, alternatively see above for *Manual Media Files Import*.
BE AWARE: There is no undo.

20
docs/Users.md Normal file
View File

@@ -0,0 +1,20 @@
# User Management
For now, **Tube Archivist** is a single user application. You can create multiple users with different names and passwords, they will share the same videos and permissions but some interface configurations are on a per user basis. *More is on the roadmap*.
## Superuser
The first user gets created with the environment variables **TA_USERNAME** and **TA_PASSWORD** from your docker-compose file. That first user will automatically have *superuser* privileges.
## Admin Interface
When logged in from your *superuser* account, you are able to access the admin interface from the settings page or at `/admin/`. This interface holds all functionality for user management.
## Create additional users
From the admin interface when you click on *Accounts* you will get a list of all users. From there you can create additional users by clicking on *Add Account*, provide a name and confirm password and click on *Save* to create the user.
## Changing users
You can delete or change permissions and password of a user by clicking on the username from the *Accounts* list page and follow the interface from there. Changing the password of the *superuser* here will overwrite the password originally set with the environment variables.
## Reset
Delete all user configurations by deleting the file `cache/db.sqlite3` and restart the container. This will create the superuser again from the environment variables.
NOTE: Future improvements here will most likely require such a reset.

BIN
docs/assets/icon-add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
docs/assets/icon-rescan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
docs/assets/icon-search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
docs/assets/icon-seen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
docs/assets/icon-stop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
docs/assets/icon-unseen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

25
run.sh
View File

@@ -1,19 +1,38 @@
#!/bin/bash
# startup script inside the container for tubearchivist
# check environment
if [[ -z "$ELASTIC_USER" ]]; then
export ELASTIC_USER=elastic
fi
ENV_VARS=("TA_USERNAME" "TA_PASSWORD" "ELASTIC_PASSWORD" "ELASTIC_USER")
for each in "${ENV_VARS[@]}"; do
if ! [[ -v $each ]]; then
echo "missing environment variable $each"
exit 1
fi
done
# wait for elasticsearch
counter=0
until curl "$ES_URL" -fs; do
until curl -u "$ELASTIC_USER":"$ELASTIC_PASSWORD" "$ES_URL" -fs; do
echo "waiting for elastic search to start"
counter=$((counter+1))
if [[ $counter -eq 12 ]]; then
# fail after 1 min
# fail after 2 min
echo "failed to connect to elastic search, exiting..."
exit 1
fi
sleep 5
sleep 10
done
# start python application
python manage.py makemigrations
python manage.py migrate
export DJANGO_SUPERUSER_PASSWORD=$TA_PASSWORD && \
python manage.py createsuperuser --noinput --name "$TA_USERNAME"
python manage.py collectstatic --noinput -c
nginx &
celery -A home.tasks worker --loglevel=INFO &

View File

@@ -10,9 +10,12 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
from os import environ
import hashlib
from os import environ, path
from pathlib import Path
from home.src.config import AppConfig
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -20,8 +23,8 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "Fvid^aUL6LohRZz*kZFvq85B&JW&kB9o*#jdzWsdWE8*XkCLR8"
PW_HASH = hashlib.sha256(environ.get("TA_PASSWORD").encode())
SECRET_KEY = PW_HASH.hexdigest()
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = bool(environ.get("DJANGO_DEBUG"))
@@ -78,10 +81,12 @@ WSGI_APPLICATION = "config.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
CACHE_DIR = AppConfig().config["application"]["cache_dir"]
DB_PATH = path.join(CACHE_DIR, "db.sqlite3")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"NAME": DB_PATH,
}
}
@@ -104,18 +109,16 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
AUTH_USER_MODEL = "home.Account"
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
@@ -123,22 +126,14 @@ USE_TZ = True
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"
# STATICFILES_DIRS = [
# str(BASE_DIR.joinpath('static')),
# '/cache/'
# ]
# STATIC_URL = '/static/'
STATICFILES_DIRS = (str(BASE_DIR.joinpath("static")),)
# MEDIA_ROOT = str(BASE_DIR.joinpath('media'))
# MEDIA_URL = '/media/'
STATIC_ROOT = str(BASE_DIR.joinpath("staticfiles"))
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/"

View File

@@ -1,42 +1,5 @@
""" handle startup """
import os
from home.src.config import AppConfig
from home.src.helper import set_message
from home.src.index_management import index_check
""" handle celery startup """
from .tasks import app as celery_app
def sync_redis_state():
"""make sure redis gets the config.json values"""
print("sync redis")
config_handler = AppConfig()
config_handler.load_new_defaults()
config = config_handler.config
sort_order = config["archive"]["sort"]
set_message("sort_order", sort_order, expire=False)
hide_watched = bool(int(config["archive"]["hide_watched"]))
set_message("hide_watched", hide_watched, expire=False)
show_subed_only = bool(int(config["archive"]["show_subed_only"]))
set_message("show_subed_only", show_subed_only, expire=False)
def make_folders():
"""make needed cache folders here so docker doesn't mess it up"""
folders = ["download", "channels", "videos", "import", "backup"]
config = AppConfig().config
cache_dir = config["application"]["cache_dir"]
for folder in folders:
folder_path = os.path.join(cache_dir, folder)
try:
os.makedirs(folder_path)
except FileExistsError:
continue
__all__ = ("celery_app",)
make_folders()
sync_redis_state()
index_check()

View File

@@ -1,3 +1,36 @@
from django.contrib import admin # noqa: F401 - Unused import
"""custom admin classes"""
# Register your models here.
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import Account
class HomeAdmin(BaseUserAdmin):
"""register in admin page"""
list_display = ("name", "is_staff", "is_superuser")
list_filter = ("is_superuser",)
fieldsets = (
(None, {"fields": ("is_staff", "is_superuser", "password")}),
("Personal info", {"fields": ("name",)}),
("Groups", {"fields": ("groups",)}),
("Permissions", {"fields": ("user_permissions",)}),
)
add_fieldsets = (
(
None,
{"fields": ("is_staff", "is_superuser", "password1", "password2")},
),
("Personal info", {"fields": ("name",)}),
("Groups", {"fields": ("groups",)}),
("Permissions", {"fields": ("user_permissions",)}),
)
search_fields = ("name",)
ordering = ("name",)
filter_horizontal = ()
admin.site.register(Account, HomeAdmin)

View File

@@ -1,6 +1,52 @@
"""handle custom startup functions"""
import os
from django.apps import AppConfig
from home.src.config import AppConfig as ArchivistConfig
from home.src.helper import RedisArchivist
from home.src.index_management import index_check
from home.src.thumbnails import validate_thumbnails
def sync_redis_state():
"""make sure redis gets new config.json values"""
print("sync redis")
config_handler = ArchivistConfig()
config_handler.load_new_defaults()
def make_folders():
"""make needed cache folders here so docker doesn't mess it up"""
folders = ["download", "channels", "videos", "import", "backup"]
config = ArchivistConfig().config
cache_dir = config["application"]["cache_dir"]
for folder in folders:
folder_path = os.path.join(cache_dir, folder)
try:
os.makedirs(folder_path)
except FileExistsError:
continue
def release_lock():
"""make sure there are no leftover locks set in redis on container start"""
all_locks = ["manual_import", "downloading", "dl_queue", "dl_queue_id"]
for lock in all_locks:
response = RedisArchivist().del_message(lock)
if response:
print("deleted leftover key from redis: " + lock)
class HomeConfig(AppConfig):
"""call startup funcs"""
default_auto_field = "django.db.models.BigAutoField"
name = "home"
def ready(self):
release_lock()
index_check()
sync_redis_state()
make_folders()
validate_thumbnails()

View File

@@ -1,23 +1,30 @@
{
"archive": {
"sort": "published",
"hide_watched": false,
"show_subed_only": false,
"sort_by": "published",
"sort_order": "desc",
"page_size": 12
},
"default_view": {
"home": "grid",
"channel": "list",
"downloads": "list"
},
"subscriptions": {
"auto_search": false,
"auto_download": false,
"channel_size": 50
},
"downloads": {
"limit_count": 5,
"limit_count": false,
"limit_speed": false,
"sleep_interval": 3,
"format": false,
"add_metadata": false
"add_metadata": false,
"add_thumbnail": false,
"throttledratelimit": false
},
"application": {
"app_root": "/app",
"cache_dir": "/cache",
"videos": "/youtube",
"file_template": "%(id)s_%(title)s.mp4",

114
tubearchivist/home/forms.py Normal file
View File

@@ -0,0 +1,114 @@
"""functionality:
- hold all form classes used in the views
"""
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.forms.widgets import PasswordInput, TextInput
class CustomAuthForm(AuthenticationForm):
"""better styled login form"""
username = forms.CharField(
widget=TextInput(attrs={"placeholder": "Username"}), label=False
)
password = forms.CharField(
widget=PasswordInput(attrs={"placeholder": "Password"}), label=False
)
class UserSettingsForm(forms.Form):
"""user configurations values"""
CHOICES = [
("", "-- change color scheme --"),
("dark", "Dark"),
("light", "Light"),
]
colors = forms.ChoiceField(
widget=forms.Select, choices=CHOICES, required=False
)
page_size = forms.IntegerField(required=False)
class ApplicationSettingsForm(forms.Form):
"""handle all application settings"""
METADATA_CHOICES = [
("", "-- change metadata embed --"),
("0", "don't embed metadata"),
("1", "embed metadata"),
]
THUMBNAIL_CHOICES = [
("", "-- change thumbnail embed --"),
("0", "don't embed thumbnail"),
("1", "embed thumbnail"),
]
subscriptions_channel_size = forms.IntegerField(required=False)
downloads_limit_count = forms.IntegerField(required=False)
downloads_limit_speed = forms.IntegerField(required=False)
downloads_throttledratelimit = forms.IntegerField(required=False)
downloads_sleep_interval = forms.IntegerField(required=False)
downloads_format = forms.CharField(required=False)
downloads_add_metadata = forms.ChoiceField(
widget=forms.Select, choices=METADATA_CHOICES, required=False
)
downloads_add_thumbnail = forms.ChoiceField(
widget=forms.Select, choices=THUMBNAIL_CHOICES, required=False
)
class VideoSearchForm(forms.Form):
"""search videos form"""
searchInput = forms.CharField(
label="Search your videos",
widget=forms.TextInput(attrs={"autocomplete": "off"}),
)
class ChannelSearchForm(forms.Form):
"""search for channels"""
searchInput = forms.CharField(
label="",
widget=forms.TextInput(
attrs={
"oninput": "searchChannels(this.value)",
"autocomplete": "off",
"list": "resultBox",
}
),
)
class AddToQueueForm(forms.Form):
"""text area form to add to downloads"""
vid_url = forms.CharField(
label=False,
widget=forms.Textarea(
attrs={
"rows": 4,
"placeholder": "Enter Video Urls or IDs here...",
}
),
)
class SubscribeToChannelForm(forms.Form):
"""text area form to subscribe to multiple channels"""
subscribe = forms.CharField(
label=False,
widget=forms.Textarea(
attrs={
"rows": 3,
"placeholder": "Input channel ID, URL or Video of a channel",
}
),
)

View File

@@ -1,3 +1,53 @@
from django.db import models # noqa: F401 - Unused import
"""custom models"""
from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,
PermissionsMixin,
)
from django.db import models
# Create your models here.
class AccountManager(BaseUserManager):
"""manage user creation methods"""
use_in_migrations = True
def _create_user(self, name, password, **extra_fields):
"""create regular user private"""
values = [name, password]
field_value_map = dict(zip(self.model.REQUIRED_FIELDS, values))
for field_name, value in field_value_map.items():
if not value:
raise ValueError(f"The {field_name} value must be set")
user = self.model(name=name, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, name, password):
"""create regular user public"""
return self._create_user(name, password)
def create_superuser(self, name, password, **extra_fields):
"""create super user"""
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True.")
return self._create_user(name, password, **extra_fields)
class Account(AbstractBaseUser, PermissionsMixin):
"""handle account creation"""
name = models.CharField(max_length=150, unique=True)
is_staff = models.BooleanField(default=False)
objects = AccountManager()
USERNAME_FIELD = "name"
REQUIRED_FIELDS = ["password"]

View File

@@ -8,14 +8,16 @@ Functionality:
import json
import os
from home.src.helper import get_message, set_message
from home.src.helper import RedisArchivist
class AppConfig:
"""handle user settings and application variables"""
def __init__(self):
def __init__(self, user_id=False):
self.user_id = user_id
self.config = self.get_config()
self.colors = self.get_colors()
def get_config(self):
"""get config from default file or redis if changed"""
@@ -23,6 +25,12 @@ class AppConfig:
if not config:
config = self.get_config_file()
if self.user_id:
key = f"{self.user_id}:page_size"
page_size = RedisArchivist().get_message(key)["status"]
if page_size:
config["archive"]["page_size"] = page_size
config["application"].update(self.get_config_env())
return config
@@ -39,11 +47,27 @@ class AppConfig:
@staticmethod
def get_config_env():
"""read environment application variables"""
host_uid_env = os.environ.get("HOST_UID")
if host_uid_env:
host_uid = int(host_uid_env)
else:
host_uid = False
host_gid_env = os.environ.get("HOST_GID")
if host_gid_env:
host_gid = int(host_gid_env)
else:
host_gid = False
es_pass = os.environ.get("ELASTIC_PASSWORD")
es_user = os.environ.get("ELASTIC_USER", default="elastic")
application = {
"REDIS_HOST": os.environ.get("REDIS_HOST"),
"es_url": os.environ.get("ES_URL"),
"HOST_UID": int(os.environ.get("HOST_UID")),
"HOST_GID": int(os.environ.get("HOST_GID")),
"es_auth": (es_user, es_pass),
"HOST_UID": host_uid,
"HOST_GID": host_gid,
}
return application
@@ -51,7 +75,7 @@ class AppConfig:
@staticmethod
def get_config_redis():
"""read config json set from redis to overwrite defaults"""
config = get_message("config")
config = RedisArchivist().get_message("config")
if not list(config.values())[0]:
return False
@@ -70,10 +94,35 @@ class AppConfig:
elif to_write.isdigit():
to_write = int(to_write)
config_dict, config_value = key.split(".")
config_dict, config_value = key.split("_", maxsplit=1)
config[config_dict][config_value] = to_write
set_message("config", config, expire=False)
RedisArchivist().set_message("config", config, expire=False)
@staticmethod
def set_user_config(form_post, user_id):
"""set values in redis for user settings"""
for key, value in form_post.items():
to_write = value[0]
if len(to_write):
if to_write.isdigit():
to_write = int(to_write)
message = {"status": to_write}
redis_key = f"{user_id}:{key}"
RedisArchivist().set_message(redis_key, message, expire=False)
def get_colors(self):
"""overwrite config if user has set custom values"""
colors = False
if self.user_id:
col_dict = RedisArchivist().get_message(f"{self.user_id}:colors")
colors = col_dict["status"]
if not colors:
colors = self.config["application"]["colors"]
self.config["application"]["colors"] = colors
return colors
def load_new_defaults(self):
"""check config.json for missing defaults"""
@@ -100,4 +149,4 @@ class AppConfig:
needs_update = True
if needs_update:
set_message("config", redis_config, expire=False)
RedisArchivist().set_message("config", redis_config, expire=False)

View File

@@ -14,7 +14,13 @@ from time import sleep
import requests
import yt_dlp as youtube_dl
from home.src.config import AppConfig
from home.src.helper import DurationConverter, clean_string, set_message
from home.src.helper import (
DurationConverter,
RedisArchivist,
RedisQueue,
clean_string,
ignore_filelist,
)
from home.src.index import YoutubeChannel, index_new_video
@@ -23,8 +29,13 @@ class PendingList:
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
VIDEOS = CONFIG["application"]["videos"]
def __init__(self):
self.all_channel_ids = False
self.all_downloaded = False
@staticmethod
def parse_url_list(youtube_ids):
"""extract youtube ids from list"""
@@ -37,19 +48,21 @@ class PendingList:
"title": "Adding to download queue.",
"message": "Extracting lists",
}
set_message("progress:download", mess_dict)
RedisArchivist().set_message("progress:download", mess_dict)
# extract
url = entry["url"]
url_type = entry["type"]
if url_type == "video":
missing_videos.append(url)
elif url_type == "channel":
youtube_ids = ChannelSubscription().get_last_youtube_videos(
video_results = ChannelSubscription().get_last_youtube_videos(
url, limit=False
)
youtube_ids = [i[0] for i in video_results]
missing_videos = missing_videos + youtube_ids
elif url_type == "playlist":
youtube_ids = playlist_extractor(url)
video_results = playlist_extractor(url)
youtube_ids = [i[0] for i in video_results]
missing_videos = missing_videos + youtube_ids
return missing_videos
@@ -59,49 +72,62 @@ class PendingList:
# check if channel is indexed
channel_handler = ChannelSubscription()
all_indexed = channel_handler.get_channels(subscribed_only=False)
all_channel_ids = [i["channel_id"] for i in all_indexed]
self.all_channel_ids = [i["channel_id"] for i in all_indexed]
# check if already there
all_downloaded = self.get_all_downloaded()
# loop
bulk_list = []
for video in missing_videos:
if isinstance(video, str):
youtube_id = video
elif isinstance(video, tuple):
youtube_id = video[0]
if youtube_id in all_downloaded:
# skip already downloaded
continue
video = self.get_youtube_details(youtube_id)
# skip on download error
if not video:
continue
self.all_downloaded = self.get_all_downloaded()
if video["channel_id"] in all_channel_ids:
video["channel_indexed"] = True
else:
video["channel_indexed"] = False
video["status"] = "pending"
action = {"create": {"_id": youtube_id, "_index": "ta_download"}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(video))
# notify
mess_dict = {
"status": "pending",
"level": "info",
"title": "Adding to download queue.",
"message": "Processing IDs...",
}
set_message("progress:download", mess_dict)
bulk_list, all_videos_added = self.build_bulk(missing_videos)
# add last newline
bulk_list.append("\n")
query_str = "\n".join(bulk_list)
headers = {"Content-type": "application/x-ndjson"}
url = self.ES_URL + "/_bulk"
request = requests.post(url, data=query_str, headers=headers)
request = requests.post(
url, data=query_str, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request)
return all_videos_added
def build_bulk(self, missing_videos):
"""build the bulk lists"""
bulk_list = []
all_videos_added = []
counter = 1
for youtube_id in missing_videos:
# check if already downloaded
if youtube_id in self.all_downloaded:
continue
video = self.get_youtube_details(youtube_id)
# skip on download error
if not video:
continue
channel_indexed = video["channel_id"] in self.all_channel_ids
video["channel_indexed"] = channel_indexed
thumb_url = video["vid_thumb_url"]
video["status"] = "pending"
action = {"create": {"_id": youtube_id, "_index": "ta_download"}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(video))
all_videos_added.append((youtube_id, thumb_url))
# notify
progress = f"{counter}/{len(missing_videos)}"
mess_dict = {
"status": "pending",
"level": "info",
"title": "Adding new videos to download queue.",
"message": "Progress: " + progress,
}
RedisArchivist().set_message("progress:download", mess_dict)
if counter % 25 == 0:
print("adding to queue progress: " + progress)
counter = counter + 1
return bulk_list, all_videos_added
@staticmethod
def get_youtube_details(youtube_id):
"""get details from youtubedl for single pending video"""
@@ -115,9 +141,14 @@ class PendingList:
except youtube_dl.utils.DownloadError:
print("failed to extract info for: " + youtube_id)
return False
# stop if video is streaming live now
if vid["is_live"]:
return False
# parse response
seconds = vid["duration"]
duration_str = DurationConverter.get_str(seconds)
if duration_str == "NA":
print(f"skip extracting duration for: {youtube_id}")
upload_date = vid["upload_date"]
upload_dt = datetime.strptime(upload_date, "%Y%m%d")
published = upload_dt.strftime("%Y-%m-%d")
@@ -139,7 +170,7 @@ class PendingList:
headers = {"Content-type": "application/json"}
# get PIT ID
url = self.ES_URL + "/ta_download/_pit?keep_alive=1m"
response = requests.post(url)
response = requests.post(url, auth=self.ES_AUTH)
json_data = json.loads(response.text)
pit_id = json_data["id"]
# query
@@ -147,24 +178,25 @@ class PendingList:
"size": 50,
"query": {"match_all": {}},
"pit": {"id": pit_id, "keep_alive": "1m"},
"sort": [{"timestamp": {"order": "desc"}}],
"sort": [{"timestamp": {"order": "asc"}}],
}
query_str = json.dumps(data)
url = self.ES_URL + "/_search"
all_pending = []
all_ignore = []
while True:
response = requests.get(url, data=query_str, headers=headers)
response = requests.get(
url, data=query_str, headers=headers, auth=self.ES_AUTH
)
json_data = json.loads(response.text)
all_hits = json_data["hits"]["hits"]
if all_hits:
for hit in all_hits:
youtube_id = hit["_source"]["youtube_id"]
status = hit["_source"]["status"]
if status == "pending":
all_pending.append(hit["_source"])
elif status == "ignore":
all_ignore.append(youtube_id)
all_ignore.append(hit["_source"])
search_after = hit["sort"]
# update search_after with last hit data
data["search_after"] = search_after
@@ -173,7 +205,12 @@ class PendingList:
break
# clean up PIT
query_str = json.dumps({"id": pit_id})
requests.delete(self.ES_URL + "/_pit", data=query_str, headers=headers)
requests.delete(
self.ES_URL + "/_pit",
data=query_str,
headers=headers,
auth=self.ES_AUTH,
)
return all_pending, all_ignore
def get_all_indexed(self):
@@ -181,7 +218,7 @@ class PendingList:
headers = {"Content-type": "application/json"}
# get PIT ID
url = self.ES_URL + "/ta_video/_pit?keep_alive=1m"
response = requests.post(url)
response = requests.post(url, auth=self.ES_AUTH)
json_data = json.loads(response.text)
pit_id = json_data["id"]
# query
@@ -195,7 +232,9 @@ class PendingList:
url = self.ES_URL + "/_search"
all_indexed = []
while True:
response = requests.get(url, data=query_str, headers=headers)
response = requests.get(
url, data=query_str, headers=headers, auth=self.ES_AUTH
)
json_data = json.loads(response.text)
all_hits = json_data["hits"]["hits"]
if all_hits:
@@ -209,16 +248,23 @@ class PendingList:
break
# clean up PIT
query_str = json.dumps({"id": pit_id})
requests.delete(self.ES_URL + "/_pit", data=query_str, headers=headers)
requests.delete(
self.ES_URL + "/_pit",
data=query_str,
headers=headers,
auth=self.ES_AUTH,
)
return all_indexed
def get_all_downloaded(self):
"""get a list of all videos in archive"""
all_channel_folders = os.listdir(self.VIDEOS)
channel_folders = os.listdir(self.VIDEOS)
all_channel_folders = ignore_filelist(channel_folders)
all_downloaded = []
for channel_folder in all_channel_folders:
channel_path = os.path.join(self.VIDEOS, channel_folder)
all_videos = os.listdir(channel_path)
videos = os.listdir(channel_path)
all_videos = ignore_filelist(videos)
youtube_vids = [i[9:20] for i in all_videos]
for youtube_id in youtube_vids:
all_downloaded.append(youtube_id)
@@ -227,7 +273,7 @@ class PendingList:
def delete_from_pending(self, youtube_id):
"""delete the youtube_id from ta_download"""
url = f"{self.ES_URL}/ta_download/_doc/{youtube_id}"
response = requests.delete(url)
response = requests.delete(url, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
@@ -249,14 +295,16 @@ class PendingList:
headers = {"Content-type": "application/x-ndjson"}
url = self.ES_URL + "/_bulk"
request = requests.post(url, data=query_str, headers=headers)
request = requests.post(
url, data=query_str, headers=headers, auth=self.ES_AUTH
)
mess_dict = {
"status": "ignore",
"level": "info",
"title": "Added to ignore list",
"message": "",
}
set_message("progress:download", mess_dict)
RedisArchivist().set_message("progress:download", mess_dict)
if not request.ok:
print(request)
@@ -267,6 +315,7 @@ class ChannelSubscription:
def __init__(self):
config = AppConfig().config
self.es_url = config["application"]["es_url"]
self.es_auth = config["application"]["es_auth"]
self.channel_size = config["subscriptions"]["channel_size"]
def get_channels(self, subscribed_only=True):
@@ -274,7 +323,7 @@ class ChannelSubscription:
headers = {"Content-type": "application/json"}
# get PIT ID
url = self.es_url + "/ta_channel/_pit?keep_alive=1m"
response = requests.post(url)
response = requests.post(url, auth=self.es_auth)
json_data = json.loads(response.text)
pit_id = json_data["id"]
# query
@@ -296,7 +345,9 @@ class ChannelSubscription:
url = self.es_url + "/_search"
all_channels = []
while True:
response = requests.get(url, data=query_str, headers=headers)
response = requests.get(
url, data=query_str, headers=headers, auth=self.es_auth
)
json_data = json.loads(response.text)
all_hits = json_data["hits"]["hits"]
if all_hits:
@@ -311,7 +362,12 @@ class ChannelSubscription:
break
# clean up PIT
query_str = json.dumps({"id": pit_id})
requests.delete(self.es_url + "/_pit", data=query_str, headers=headers)
requests.delete(
self.es_url + "/_pit",
data=query_str,
headers=headers,
auth=self.es_auth,
)
return all_channels
def get_last_youtube_videos(self, channel_id, limit=True):
@@ -334,20 +390,20 @@ class ChannelSubscription:
all_channels = self.get_channels()
pending_handler = PendingList()
all_pending, all_ignore = pending_handler.get_all_pending()
all_pending_ids = [i["youtube_id"] for i in all_pending]
all_ids = [i["youtube_id"] for i in all_ignore + all_pending]
all_downloaded = pending_handler.get_all_downloaded()
to_ignore = all_pending_ids + all_ignore + all_downloaded
to_ignore = all_ids + all_downloaded
missing_videos = []
counter = 1
for channel in all_channels:
channel_id = channel["channel_id"]
last_videos = self.get_last_youtube_videos(channel_id)
set_message(
RedisArchivist().set_message(
"progress:download",
{
"status": "rescan",
"level": "info",
"title": "Rescanning: Looking for new videos.",
"title": "Scanning channels: Looking for new videos.",
"message": f"Progress: {counter}/{len(all_channels)}",
},
)
@@ -377,11 +433,15 @@ class ChannelSubscription:
url = self.es_url + "/ta_channel/_update/" + channel_id
payload = json.dumps({"doc": channel_dict})
# update channel
request = requests.post(url, data=payload, headers=headers)
request = requests.post(
url, data=payload, headers=headers, auth=self.es_auth
)
if not request.ok:
print(request.text)
# sync to videos
channel_handler.sync_to_videos()
if channel_handler.source == "scraped":
channel_handler.get_channel_art()
def playlist_extractor(playlist_id):
@@ -400,19 +460,28 @@ def playlist_extractor(playlist_id):
class VideoDownloader:
"""handle the video download functionality"""
"""
handle the video download functionality
if not initiated with list, take from queue
"""
def __init__(self, youtube_id_list):
def __init__(self, youtube_id_list=False):
self.youtube_id_list = youtube_id_list
self.config = AppConfig().config
def download_list(self):
"""download the list of youtube_ids"""
limit_count = self.config["downloads"]["limit_count"]
if limit_count:
self.youtube_id_list = self.youtube_id_list[:limit_count]
def run_queue(self):
"""setup download queue in redis loop until no more items"""
queue = RedisQueue("dl_queue")
limit_queue = self.config["downloads"]["limit_count"]
if limit_queue:
queue.trim(limit_queue - 1)
while True:
youtube_id = queue.get_next()
if not youtube_id:
break
for youtube_id in self.youtube_id_list:
try:
self.dl_single_vid(youtube_id)
except youtube_dl.utils.DownloadError:
@@ -421,8 +490,26 @@ class VideoDownloader:
vid_dict = index_new_video(youtube_id)
self.move_to_archive(vid_dict)
self.delete_from_pending(youtube_id)
if self.config["downloads"]["sleep_interval"]:
sleep(self.config["downloads"]["sleep_interval"])
@staticmethod
def add_pending():
"""add pending videos to download queue"""
all_pending, _ = PendingList().get_all_pending()
to_add = [i["youtube_id"] for i in all_pending]
if not to_add:
# there is nothing pending
print("download queue is empty")
mess_dict = {
"status": "downloading",
"level": "error",
"title": "Download queue is empty",
"message": "",
}
RedisArchivist().set_message("progress:download", mess_dict)
return
queue = RedisQueue("dl_queue")
queue.add_list(to_add)
@staticmethod
def progress_hook(response):
@@ -445,10 +532,10 @@ class VideoDownloader:
"title": title,
"message": message,
}
set_message("progress:download", mess_dict)
RedisArchivist().set_message("progress:download", mess_dict)
def dl_single_vid(self, youtube_id):
"""download single video"""
def build_obs(self):
"""build obs dictionary for yt-dlp"""
obs = {
"default_search": "ytsearch",
"merge_output_format": "mp4",
@@ -459,14 +546,21 @@ class VideoDownloader:
+ self.config["application"]["file_template"]
),
"progress_hooks": [self.progress_hook],
"noprogress": True,
"quiet": True,
"continuedl": True,
"retries": 3,
"writethumbnail": False,
}
if self.config["downloads"]["format"]:
obs["format"] = self.config["downloads"]["format"]
if self.config["downloads"]["limit_speed"]:
obs["ratelimit"] = self.config["downloads"]["limit_speed"] * 1024
throttle = self.config["downloads"]["throttledratelimit"]
if throttle:
obs["throttledratelimit"] = throttle * 1024
external = False
if external:
obs["external_downloader"] = "aria2c"
@@ -482,14 +576,29 @@ class VideoDownloader:
}
)
if self.config["downloads"]["add_thumbnail"]:
postprocessors.append(
{
"key": "EmbedThumbnail",
"already_have_thumbnail": True,
}
)
obs["writethumbnail"] = True
obs["postprocessors"] = postprocessors
return obs
def dl_single_vid(self, youtube_id):
"""download single video"""
dl_cache = self.config["application"]["cache_dir"] + "/download/"
obs = self.build_obs()
# check if already in cache to continue from there
cache_dir = self.config["application"]["cache_dir"]
all_cached = os.listdir(cache_dir + "/download/")
all_cached = ignore_filelist(os.listdir(dl_cache))
for file_name in all_cached:
if youtube_id in file_name:
obs["outtmpl"] = cache_dir + "/download/" + file_name
obs["outtmpl"] = os.path.join(dl_cache, file_name)
with youtube_dl.YoutubeDL(obs) as ydl:
try:
ydl.download([youtube_id])
@@ -498,36 +607,44 @@ class VideoDownloader:
sleep(10)
ydl.download([youtube_id])
if obs["writethumbnail"]:
# webp files don't get cleaned up automatically
all_cached = ignore_filelist(os.listdir(dl_cache))
to_clean = [i for i in all_cached if not i.endswith(".mp4")]
for file_name in to_clean:
file_path = os.path.join(dl_cache, file_name)
os.remove(file_path)
def move_to_archive(self, vid_dict):
"""move downloaded video from cache to archive"""
videos = self.config["application"]["videos"]
channel_name = vid_dict["channel"]["channel_name"]
channel_name_clean = clean_string(channel_name)
media_url = vid_dict["media_url"]
youtube_id = vid_dict["youtube_id"]
# make archive folder
videos = self.config["application"]["videos"]
new_folder = os.path.join(videos, channel_name_clean)
os.makedirs(new_folder, exist_ok=True)
host_uid = self.config["application"]["HOST_UID"]
host_gid = self.config["application"]["HOST_GID"]
channel_name = clean_string(vid_dict["channel"]["channel_name"])
# make archive folder with correct permissions
new_folder = os.path.join(videos, channel_name)
if not os.path.exists(new_folder):
os.makedirs(new_folder)
if host_uid and host_gid:
os.chown(new_folder, host_uid, host_gid)
# find real filename
cache_dir = self.config["application"]["cache_dir"]
for file_str in os.listdir(cache_dir + "/download"):
if youtube_id in file_str:
all_cached = ignore_filelist(os.listdir(cache_dir + "/download/"))
for file_str in all_cached:
if vid_dict["youtube_id"] in file_str:
old_file = file_str
old_file_path = os.path.join(cache_dir, "download", old_file)
new_file_path = os.path.join(videos, media_url)
# move and fix permission
new_file_path = os.path.join(videos, vid_dict["media_url"])
# move media file and fix permission
shutil.move(old_file_path, new_file_path)
os.chown(
new_file_path,
self.config["application"]["HOST_UID"],
self.config["application"]["HOST_GID"],
)
if host_uid and host_gid:
os.chown(new_file_path, host_uid, host_gid)
def delete_from_pending(self, youtube_id):
"""delete downloaded video from pending index if its there"""
es_url = self.config["application"]["es_url"]
es_auth = self.config["application"]["es_auth"]
url = f"{es_url}/ta_download/_doc/{youtube_id}"
response = requests.delete(url)
response = requests.delete(url, auth=es_auth)
if not response.ok and not response.status_code == 404:
print(response.text)

View File

@@ -9,20 +9,20 @@ import re
import string
import subprocess
import unicodedata
from urllib.parse import parse_qs, urlparse
import redis
import requests
REDIS_HOST = os.environ.get("REDIS_HOST")
import yt_dlp as youtube_dl
def get_total_hits(index, es_url, match_field):
def get_total_hits(index, es_url, es_auth, match_field):
"""get total hits from index"""
headers = {"Content-type": "application/json"}
data = {"query": {"match": {match_field: True}}}
payload = json.dumps(data)
url = f"{es_url}/{index}/_search?filter_path=hits.total"
request = requests.post(url, data=payload, headers=headers)
request = requests.post(url, data=payload, headers=headers, auth=es_auth)
if not request.ok:
print(request.text)
total_json = json.loads(request.text)
@@ -40,88 +40,250 @@ def clean_string(file_name):
return cleaned
def process_url_list(url_str):
"""parse url_list to find valid youtube video or channel ids"""
to_replace = ["watch?v=", "playlist?list="]
url_list = re.split("\n+", url_str[0])
youtube_ids = []
for url in url_list:
url_clean = url.strip().strip("/").split("/")[-1]
for i in to_replace:
url_clean = url_clean.replace(i, "")
url_no_param = url_clean.split("&")[0]
str_len = len(url_no_param)
def ignore_filelist(filelist):
"""ignore temp files for os.listdir sanitizer"""
to_ignore = ["Icon\r\r", "Temporary Items", "Network Trash Folder"]
cleaned = []
for file_name in filelist:
if file_name.startswith(".") or file_name in to_ignore:
continue
cleaned.append(file_name)
return cleaned
class UrlListParser:
"""take a multi line string and detect valid youtube ids"""
def __init__(self, url_str):
self.url_list = [i.strip() for i in url_str.split()]
def process_list(self):
"""loop through the list"""
youtube_ids = []
for url in self.url_list:
parsed = urlparse(url)
print(f"processing: {url}")
print(parsed)
if not parsed.netloc:
# is not a url
id_type = self.find_valid_id(url)
youtube_id = url
elif "youtube.com" not in url:
raise ValueError(f"{url} is not a youtube link")
elif parsed.path:
# is a url
youtube_id, id_type = self.detect_from_url(parsed)
else:
# not detected
raise ValueError(f"failed to detect {url}")
youtube_ids.append({"url": youtube_id, "type": id_type})
return youtube_ids
def detect_from_url(self, parsed):
"""detect from parsed url"""
if parsed.netloc == "youtu.be":
# shortened
youtube_id = parsed.path.strip("/")
_ = self.find_valid_id(youtube_id)
return youtube_id, "video"
if parsed.query:
# detect from query string
query_parsed = parse_qs(parsed.query)
if "v" in query_parsed.keys():
youtube_id = query_parsed["v"][0]
_ = self.find_valid_id(youtube_id)
return youtube_id, "video"
if "list" in query_parsed.keys():
youtube_id = query_parsed["list"][0]
return youtube_id, "playlist"
if parsed.path.startswith("/channel/"):
# channel id in url
youtube_id = parsed.path.split("/")[2]
_ = self.find_valid_id(youtube_id)
return youtube_id, "channel"
# dedect channel with yt_dlp
youtube_id = self.extract_channel_name(parsed.geturl())
return youtube_id, "channel"
@staticmethod
def find_valid_id(id_str):
"""dedect valid id from length of string"""
str_len = len(id_str)
if str_len == 11:
link_type = "video"
id_type = "video"
elif str_len == 24:
link_type = "channel"
id_type = "channel"
elif str_len == 34:
link_type = "playlist"
id_type = "playlist"
else:
# unable to parse
raise ValueError("not a valid url: " + url)
raise ValueError("not a valid id_str: " + id_str)
youtube_ids.append({"url": url_no_param, "type": link_type})
return id_type
return youtube_ids
def set_message(key, message, expire=True):
"""write new message to redis"""
redis_connection = redis.Redis(host=REDIS_HOST)
redis_connection.execute_command("JSON.SET", key, ".", json.dumps(message))
if expire:
redis_connection.execute_command("EXPIRE", key, 20)
def get_message(key):
"""get any message from JSON key"""
redis_connection = redis.Redis(host=REDIS_HOST)
reply = redis_connection.execute_command("JSON.GET", key)
if reply:
json_str = json.loads(reply)
else:
json_str = {"status": False}
return json_str
def get_dl_message(cache_dir):
"""get latest message if available"""
redis_connection = redis.Redis(host=REDIS_HOST)
reply = redis_connection.execute_command("JSON.GET", "progress:download")
if reply:
json_str = json.loads(reply)
elif json_str := monitor_cache_dir(cache_dir):
json_str = monitor_cache_dir(cache_dir)
else:
json_str = {"status": False}
return json_str
def get_lock(lock_key):
"""handle lock for task management"""
redis_lock = redis.Redis(host=REDIS_HOST).lock(lock_key)
return redis_lock
def monitor_cache_dir(cache_dir):
"""
look at download cache dir directly as alternative progress info
"""
dl_cache = os.path.join(cache_dir, "download")
cache_file = os.listdir(dl_cache)
if cache_file:
filename = cache_file[0][12:].replace("_", " ").split(".")[0]
mess_dict = {
"status": "downloading",
"level": "info",
"title": "Downloading: " + filename,
"message": "",
@staticmethod
def extract_channel_name(url):
"""find channel id from channel name with yt-dlp help"""
obs = {
"default_search": "ytsearch",
"quiet": True,
"skip_download": True,
"extract_flat": True,
"playlistend": 0,
}
else:
return False
url_info = youtube_dl.YoutubeDL(obs).extract_info(url, download=False)
try:
channel_id = url_info["channel_id"]
except KeyError as error:
print(f"failed to extract channel id from {url}")
raise ValueError from error
return mess_dict
return channel_id
class RedisArchivist:
"""collection of methods to interact with redis"""
REDIS_HOST = os.environ.get("REDIS_HOST")
REDIS_PORT = os.environ.get("REDIS_PORT")
NAME_SPACE = "ta:"
if not REDIS_PORT:
REDIS_PORT = 6379
def __init__(self):
self.redis_connection = redis.Redis(
host=self.REDIS_HOST, port=self.REDIS_PORT
)
def set_message(self, key, message, expire=True):
"""write new message to redis"""
self.redis_connection.execute_command(
"JSON.SET", self.NAME_SPACE + key, ".", json.dumps(message)
)
if expire:
self.redis_connection.execute_command(
"EXPIRE", self.NAME_SPACE + key, 20
)
def get_message(self, key):
"""get message dict from redis"""
reply = self.redis_connection.execute_command(
"JSON.GET", self.NAME_SPACE + key
)
if reply:
json_str = json.loads(reply)
else:
json_str = {"status": False}
return json_str
def del_message(self, key):
"""delete key from redis"""
response = self.redis_connection.execute_command(
"DEL", self.NAME_SPACE + key
)
return response
def get_lock(self, lock_key):
"""handle lock for task management"""
redis_lock = self.redis_connection.lock(self.NAME_SPACE + lock_key)
return redis_lock
def get_dl_message(self, cache_dir):
"""get latest download progress message if available"""
reply = self.redis_connection.execute_command(
"JSON.GET", self.NAME_SPACE + "progress:download"
)
if reply:
json_str = json.loads(reply)
elif json_str := self.monitor_cache_dir(cache_dir):
json_str = self.monitor_cache_dir(cache_dir)
else:
json_str = {"status": False}
return json_str
@staticmethod
def monitor_cache_dir(cache_dir):
"""
look at download cache dir directly as alternative progress info
"""
dl_cache = os.path.join(cache_dir, "download")
all_cache_file = os.listdir(dl_cache)
cache_file = ignore_filelist(all_cache_file)
if cache_file:
filename = cache_file[0][12:].replace("_", " ").split(".")[0]
mess_dict = {
"status": "downloading",
"level": "info",
"title": "Downloading: " + filename,
"message": "",
}
else:
return False
return mess_dict
class RedisQueue:
"""dynamically interact with the download queue in redis"""
REDIS_HOST = os.environ.get("REDIS_HOST")
REDIS_PORT = os.environ.get("REDIS_PORT")
NAME_SPACE = "ta:"
if not REDIS_PORT:
REDIS_PORT = 6379
def __init__(self, key):
self.key = self.NAME_SPACE + key
self.conn = redis.Redis(host=self.REDIS_HOST, port=self.REDIS_PORT)
def get_all(self):
"""return all elements in list"""
result = self.conn.execute_command("LRANGE", self.key, 0, -1)
all_elements = [i.decode() for i in result]
return all_elements
def add_list(self, to_add):
"""add list to queue"""
self.conn.execute_command("RPUSH", self.key, *to_add)
def add_priority(self, to_add):
"""add single video to front of queue"""
self.clear_item(to_add)
self.conn.execute_command("LPUSH", self.key, to_add)
def get_next(self):
"""return next element in the queue, False if none"""
result = self.conn.execute_command("LPOP", self.key)
if not result:
return False
next_element = result.decode()
return next_element
def clear(self):
"""delete list from redis"""
self.conn.execute_command("DEL", self.key)
def clear_item(self, to_clear):
"""remove single item from list if it's there"""
self.conn.execute_command("LREM", self.key, 0, to_clear)
def trim(self, size):
"""trim the queue based on settings amount"""
self.conn.execute_command("LTRIM", self.key, 0, size)
class DurationConverter:
@@ -152,6 +314,10 @@ class DurationConverter:
@staticmethod
def get_str(duration_sec):
"""takes duration in sec and returns clean string"""
if not duration_sec:
# failed to extract
return "NA"
hours = duration_sec // 3600
minutes = (duration_sec - (hours * 3600)) // 60
secs = duration_sec - (hours * 3600) - (minutes * 60)

View File

@@ -15,7 +15,8 @@ import requests
import yt_dlp as youtube_dl
from bs4 import BeautifulSoup
from home.src.config import AppConfig
from home.src.helper import DurationConverter, clean_string, process_url_list
from home.src.helper import DurationConverter, UrlListParser, clean_string
from home.src.thumbnails import ThumbManager
class YoutubeChannel:
@@ -23,7 +24,9 @@ class YoutubeChannel:
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
CACHE_DIR = CONFIG["application"]["cache_dir"]
VIDEOS = CONFIG["application"]["videos"]
def __init__(self, channel_id):
self.channel_id = channel_id
@@ -49,7 +52,7 @@ class YoutubeChannel:
"""get from elastic search first if possible"""
channel_id = self.channel_id
url = f"{self.ES_URL}/ta_channel/_doc/{channel_id}"
response = requests.get(url)
response = requests.get(url, auth=self.ES_AUTH)
if response.ok:
channel_source = response.json()["_source"]
self.source = "elastic"
@@ -61,7 +64,7 @@ class YoutubeChannel:
channel_id = self.channel_id
url = f"https://www.youtube.com/channel/{channel_id}/about?hl=en"
cookies = {"CONSENT": "YES+xxxxxxxxxxxxxxxxxxxxxxxxxxx"}
response = requests.get(url, cookies=cookies)
response = requests.get(url, cookies=cookies, auth=self.ES_AUTH)
if response.ok:
channel_page = response.text
else:
@@ -152,24 +155,23 @@ class YoutubeChannel:
return meta_channel_dict
def get_channel_art(self):
"""download channel art for new channels"""
channel_id = self.channel_id
channel_thumb = self.channel_dict["channel_thumb_url"]
channel_banner = self.channel_dict["channel_banner_url"]
ThumbManager().download_chan(
[(channel_id, channel_thumb, channel_banner)]
)
def upload_to_es(self):
"""upload channel data to elastic search"""
url = f"{self.ES_URL}/ta_channel/_doc/{self.channel_id}"
response = requests.put(url, json=self.channel_dict)
response = requests.put(url, json=self.channel_dict, auth=self.ES_AUTH)
print(f"added {self.channel_id} to es")
if not response.ok:
print(response.text)
def clear_cache(self):
"""delete banner and thumb from cache if available"""
channel_cache = os.path.join(self.CACHE_DIR, "channels")
thumb = os.path.join(channel_cache, self.channel_id + "_thumb.jpg")
banner = os.path.join(channel_cache, self.channel_id + "_banner.jpg")
if os.path.exists(thumb):
os.remove(thumb)
if os.path.exists(banner):
os.remove(banner)
def sync_to_videos(self):
"""sync new channel_dict to all videos of channel"""
headers = {"Content-type": "application/json"}
@@ -182,28 +184,66 @@ class YoutubeChannel:
data = {"description": channel_id, "processors": processors}
payload = json.dumps(data)
url = self.ES_URL + "/_ingest/pipeline/" + channel_id
request = requests.put(url, data=payload, headers=headers)
request = requests.put(
url, data=payload, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request.text)
# apply pipeline
data = {"query": {"match": {"channel.channel_id": channel_id}}}
payload = json.dumps(data)
url = self.ES_URL + "/ta_video/_update_by_query?pipeline=" + channel_id
request = requests.post(url, data=payload, headers=headers)
request = requests.post(
url, data=payload, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request.text)
def get_total_hits(self):
"""get total channels indexed"""
def get_folder_path(self):
"""get folder where media files get stored"""
channel_name = self.channel_dict["channel_name"]
folder_name = clean_string(channel_name)
folder_path = os.path.join(self.VIDEOS, folder_name)
return folder_path
def delete_es_videos(self):
"""delete all channel documents from elasticsearch"""
headers = {"Content-type": "application/json"}
data = {"query": {"match_all": {}}}
data = {
"query": {
"term": {"channel.channel_id": {"value": self.channel_id}}
}
}
payload = json.dumps(data)
url = f"{self.ES_URL}/ta_channel/_search?filter_path=hits.total"
request = requests.post(url, data=payload, headers=headers)
if not request.ok:
print(request.text)
total_hits = json.loads(request.text)["hits"]["total"]["value"]
return total_hits
url = self.ES_URL + "/ta_video/_delete_by_query"
response = requests.post(
url, data=payload, headers=headers, auth=self.ES_AUTH
)
if not response.ok:
print(response.text)
def delete_channel(self):
"""delete channel and all videos"""
print(f"deleting {self.channel_id} and all matching media files")
folder_path = self.get_folder_path()
print("delete all media files")
try:
all_videos = os.listdir(folder_path)
for video in all_videos:
video_path = os.path.join(folder_path, video)
os.remove(video_path)
os.rmdir(folder_path)
except FileNotFoundError:
print(f"no videos found for {folder_path}")
ThumbManager().delete_chan_thumb(self.channel_id)
print("delete indexed videos")
self.delete_es_videos()
url = self.ES_URL + "/ta_channel/_doc/" + self.channel_id
response = requests.delete(url, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
class YoutubeVideo:
@@ -211,17 +251,19 @@ class YoutubeVideo:
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
CACHE_DIR = CONFIG["application"]["cache_dir"]
VIDEOS = CONFIG["application"]["videos"]
def __init__(self, youtube_id):
self.youtube_id = youtube_id
self.channel_id = None
self.vid_dict = self.get_wrapper()
self.vid_dict = None
def get_wrapper(self):
def get_vid_dict(self):
"""wrapper to loop around youtube_dl to retry on failure"""
print(f"get video data for {self.youtube_id}")
vid_dict = False
for i in range(3):
try:
vid_dict = self.get_youtubedl_vid_data()
@@ -232,7 +274,7 @@ class YoutubeVideo:
else:
break
return vid_dict
self.vid_dict = vid_dict
def get_youtubedl_vid_data(self):
"""parse youtubedl extract info"""
@@ -330,7 +372,7 @@ class YoutubeVideo:
def get_es_data(self):
"""get current data from elastic search"""
url = self.ES_URL + "/ta_video/_doc/" + self.youtube_id
response = requests.get(url)
response = requests.get(url, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
es_vid_dict = json.loads(response.text)
@@ -339,17 +381,10 @@ class YoutubeVideo:
def upload_to_es(self):
"""upload channel data to elastic search"""
url = f"{self.ES_URL}/ta_video/_doc/{self.youtube_id}"
response = requests.put(url, json=self.vid_dict)
response = requests.put(url, json=self.vid_dict, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
def delete_cache(self):
"""delete thumbnail from cache if exist"""
video_cache = os.path.join(self.CACHE_DIR, "videos")
thumb = os.path.join(video_cache, self.youtube_id + ".jpg")
if os.path.exists(thumb):
os.remove(thumb)
def deactivate(self):
"""deactivate document on extractor error"""
youtube_id = self.youtube_id
@@ -357,17 +392,36 @@ class YoutubeVideo:
url = f"{self.ES_URL}/ta_video/_update/{youtube_id}"
data = {"script": "ctx._source.active = false"}
json_str = json.dumps(data)
response = requests.post(url, data=json_str, headers=headers)
response = requests.post(
url, data=json_str, headers=headers, auth=self.ES_AUTH
)
print(f"deactivated {youtube_id}")
if not response.ok:
print(response.text)
def delete_media_file(self):
"""delete video file, meta data, thumbnails"""
# delete media file
es_vid_dict = self.get_es_data()
media_url = es_vid_dict["_source"]["media_url"]
print(f"delete {media_url} from file system")
to_delete = os.path.join(self.VIDEOS, media_url)
os.remove(to_delete)
# delete from index
url = f"{self.ES_URL}/ta_video/_doc/{self.youtube_id}"
response = requests.delete(url, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
# delete thumbs from cache
ThumbManager().delete_vid_thumb(self.youtube_id)
class WatchState:
"""handle watched checkbox for videos and channels"""
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
HEADERS = {"Content-type": "application/json"}
def __init__(self, youtube_id):
@@ -384,25 +438,39 @@ class WatchState:
print(f"marked {self.youtube_id} as watched")
def mark_as_unwatched(self):
"""revert watched state to false"""
url_type = self.dedect_type()
if url_type == "video":
self.mark_vid_watched(revert=True)
elif url_type == "channel":
self.mark_channel_watched(revert=True)
print(f"revert {self.youtube_id} as unwatched")
def dedect_type(self):
"""find youtube id type"""
url_process = process_url_list([self.youtube_id])
url_process = UrlListParser(self.youtube_id).process_list()
url_type = url_process[0]["type"]
return url_type
def mark_vid_watched(self):
def mark_vid_watched(self, revert=False):
"""change watched status of single video"""
url = self.ES_URL + "/ta_video/_update/" + self.youtube_id
data = {
"doc": {"player": {"watched": True, "watched_date": self.stamp}}
}
if revert:
data["doc"]["player"]["watched"] = False
payload = json.dumps(data)
request = requests.post(url, data=payload, headers=self.HEADERS)
request = requests.post(
url, data=payload, headers=self.HEADERS, auth=self.ES_AUTH
)
if not request.ok:
print(request.text)
def mark_channel_watched(self):
def mark_channel_watched(self, revert=False):
"""change watched status of every video in channel"""
es_url = self.ES_URL
headers = self.HEADERS
@@ -415,9 +483,14 @@ class WatchState:
{"set": {"field": "player.watched_date", "value": self.stamp}},
],
}
if revert:
data["processors"][0]["set"]["value"] = False
payload = json.dumps(data)
url = f"{es_url}/_ingest/pipeline/{youtube_id}"
request = requests.put(url, data=payload, headers=headers)
request = requests.put(
url, data=payload, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request.text)
raise ValueError("failed to post ingest pipeline")
@@ -430,7 +503,9 @@ class WatchState:
data = {"query": {"bool": {"must": must_list}}}
payload = json.dumps(data)
url = f"{es_url}/ta_video/_update_by_query?pipeline={youtube_id}"
request = requests.post(url, data=payload, headers=headers)
request = requests.post(
url, data=payload, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request.text)
@@ -438,6 +513,7 @@ class WatchState:
def index_new_video(youtube_id, missing_vid=False):
"""combine video and channel classes for new video index"""
vid_handler = YoutubeVideo(youtube_id)
vid_handler.get_vid_dict()
if not vid_handler.vid_dict:
raise ValueError("failed to get metadata for " + youtube_id)
@@ -452,6 +528,7 @@ def index_new_video(youtube_id, missing_vid=False):
if channel_handler.source == "scraped":
channel_handler.channel_dict["channel_subscribed"] = False
channel_handler.upload_to_es()
channel_handler.get_channel_art()
# upload video to es
vid_handler.upload_to_es()
# return vid_dict for further processing

View File

@@ -13,6 +13,7 @@ from datetime import datetime
import requests
from home.src.config import AppConfig
from home.src.helper import ignore_filelist
# expected mapping and settings
INDEX_CONFIG = [
@@ -163,6 +164,7 @@ class ElasticIndex:
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
HEADERS = {"Content-type": "application/json"}
def __init__(self, index_name, expected_map, expected_set):
@@ -175,7 +177,7 @@ class ElasticIndex:
"""check if index already exists and return mapping if it does"""
index_name = self.index_name
url = f"{self.ES_URL}/ta_{index_name}"
response = requests.get(url)
response = requests.get(url, auth=self.ES_AUTH)
exists = response.ok
if exists:
@@ -273,7 +275,9 @@ class ElasticIndex:
query = {"source": {"index": source}, "dest": {"index": destination}}
data = json.dumps(query)
url = self.ES_URL + "/_reindex?refresh=true"
response = requests.post(url=url, data=data, headers=self.HEADERS)
response = requests.post(
url=url, data=data, headers=self.HEADERS, auth=self.ES_AUTH
)
if not response.ok:
print(response.text)
@@ -283,7 +287,7 @@ class ElasticIndex:
url = f"{self.ES_URL}/ta_{self.index_name}_backup"
else:
url = f"{self.ES_URL}/ta_{self.index_name}"
response = requests.delete(url)
response = requests.delete(url, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
@@ -300,7 +304,9 @@ class ElasticIndex:
# create
url = f"{self.ES_URL}/ta_{self.index_name}"
data = json.dumps(payload)
response = requests.put(url=url, data=data, headers=self.HEADERS)
response = requests.put(
url=url, data=data, headers=self.HEADERS, auth=self.ES_AUTH
)
if not response.ok:
print(response.text)
@@ -318,9 +324,10 @@ class ElasticBackup:
"""export all documents of a single index"""
headers = {"Content-type": "application/json"}
es_url = self.config["application"]["es_url"]
es_auth = self.config["application"]["es_auth"]
# get PIT ID
url = f"{es_url}/ta_{index_name}/_pit?keep_alive=1m"
response = requests.post(url)
response = requests.post(url, auth=es_auth)
json_data = json.loads(response.text)
pit_id = json_data["id"]
# build query
@@ -335,7 +342,9 @@ class ElasticBackup:
# loop until nothing left
all_results = []
while True:
response = requests.get(url, data=query_str, headers=headers)
response = requests.get(
url, data=query_str, headers=headers, auth=es_auth
)
json_data = json.loads(response.text)
all_hits = json_data["hits"]["hits"]
if all_hits:
@@ -349,7 +358,9 @@ class ElasticBackup:
break
# clean up PIT
query_str = json.dumps({"id": pit_id})
requests.delete(es_url + "/_pit", data=query_str, headers=headers)
requests.delete(
es_url + "/_pit", data=query_str, headers=headers, auth=es_auth
)
return all_results
@@ -415,6 +426,7 @@ class ElasticBackup:
"""send bulk to es"""
cache_dir = self.config["application"]["cache_dir"]
es_url = self.config["application"]["es_url"]
es_auth = self.config["application"]["es_auth"]
headers = {"Content-type": "application/x-ndjson"}
file_path = os.path.join(cache_dir, file_name)
@@ -425,7 +437,9 @@ class ElasticBackup:
return
url = es_url + "/_bulk"
request = requests.post(url, data=query_str, headers=headers)
request = requests.post(
url, data=query_str, headers=headers, auth=es_auth
)
if not request.ok:
print(request.text)
@@ -433,9 +447,11 @@ class ElasticBackup:
"""extract backup zip and return filelist"""
cache_dir = self.config["application"]["cache_dir"]
backup_dir = os.path.join(cache_dir, "backup")
backup_files = os.listdir(backup_dir)
all_backup_files = ignore_filelist(backup_files)
all_available_backups = [
i
for i in os.listdir(backup_dir)
for i in all_backup_files
if i.startswith("ta_") and i.endswith(".zip")
]
all_available_backups.sort()

View File

@@ -18,12 +18,13 @@ import requests
from home.src.config import AppConfig
from home.src.download import ChannelSubscription, PendingList, VideoDownloader
from home.src.helper import (
RedisArchivist,
clean_string,
get_message,
get_total_hits,
set_message,
ignore_filelist,
)
from home.src.index import YoutubeChannel, YoutubeVideo, index_new_video
from home.src.thumbnails import ThumbManager
class Reindex:
@@ -34,6 +35,7 @@ class Reindex:
config = AppConfig().config
self.sleep_interval = config["downloads"]["sleep_interval"]
self.es_url = config["application"]["es_url"]
self.es_auth = config["application"]["es_auth"]
self.refresh_interval = 90
# scan
self.video_daily, self.channel_daily = self.get_daily()
@@ -42,10 +44,12 @@ class Reindex:
def get_daily(self):
"""get daily refresh values"""
total_videos = get_total_hits("ta_video", self.es_url, "active")
total_videos = get_total_hits(
"ta_video", self.es_url, self.es_auth, "active"
)
video_daily = ceil(total_videos / self.refresh_interval * 1.2)
total_channels = get_total_hits(
"ta_channel", self.es_url, "channel_active"
"ta_channel", self.es_url, self.es_auth, "channel_active"
)
channel_daily = ceil(total_channels / self.refresh_interval * 1.2)
return (video_daily, channel_daily)
@@ -71,7 +75,9 @@ class Reindex:
}
query_str = json.dumps(data)
url = self.es_url + "/ta_video/_search"
response = requests.get(url, data=query_str, headers=headers)
response = requests.get(
url, data=query_str, headers=headers, auth=self.es_auth
)
if not response.ok:
print(response.text)
response_dict = json.loads(response.text)
@@ -99,7 +105,9 @@ class Reindex:
}
query_str = json.dumps(data)
url = self.es_url + "/ta_channel/_search"
response = requests.get(url, data=query_str, headers=headers)
response = requests.get(
url, data=query_str, headers=headers, auth=self.es_auth
)
if not response.ok:
print(response.text)
response_dict = json.loads(response.text)
@@ -127,7 +135,7 @@ class Reindex:
"title": "Scraping all youtube channels",
"message": message,
}
set_message("progress:download", mess_dict)
RedisArchivist().set_message("progress:download", mess_dict)
channel_index = YoutubeChannel(channel_id)
subscribed = channel_index.channel_dict["channel_subscribed"]
channel_index.channel_dict = channel_index.build_channel_dict(
@@ -144,6 +152,7 @@ class Reindex:
def reindex_single_video(youtube_id):
"""refresh data for single video"""
vid_handler = YoutubeVideo(youtube_id)
vid_handler.get_vid_dict()
if not vid_handler.vid_dict:
# stop if deactivated
vid_handler.deactivate()
@@ -161,7 +170,10 @@ class Reindex:
vid_handler.vid_dict["channel"] = channel_dict
# update
vid_handler.upload_to_es()
vid_handler.delete_cache()
thumb_handler = ThumbManager()
thumb_handler.delete_vid_thumb(youtube_id)
to_download = (youtube_id, vid_handler.vid_dict["vid_thumb_url"])
thumb_handler.download_vid([to_download], notify=False)
@staticmethod
def reindex_single_channel(channel_id):
@@ -172,9 +184,15 @@ class Reindex:
scrape=True
)
channel_handler.channel_dict["channel_subscribed"] = subscribed
# update
channel_handler.upload_to_es()
channel_handler.sync_to_videos()
channel_handler.clear_cache()
thumb_handler = ThumbManager()
thumb_handler.delete_chan_thumb(channel_id)
channel_thumb = channel_handler.channel_dict["channel_thumb_url"]
channel_banner = channel_handler.channel_dict["channel_banner_url"]
to_download = (channel_id, channel_thumb, channel_banner)
thumb_handler.download_chan([to_download])
def reindex(self):
"""reindex what's needed"""
@@ -197,6 +215,7 @@ class FilesystemScanner:
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
VIDEOS = CONFIG["application"]["videos"]
def __init__(self):
@@ -209,12 +228,15 @@ class FilesystemScanner:
def get_all_downloaded(self):
"""get a list of all video files downloaded"""
all_channels = os.listdir(self.VIDEOS)
channels = os.listdir(self.VIDEOS)
all_channels = ignore_filelist(channels)
all_channels.sort()
all_downloaded = []
for channel_name in all_channels:
channel_path = os.path.join(self.VIDEOS, channel_name)
for video in os.listdir(channel_path):
videos = os.listdir(channel_path)
all_videos = ignore_filelist(videos)
for video in all_videos:
youtube_id = video[9:20]
all_downloaded.append((channel_name, video, youtube_id))
@@ -294,6 +316,7 @@ class FilesystemScanner:
"""rename media files as identified by find_bad_media_url"""
for bad_filename in self.to_rename:
channel, filename, expected_filename = bad_filename
print(f"renaming [{filename}] to [{expected_filename}]")
old_path = os.path.join(self.VIDEOS, channel, filename)
new_path = os.path.join(self.VIDEOS, channel, expected_filename)
os.rename(old_path, new_path)
@@ -303,6 +326,7 @@ class FilesystemScanner:
bulk_list = []
for video_mismatch in self.mismatch:
youtube_id, media_url = video_mismatch
print(f"{youtube_id}: fixing media url {media_url}")
action = {"update": {"_id": youtube_id, "_index": "ta_video"}}
source = {"doc": {"media_url": media_url}}
bulk_list.append(json.dumps(action))
@@ -313,16 +337,19 @@ class FilesystemScanner:
# make the call
headers = {"Content-type": "application/x-ndjson"}
url = self.ES_URL + "/_bulk"
request = requests.post(url, data=query_str, headers=headers)
request = requests.post(
url, data=query_str, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request.text)
def delete_from_index(self):
"""find indexed but deleted mediafile"""
for indexed in self.to_delete:
youtube_id, _ = indexed
youtube_id = indexed[0]
print(f"deleting {youtube_id} from index")
url = self.ES_URL + "/ta_video/_doc/" + youtube_id
request = requests.delete(url)
request = requests.delete(url, auth=self.ES_AUTH)
if not request.ok:
print(request.text)
@@ -339,8 +366,8 @@ class ManualImport:
def import_folder_parser(self):
"""detect files in import folder"""
to_import = os.listdir(self.IMPORT_DIR)
import_files = os.listdir(self.IMPORT_DIR)
to_import = ignore_filelist(import_files)
to_import.sort()
video_files = [i for i in to_import if not i.endswith(".json")]
@@ -396,6 +423,8 @@ class ManualImport:
def process_import(self):
"""go through identified media files"""
all_videos_added = []
for media_file in self.identified:
json_file = media_file["json_file"]
video_file = media_file["video_file"]
@@ -408,6 +437,9 @@ class ManualImport:
# identify and archive
vid_dict = index_new_video(youtube_id)
VideoDownloader([youtube_id]).move_to_archive(vid_dict)
youtube_id = vid_dict["youtube_id"]
thumb_url = vid_dict["vid_thumb_url"]
all_videos_added.append((youtube_id, thumb_url))
# cleanup
if os.path.exists(video_path):
@@ -416,6 +448,8 @@ class ManualImport:
json_path = os.path.join(self.CACHE_DIR, "import", json_file)
os.remove(json_path)
return all_videos_added
def move_to_cache(self, video_path, youtube_id):
"""move identified video file to cache, convert to mp4"""
file_name = os.path.split(video_path)[-1]
@@ -453,12 +487,16 @@ def scan_filesystem():
filesystem_handler = FilesystemScanner()
filesystem_handler.list_comarison()
if filesystem_handler.to_rename:
print("renaming files")
filesystem_handler.rename_files()
if filesystem_handler.mismatch:
print("fixing media urls in index")
filesystem_handler.send_mismatch_bulk()
if filesystem_handler.to_delete:
print("delete metadata from index")
filesystem_handler.delete_from_index()
if filesystem_handler.to_index:
print("index new videos")
for missing_vid in filesystem_handler.to_index:
youtube_id = missing_vid[2]
index_new_video(youtube_id, missing_vid=missing_vid)
@@ -468,7 +506,7 @@ def reindex_old_documents():
"""daily refresh of old documents"""
# check needed last run
now = int(datetime.now().strftime("%s"))
last_reindex = get_message("last_reindex")
last_reindex = RedisArchivist().get_message("last_reindex")
if isinstance(last_reindex, int) and now - last_reindex < 60 * 60 * 24:
return
# continue if needed
@@ -476,4 +514,4 @@ def reindex_old_documents():
reindex_handler.check_outdated()
reindex_handler.reindex()
# set timestamp
set_message("last_reindex", now, expire=False)
RedisArchivist().set_message("last_reindex", now, expire=False)

View File

@@ -7,13 +7,13 @@ Functionality:
"""
import math
import os
import urllib.parse
from datetime import datetime
import requests
from home.src.config import AppConfig
from PIL import Image
from home.src.helper import RedisArchivist
from home.src.thumbnails import ThumbManager
class SearchHandler:
@@ -21,19 +21,21 @@ class SearchHandler:
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
ES_AUTH = CONFIG["application"]["es_auth"]
def __init__(self, url, data, cache=True):
def __init__(self, url, data):
self.max_hits = None
self.url = url
self.data = data
self.cache = cache
def get_data(self):
"""get the data"""
if self.data:
response = requests.get(self.url, json=self.data).json()
response = requests.get(
self.url, json=self.data, auth=self.ES_AUTH
).json()
else:
response = requests.get(self.url).json()
response = requests.get(self.url, auth=self.ES_AUTH).json()
if "hits" in response.keys():
self.max_hits = response["hits"]["total"]["value"]
@@ -60,10 +62,6 @@ class SearchHandler:
channel_dict = self.channel_cache_link(hit)
if channel_dict not in all_channels:
all_channels.append(channel_dict)
if self.cache:
# validate cache
self.cache_dl_vids(all_videos)
self.cache_dl_chan(all_channels)
return return_value
@@ -102,48 +100,6 @@ class SearchHandler:
}
return channel_dict
def cache_dl_vids(self, all_videos):
"""video thumbs links for cache"""
vid_cache = os.path.join(self.CACHE_DIR, "videos")
all_vid_cached = os.listdir(vid_cache)
# videos
for video_dict in all_videos:
youtube_id = video_dict["youtube_id"]
if not youtube_id + ".jpg" in all_vid_cached:
cache_path = os.path.join(vid_cache, youtube_id + ".jpg")
thumb_url = video_dict["vid_thumb"]
img_raw = requests.get(thumb_url, stream=True).raw
img = Image.open(img_raw)
width, height = img.size
if not width / height == 16 / 9:
new_height = width / 16 * 9
offset = (height - new_height) / 2
img = img.crop((0, offset, width, height - offset))
img.convert("RGB").save(cache_path)
def cache_dl_chan(self, all_channels):
"""download channel thumbs"""
chan_cache = os.path.join(self.CACHE_DIR, "channels")
all_chan_cached = os.listdir(chan_cache)
for channel_dict in all_channels:
channel_id_cache = channel_dict["channel_id"]
channel_banner_url = channel_dict["chan_banner"]
channel_banner = channel_id_cache + "_banner.jpg"
channel_thumb_url = channel_dict["chan_thumb"]
channel_thumb = channel_id_cache + "_thumb.jpg"
# thumb
if channel_thumb_url and channel_thumb not in all_chan_cached:
cache_path = os.path.join(chan_cache, channel_thumb)
img_raw = requests.get(channel_thumb_url, stream=True).content
with open(cache_path, "wb") as f:
f.write(img_raw)
# banner
if channel_banner_url and channel_banner not in all_chan_cached:
cache_path = os.path.join(chan_cache, channel_banner)
img_raw = requests.get(channel_banner_url, stream=True).content
with open(cache_path, "wb") as f:
f.write(img_raw)
@staticmethod
def hit_cleanup(hit):
"""clean up and parse data from a single hit"""
@@ -165,6 +121,11 @@ class SearchHandler:
date_str = datetime.strftime(date_refresh, "%d %b, %Y")
hit["source"]["vid_last_refresh"] = date_str
if "vid_thumb_url" in hit_keys:
youtube_id = hit["source"]["youtube_id"]
thumb_path = ThumbManager().vid_thumb_path(youtube_id)
hit["source"]["vid_thumb_url"] = thumb_path
if "channel_last_refresh" in hit_keys:
refreshed = hit["source"]["channel_last_refresh"]
date_refresh = datetime.fromtimestamp(refreshed)
@@ -205,7 +166,7 @@ class SearchForm:
}
},
}
look_up = SearchHandler(url, data, cache=False)
look_up = SearchHandler(url, data)
search_results = look_up.get_data()
return {"results": search_results}
@@ -221,13 +182,23 @@ class Pagination:
figure out the pagination based on page size and total_hits
"""
def __init__(self, page_get, search_get=False):
config = AppConfig().config
self.page_size = config["archive"]["page_size"]
def __init__(self, page_get, user_id, search_get=False):
self.user_id = user_id
self.page_size = self.get_page_size()
self.page_get = page_get
self.search_get = search_get
self.pagination = self.first_guess()
def get_page_size(self):
"""get default or user modified page_size"""
key = f"{self.user_id}:page_size"
page_size = RedisArchivist().get_message(key)["status"]
if not page_size:
config = AppConfig().config
page_size = config["archive"]["page_size"]
return page_size
def first_guess(self):
"""build first guess before api call"""
page_get = self.page_get

View File

@@ -0,0 +1,288 @@
"""
functionality:
- handle download and caching for thumbnails
"""
import os
from collections import Counter
from time import sleep
import home.src.download as download
import requests
from home.src.config import AppConfig
from home.src.helper import RedisArchivist, ignore_filelist
from mutagen.mp4 import MP4, MP4Cover
from PIL import Image
class ThumbManager:
"""handle thumbnails related functions"""
CONFIG = AppConfig().config
MEDIA_DIR = CONFIG["application"]["videos"]
CACHE_DIR = CONFIG["application"]["cache_dir"]
VIDEO_DIR = os.path.join(CACHE_DIR, "videos")
CHANNEL_DIR = os.path.join(CACHE_DIR, "channels")
def get_all_thumbs(self):
"""get all video artwork already downloaded"""
all_thumb_folders = ignore_filelist(os.listdir(self.VIDEO_DIR))
all_thumbs = []
for folder in all_thumb_folders:
folder_path = os.path.join(self.VIDEO_DIR, folder)
if os.path.isfile(folder_path):
self.update_path(folder)
all_thumbs.append(folder_path)
continue
# raise exemption here in a future version
# raise FileExistsError("video cache dir has files inside")
all_folder_thumbs = ignore_filelist(os.listdir(folder_path))
all_thumbs.extend(all_folder_thumbs)
return all_thumbs
def update_path(self, file_name):
"""reorganize thumbnails into folders as update path from v0.0.5"""
folder_name = file_name[0].lower()
folder_path = os.path.join(self.VIDEO_DIR, folder_name)
old_file = os.path.join(self.VIDEO_DIR, file_name)
new_file = os.path.join(folder_path, file_name)
os.makedirs(folder_path, exist_ok=True)
os.rename(old_file, new_file)
def get_needed_thumbs(self, missing_only=False):
"""get a list of all missing thumbnails"""
all_thumbs = self.get_all_thumbs()
all_indexed = download.PendingList().get_all_indexed()
all_in_queue, all_ignored = download.PendingList().get_all_pending()
needed_thumbs = []
for video in all_indexed:
youtube_id = video["_source"]["youtube_id"]
thumb_url = video["_source"]["vid_thumb_url"]
if missing_only:
if youtube_id + ".jpg" not in all_thumbs:
needed_thumbs.append((youtube_id, thumb_url))
else:
needed_thumbs.append((youtube_id, thumb_url))
for video in all_in_queue + all_ignored:
youtube_id = video["youtube_id"]
thumb_url = video["vid_thumb_url"]
if missing_only:
if youtube_id + ".jpg" not in all_thumbs:
needed_thumbs.append((youtube_id, thumb_url))
else:
needed_thumbs.append((youtube_id, thumb_url))
return needed_thumbs
def get_missing_channels(self):
"""get all channel artwork"""
all_channel_art = os.listdir(self.CHANNEL_DIR)
files = [i[0:24] for i in all_channel_art]
cached_channel_ids = [k for (k, v) in Counter(files).items() if v > 1]
channels = download.ChannelSubscription().get_channels(
subscribed_only=False
)
missing_channels = []
for channel in channels:
channel_id = channel["channel_id"]
if channel_id not in cached_channel_ids:
channel_banner = channel["channel_banner_url"]
channel_thumb = channel["channel_thumb_url"]
missing_channels.append(
(channel_id, channel_thumb, channel_banner)
)
return missing_channels
def get_raw_img(self, img_url, thumb_type):
"""get raw image from youtube and handle 404"""
try:
app_root = self.CONFIG["application"]["app_root"]
except KeyError:
# lazy keyerror fix to not have to deal with a strange startup
# racing contition between the threads in HomeConfig.ready()
app_root = "/app"
default_map = {
"video": os.path.join(
app_root, "static/img/default-video-thumb.jpg"
),
"icon": os.path.join(
app_root, "static/img/default-channel-icon.jpg"
),
"banner": os.path.join(
app_root, "static/img/default-channel-banner.jpg"
),
}
if img_url:
try:
response = requests.get(img_url, stream=True)
except ConnectionError:
sleep(5)
response = requests.get(img_url, stream=True)
if not response.ok and not response.status_code == 404:
print("retry thumbnail download for " + img_url)
sleep(5)
response = requests.get(img_url, stream=True)
else:
response = False
if not response or response.status_code == 404:
# use default
img_raw = Image.open(default_map[thumb_type])
else:
# use response
img_obj = response.raw
img_raw = Image.open(img_obj)
return img_raw
def download_vid(self, missing_thumbs, notify=True):
"""download all missing thumbnails from list"""
print(f"downloading {len(missing_thumbs)} thumbnails")
counter = 1
for youtube_id, thumb_url in missing_thumbs:
folder_path = os.path.join(self.VIDEO_DIR, youtube_id[0].lower())
thumb_path = os.path.join(
self.CACHE_DIR, self.vid_thumb_path(youtube_id)
)
os.makedirs(folder_path, exist_ok=True)
img_raw = self.get_raw_img(thumb_url, "video")
width, height = img_raw.size
if not width / height == 16 / 9:
new_height = width / 16 * 9
offset = (height - new_height) / 2
img_raw = img_raw.crop((0, offset, width, height - offset))
img_raw.convert("RGB").save(thumb_path)
progress = f"{counter}/{len(missing_thumbs)}"
if notify:
mess_dict = {
"status": "pending",
"level": "info",
"title": "Downloading Thumbnails",
"message": "Progress: " + progress,
}
RedisArchivist().set_message("progress:download", mess_dict)
if counter % 25 == 0:
print("thumbnail progress: " + progress)
counter = counter + 1
def download_chan(self, missing_channels):
"""download needed artwork for channels"""
print(f"downloading {len(missing_channels)} channel artwork")
for channel in missing_channels:
channel_id, channel_thumb, channel_banner = channel
thumb_path = os.path.join(
self.CHANNEL_DIR, channel_id + "_thumb.jpg"
)
img_raw = self.get_raw_img(channel_thumb, "icon")
img_raw.convert("RGB").save(thumb_path)
banner_path = os.path.join(
self.CHANNEL_DIR, channel_id + "_banner.jpg"
)
img_raw = self.get_raw_img(channel_banner, "banner")
img_raw.convert("RGB").save(banner_path)
mess_dict = {
"status": "pending",
"level": "info",
"title": "Adding to download queue.",
"message": "Downloading Channel Art...",
}
RedisArchivist().set_message("progress:download", mess_dict)
@staticmethod
def vid_thumb_path(youtube_id):
"""build expected path for video thumbnail from youtube_id"""
folder_name = youtube_id[0].lower()
folder_path = os.path.join("videos", folder_name)
thumb_path = os.path.join(folder_path, youtube_id + ".jpg")
return thumb_path
def delete_vid_thumb(self, youtube_id):
"""delete video thumbnail if exists"""
thumb_path = self.vid_thumb_path(youtube_id)
to_delete = os.path.join(self.CACHE_DIR, thumb_path)
if os.path.exists(to_delete):
os.remove(to_delete)
def delete_chan_thumb(self, channel_id):
"""delete all artwork of channel"""
thumb = os.path.join(self.CHANNEL_DIR, channel_id + "_thumb.jpg")
banner = os.path.join(self.CHANNEL_DIR, channel_id + "_banner.jpg")
if os.path.exists(thumb):
os.remove(thumb)
if os.path.exists(banner):
os.remove(banner)
def cleanup_downloaded(self):
"""find downloaded thumbnails without video indexed"""
all_thumbs = self.get_all_thumbs()
all_indexed = self.get_needed_thumbs()
all_needed_thumbs = [i[0] + ".jpg" for i in all_indexed]
for thumb in all_thumbs:
if thumb not in all_needed_thumbs:
# cleanup
youtube_id = thumb.rstrip(".jpg")
self.delete_vid_thumb(youtube_id)
def get_thumb_list(self):
"""get list of mediafiles and matching thumbnails"""
all_indexed = download.PendingList().get_all_indexed()
video_list = []
for video in all_indexed:
youtube_id = video["_source"]["youtube_id"]
media_url = os.path.join(
self.MEDIA_DIR, video["_source"]["media_url"]
)
thumb_path = os.path.join(
self.CACHE_DIR, self.vid_thumb_path(youtube_id)
)
video_list.append(
{
"media_url": media_url,
"thumb_path": thumb_path,
}
)
return video_list
@staticmethod
def write_all_thumbs(video_list):
"""rewrite the thumbnail into media file"""
counter = 1
for video in video_list:
# loop through all videos
media_url = video["media_url"]
thumb_path = video["thumb_path"]
mutagen_vid = MP4(media_url)
with open(thumb_path, "rb") as f:
mutagen_vid["covr"] = [
MP4Cover(f.read(), imageformat=MP4Cover.FORMAT_JPEG)
]
mutagen_vid.save()
if counter % 50 == 0:
print(f"thumbnail write progress {counter}/{len(video_list)}")
counter = counter + 1
def validate_thumbnails():
"""check if all thumbnails are there and organized correctly"""
handler = ThumbManager()
thumbs_to_download = handler.get_needed_thumbs(missing_only=True)
handler.download_vid(thumbs_to_download)
missing_channels = handler.get_missing_channels()
handler.download_chan(missing_channels)
handler.cleanup_downloaded()

View File

@@ -9,16 +9,25 @@ import os
from celery import Celery, shared_task
from home.src.config import AppConfig
from home.src.download import ChannelSubscription, PendingList, VideoDownloader
from home.src.helper import get_lock
from home.src.helper import RedisArchivist, RedisQueue, UrlListParser
from home.src.index_management import backup_all_indexes, restore_from_backup
from home.src.reindex import ManualImport, reindex_old_documents
from home.src.reindex import (
ManualImport,
reindex_old_documents,
scan_filesystem,
)
from home.src.thumbnails import ThumbManager
CONFIG = AppConfig().config
REDIS_HOST = CONFIG["application"]["REDIS_HOST"]
REDIS_HOST = os.environ.get("REDIS_HOST")
REDIS_PORT = os.environ.get("REDIS_PORT")
if not REDIS_PORT:
REDIS_PORT = 6379
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "home.settings")
app = Celery("tasks", broker="redis://" + REDIS_HOST)
app.config_from_object("django.conf:settings", namespace="CELERY")
app = Celery("tasks", broker=f"redis://{REDIS_HOST}:{REDIS_PORT}")
app.config_from_object("django.conf:settings", namespace="ta:")
app.autodiscover_tasks()
@@ -29,7 +38,8 @@ def update_subscribed():
missing_videos = channel_handler.find_missing()
if missing_videos:
pending_handler = PendingList()
pending_handler.add_to_pending(missing_videos)
all_videos_added = pending_handler.add_to_pending(missing_videos)
ThumbManager().download_vid(all_videos_added)
# check if reindex is needed
check_reindex.delay()
@@ -37,20 +47,44 @@ def update_subscribed():
@shared_task
def download_pending():
"""download latest pending videos"""
pending_handler = PendingList()
pending_vids = pending_handler.get_all_pending()[0]
to_download = [i["youtube_id"] for i in pending_vids]
to_download.reverse()
if to_download:
download_handler = VideoDownloader(to_download)
download_handler.download_list()
have_lock = False
my_lock = RedisArchivist().get_lock("downloading")
try:
have_lock = my_lock.acquire(blocking=False)
if have_lock:
downloader = VideoDownloader()
downloader.add_pending()
downloader.run_queue()
else:
print("Did not acquire download lock.")
finally:
if have_lock:
my_lock.release()
@shared_task
def download_single(youtube_id):
"""start download single video now"""
download_handler = VideoDownloader([youtube_id])
download_handler.download_list()
queue = RedisQueue("dl_queue")
queue.add_priority(youtube_id)
print("Added to queue with priority: " + youtube_id)
# start queue if needed
have_lock = False
my_lock = RedisArchivist().get_lock("downloading")
try:
have_lock = my_lock.acquire(blocking=False)
if have_lock:
VideoDownloader().run_queue()
else:
print("Download queue already running.")
finally:
# release if only single run
if have_lock and not queue.get_next():
my_lock.release()
@shared_task
@@ -58,7 +92,8 @@ def extrac_dl(youtube_ids):
"""parse list passed and add to pending"""
pending_handler = PendingList()
missing_videos = pending_handler.parse_url_list(youtube_ids)
pending_handler.add_to_pending(missing_videos)
all_videos_added = pending_handler.add_to_pending(missing_videos)
ThumbManager().download_vid(all_videos_added)
@shared_task
@@ -70,17 +105,17 @@ def check_reindex():
@shared_task
def run_manual_import():
"""called from settings page, to go through import folder"""
print("starting media file import")
have_lock = False
my_lock = get_lock("manual_import")
my_lock = RedisArchivist().get_lock("manual_import")
try:
have_lock = my_lock.acquire(blocking=False)
if have_lock:
import_handler = ManualImport()
if import_handler.identified:
import_handler.process_import()
all_videos_added = import_handler.process_import()
ThumbManager().download_vid(all_videos_added)
else:
print("Did not acquire lock form import.")
@@ -101,3 +136,65 @@ def run_restore_backup():
"""called from settings page, dump backup to zip file"""
restore_from_backup()
print("index restore finished")
def kill_dl(task_id):
"""kill download worker task by ID"""
if task_id:
app.control.revoke(task_id, terminate=True)
_ = RedisArchivist().del_message("dl_queue_id")
RedisQueue("dl_queue").clear()
# clear cache
cache_dir = os.path.join(CONFIG["application"]["cache_dir"], "download")
for cached in os.listdir(cache_dir):
to_delete = os.path.join(cache_dir, cached)
os.remove(to_delete)
# notify
mess_dict = {
"status": "downloading",
"level": "error",
"title": "Brutally killing download queue",
"message": "",
}
RedisArchivist().set_message("progress:download", mess_dict)
@shared_task
def rescan_filesystem():
"""check the media folder for mismatches"""
scan_filesystem()
@shared_task
def re_sync_thumbs():
"""sync thumbnails to mediafiles"""
handler = ThumbManager()
video_list = handler.get_thumb_list()
handler.write_all_thumbs(video_list)
@shared_task
def subscribe_to(url_str):
"""take a list of urls to subscribe to"""
youtube_ids = UrlListParser(url_str).process_list()
for youtube_id in youtube_ids:
if youtube_id["type"] == "video":
to_sub = youtube_id["url"]
vid_details = PendingList().get_youtube_details(to_sub)
channel_id_sub = vid_details["channel_id"]
elif youtube_id["type"] == "channel":
channel_id_sub = youtube_id["url"]
else:
raise ValueError("failed to subscribe to: " + youtube_id)
ChannelSubscription().change_subscribe(
channel_id_sub, channel_subscribed=True
)
print("subscribed to: " + channel_id_sub)
# notify
RedisArchivist().set_message(
"progress:subscribe", {"status": "subscribing"}
)

View File

@@ -5,35 +5,13 @@
<h1>About The Tube Archivist</h1>
</div>
<div class="about-section">
<h2>Getting started</h2>
<ol>
<li>Before adding some videos to the download queue, take a look at the download format settings on your <a href="{% url 'settings' %}#format">settings</a> page and set your desired download quality.</li>
<li>While at the settings page also set the value for the Subscriptions <a href="{% url 'settings' %}#subscriptions">page size</a>. This will define the max amount of videos that will get added once you run <i>Rescan Subscriptions</i>. By default, yt-dlp uses a 50 video page size. Any value higher than that will significantly slow down the scanning process.</li>
<li>Subscribe to some of your favorite YouTube channels in the <a href="{% url 'channel' %}">channel</a> page. To subscribe to a channel you have a few options:
<ul>
<li>Enter the YouTube channel ID, a 25 character alphanumeric string. For example <span class="settings-current">UCBa659QWEk1AI4Tg--mrJ2A</span></li>
<li>Enter the URL to the channel page on YouTube. For example <span class="settings-current">https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A</span></li>
<li>Enter the video URL for any video and let <i>Tube Archivist</i> extract the channel ID for you. For example <span class="settings-current">https://www.youtube.com/watch?v=2tdiKTSdE9Y</span></li>
</ul>
</li>
<li>After that head over to the <a href="{% url 'downloads' %}">Downloads</a> Page. Once you click on the <i>Rescan Subscriptions</i> button, <b>Tube Archivist</b> will go through all your subscribed channels and look for new videos. Initially these are the latest videos depending on the page size as described above.</li>
<li>Click on <span class="settings-current">ignore</span> for any video you don't want to download or on <span class="settings-current">Download now</span> for any video you want to start downloading now independently from the queue.</li>
<li>Click on the <span class="settings-current">Download queue</span> button to start downloading. </b>Tube Archivist</b> will then start the queue from the bottom and download as many videos as defined in <a href="{% url 'settings' %}#downloads">download limit</a>. There is currently no good way to cancel the download process.</li>
</ol>
<h2>Useful Links</h2>
<p>This project is in active and constant development, take a look at the <a href="https://github.com/bbilly1/tubearchivist#roadmap" target="_blank">roadmap</a> for a overview.</p>
<p>For any questions on what a button or a function does, You can find the up-to-date user documentation on <a href="https://github.com/bbilly1/tubearchivist/wiki" target="_blank">Github</a>.</p>
<p>All contributions are welcome: Open an <a href="https://github.com/bbilly1/tubearchivist/issues" target="_blank">issue</a> for any bugs and errors, start a <a href="https://github.com/bbilly1/tubearchivist/discussions" target="_blank">discussion</a> for anything that will require a more indepth look. The <a href="https://github.com/bbilly1/tubearchivist/blob/master/CONTRIBUTING.md" target="_blank">contributing</a> page is a good place to get started.</p>
</div>
<div class="about-section">
<h2>Additional</h2>
<ul>
<li>You can manually add videos to the queue on the <a href="{% url 'downloads' %}">download page</a> by clicking on <i>Add to download queue</i> and then entering a list of videos. Add one link per line. You have a few options:
<ul>
<li>Add a link to a YouTube video. For example <span class="settings-current">https://www.youtube.com/watch?v=2tdiKTSdE9Y</span>.</li>
<li>Add a YouTube video ID. For example <span class="settings-current">2tdiKTSdE9Y</span>.</li>
<li>Add a link to a YouTube video by providing the shortened URL, for example <span class="settings-current">https://youtu.be/2tdiKTSdE9Y</span>.</li>
<li>Add a Channel ID or Channel URL to add every available video to the download queue. This will ignore the page size as described above. You can still ignore selected videos before starting the download.</li>
<li>Add a playlist ID or URL to add every available video in the list to the download queue, for example <span class="settings-current">https://www.youtube.com/playlist?list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha</span> or <span class="settings-current">PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha</span>. Note that when you add a link to a video in a playlist, <b>Tube Archivist</b> assumes you want to download only the specific video and not the whole playlist, for example <span class="settings-current">https://www.youtube.com/watch?v=CINVwWHlzTY&list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha</span> will only add one video <span class="settings-current">CINVwWHlzTY</span> to the queue.</li>
</ul>
</li>
<li>As you watch videos, mark them as watched by clicking on the unseen icon <span class="about-icon"><img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" class="unseen-icon"></span>. Once marked as watched, the icon will change to <span class="about-icon"><img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" class="seen-icon"></span>.</li>
</ul>
<h2>Donate</h2>
<p>Here are <a href="https://github.com/bbilly1/tubearchivist#donate" target="_blank">some links</a>, if you want to buy the developer a coffee. Thank you for your support!</p>
</div>
{% endblock content %}

View File

@@ -45,10 +45,13 @@
</div>
<div class="nav-icons">
<a href="{% url 'about' %}">
<img src="{% static 'img/icon-help.svg' %}" alt="help-icon">
<img src="{% static 'img/icon-help.svg' %}" alt="help-icon" title="About">
</a>
<a href="{% url 'settings' %}">
<img src="{% static 'img/icon-gear.svg' %}" alt="gear-icon">
<img src="{% static 'img/icon-gear.svg' %}" alt="gear-icon" title="Settings">
</a>
<a href="{% url 'logout' %}">
<img src="{% static 'img/icon-exit.svg' %}" alt="exit-icon" title="Logout">
</a>
</div>
</div>
@@ -96,7 +99,7 @@
</div>
<div class="footer">
<div class="boxed-content">
<span>© 2021 The Tube Archivist v0.0.3 | <a href="https://github.com/bbilly1/tubearchivist" target="_blank">Github</a> | <a href="https://hub.docker.com/r/bbilly1/tubearchivist" target="_blank">Docker Hub</a></span>
<span>© 2021 The Tube Archivist v0.0.7 | <a href="https://github.com/bbilly1/tubearchivist" target="_blank">Github</a> | <a href="https://hub.docker.com/r/bbilly1/tubearchivist" target="_blank">Docker Hub</a></span>
</div>
</div>
</body>

View File

@@ -7,50 +7,63 @@
</div>
<div class="info-box info-box-2">
<div class="icon-text">
<img id="add-icon" onclick="showForm()" src="{% static 'img/icon-add.svg' %}" alt="add-icon">
<p>Subscribe to Channels</p>
<div class="show-form">
<form id="hidden-form" action="/channel/" method="post">
{% csrf_token %}
<textarea rows="3" placeholder="Input channel ID, channel URL or Video of a channel" id="subscribe" name="subscribe"></textarea>
<button type="submit">Subscribe</button>
</form>
</div>
{% if running == "subscribing" %}
<p>Subscribing in progress, refresh.</p>
{% else %}
<img id="add-icon" onclick="showForm()" src="{% static 'img/icon-add.svg' %}" alt="add-icon">
<p>Subscribe to Channels</p>
<div class="show-form">
<form id="hidden-form" action="/channel/" method="post">
{% csrf_token %}
{{ subscribe_form }}
<button type="submit">Subscribe</button>
</form>
</div>
{% endif %}
</div>
<div class="search-form icon-text">
<div class="search-icon">
<img src="{% static 'img/icon-search.svg' %}" alt="search-icon" onclick="showSearch()">
<p>Search Channels</p>
<p>Search your Channels</p>
</div>
<form onSubmit="return channelRedirect();" id="search-box">
{% csrf_token %}
<input name="videoSearch" list="resultBox" type="text" id="searchInput" autocomplete="off" oninput="searchChannels(this.value)">
{{ search_form }}
<datalist id="resultBox">
</datalist>
</form>
</div>
</div>
<div class="padding-box">
<h2>Total matching channels: {{ max_hits }}</h2>
<span>Change show / hide subscribed only </span><span class="settings-current">{{ show_subed_only }}</span>
<select name="watched" id="watched" onchange="showSubscribedOnly(this.value)">
<option value="" disabled selected> -- change -- </option>
<option value="0">show all channels</option>
<option value="1">show subscribed channels only</option>
</select>
<div class="view-controls">
<div class="toggle">
<span>Show only subscribed channels:</span>
<div class="toggleBox">
<input
id="show_subed_only" onclick="toggleCheckbox(this)" type="checkbox"
{% if show_subed_only %}
checked
{% endif %}
>
<label for="" class="onbtn">On</label>
<label for="" class="ofbtn">Off</label>
</div>
</div>
<div class="view-icons">
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="channel" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="channel" data-value="list" alt="list view">
</div>
</div>
<div>
<h2>Total matching channels: {{ max_hits }}</h2>
<div class="channel-list {{ view_style }}">
{% if channels %}
{% for channel in channels %}
<div class="channel-item">
{% if channel.source.channel_banner_url %}
<div class="channel-banner">
<a href="{% url 'channel_id' channel.source.channel_id %}">
<img src="/cache/channels/{{ channel.source.channel_id }}_banner.jpg" alt="{{ channel.source.channel_id }}-banner">
</a>
</div>
{% endif %}
<div class="info-box info-box-2">
<div class="channel-item {{ view_style }}">
<div class="channel-banner {{ view_style }}">
<a href="{% url 'channel_id' channel.source.channel_id %}">
<img src="/cache/channels/{{ channel.source.channel_id }}_banner.jpg" alt="{{ channel.source.channel_id }}-banner">
</a>
</div>
<div class="info-box info-box-2 {{ view_style }}">
<div class="info-box-item">
<div class="round-img">
<a href="{% url 'channel_id' channel.source.channel_id %}">

View File

@@ -6,11 +6,9 @@
<h1>Channel: {{ channel_info.channel_name }}</h1>
</div>
<div class="channel-banner">
{% if channel_info.channel_banner_url %}
<a href="/channel/{{ channel_info.channel_id }}/"><img src="/cache/channels/{{ channel_info.channel_id }}_banner.jpg" alt="channel_banner"></a>
{% endif %}
<a href="/channel/{{ channel_info.channel_id }}/"><img src="/cache/channels/{{ channel_info.channel_id }}_banner.jpg" alt="channel_banner"></a>
</div>
<div class="info-box info-box-3 padding-box">
<div class="info-box info-box-3">
<div class="info-box-item">
<div class="round-img">
<a href="{% url 'channel_id' channel_info.channel_id %}">
@@ -36,22 +34,26 @@
false
{% endif %}
</p>
<p>Channel id: {{ channel_info.channel_id }}</p>
<button onclick="deleteConfirm()" id="delete-item">Delete Channel</button>
<div class="delete-confirm" id="delete-button">
<span>Delete {{ channel_info.channel_name }} including all videos? </span><button class="danger-button" onclick="deleteChannel(this)" data-id="{{ channel_info.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
</div>
</div>
</div>
{% if videos %}
<div class="info-box-item">
<div>
{% if channel_info.channel_views >= 1000000 %}
<p>Channel views: {{ channel_info.channel_views|intword }}</p>
{% else %}
{% elif channel_info.channel_views > 0 %}
<p>Channel views: {{ channel_info.channel_views|intcomma }}</p>
{% endif %}
<p>Total Videos archived: {{ max_hits }}</p>
<p>Watched: <button title="Mark all videos from {{ channel_info.channel_name }} as watched" type="button" id="{{ channel_info.channel_id }}" onclick="isWatched(this.id)">Mark as watched</button></p>
<p>Channel id: {{ channel_info.channel_id }}</p>
{% if max_hits %}
<p>Total Videos archived: {{ max_hits }}</p>
<p>Watched: <button title="Mark all videos from {{ channel_info.channel_name }} as watched" type="button" id="{{ channel_info.channel_id }}" onclick="isWatched(this.id)">Mark as watched</button></p>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if channel_info.channel_description %}
<div class="info-box-item description-box">
@@ -62,25 +64,66 @@
</div>
{% endif %}
<div id="player" class="video-player"></div>
<h2>Videos</h2>
<div class="video-list">
<div class="sort">
<p>Sort by <span class="settings-current">{{ sort_by }}</span>
<select name="sort" id="sort" onchange="sortChange(this.value)">
<option value="" disabled selected> -- change sort by -- </option>
<option value="published">date published</option>
<option value="downloaded">date downloaded</option>
<option value="views">views</option>
<option value="likes">likes</option>
</select>
<select name="sord-order" id="sort-order" onchange="sortChange(this.value)">
{% if sort_order == "asc" %}
<option value="asc" selected>asc</option>
{% else %}
<option value="asc">asc</option>
{% endif %}
{% if sort_order == "desc" %}
<option value="desc" selected>desc</option>
{% else %}
<option value="desc">desc</option>
{% endif %}
</select>
</p>
</div>
<div class="view-controls">
<div class="toggle">
<span>Hide watched videos:</span>
<div class="toggleBox">
<input
id="hide_watched" onclick="toggleCheckbox(this)" type="checkbox"
{% if hide_watched %}
checked
{% endif %}
>
<label for="" class="onbtn">On</label>
<label for="" class="ofbtn">Off</label>
</div>
</div>
<div class="view-icons">
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view">
</div>
</div>
<div class="video-list {{ view_style }}">
{% if videos %}
{% for video in videos %}
<div class="video-item">
<a href="#player" data-src="/media/{{ video.source.media_url }}" data-thumb="/cache/videos/{{ video.source.youtube_id }}.jpg" data-title="{{ video.source.title }}" data-channel="{{ video.source.channel.channel_name }}" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap">
<div class="video-item {{ view_style }}">
<a href="#player" data-src="/media/{{ video.source.media_url }}" data-thumb="/cache/{{ video.source.vid_thumb_url }}" data-title="{{ video.source.title }}" data-channel="{{ video.source.channel.channel_name }}" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="/cache/videos/{{ video.source.youtube_id }}.jpg" alt="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb">
</div>
<div class="video-play">
<img src="{% static 'img/icon-play.svg' %}" alt="play-icon">
</div>
</div>
</a>
<div class="video-desc">
<div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.source.youtube_id }}" class="seen-icon">
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.source.youtube_id }}" onclick="isUnwatched(this.id)" class="seen-icon" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" id="{{ video.source.youtube_id }}" onclick="isWatched(this.id)" class="unseen-icon" title="Mark as watched.">
{% endif %}

View File

@@ -5,14 +5,15 @@
<h1>Downloads</h1>
</div>
<div id="downloadMessage"></div>
<div class="info-box info-box-3 padding-box">
<div id="downloadControl"></div>
<div class="info-box info-box-3">
<div class="icon-text">
<img id="rescan-icon" onclick="rescanPending()" src="{% static 'img/icon-rescan.svg' %}" alt="rescan-icon">
<p>Rescan subscriptions</p>
</div>
<div class="icon-text">
<img id="download-icon" onclick="dlPending()" src="{% static 'img/icon-download.svg' %}" alt="download-icon">
<p>Download queue</p>
<p>Start download</p>
</div>
<div class="icon-text">
<img id="add-icon" onclick="showForm()" src="{% static 'img/icon-add.svg' %}" alt="add-icon">
@@ -20,22 +21,51 @@
<div class="show-form">
<form id='hidden-form' action="/downloads/" method="post">
{% csrf_token %}
<textarea rows="4" placeholder="Enter Video Urls or IDs here..." id="vid-url" name="vid-url"></textarea>
<!-- <textarea rows="4" placeholder="Enter Video Urls or IDs here..." id="vid-url" name="vid-url"></textarea> -->
{{ add_form }}
<button type="submit">Add to download queue</button>
</form>
</div>
</div>
</div>
<h2>Download queue</h2>
<div>
{% if pending %}
<h3>Total pending downloads: {{ max_hits }}</h3>
{% for video in pending %}
<div class="dl-item" id="dl-{{ video.youtube_id }}">
<div class="dl-thumb">
<img src="{{ video.vid_thumb_url }}" alt="video_thumb">
<div class="view-controls">
<div class="toggle">
<span>Show only ignored videos:</span>
<div class="toggleBox">
<input
id="show_ignored_only" onclick="toggleCheckbox(this)" type="checkbox"
{% if show_ignored_only %}
checked
{% endif %}
>
<label for="" class="onbtn">On</label>
<label for="" class="ofbtn">Off</label>
</div>
</div>
<div class="view-icons">
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="downloads" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="downloads" data-value="list" alt="list view">
</div>
</div>
{% if show_ignored_only %}
<h2>Ignored from download</h2>
{% else %}
<h2>Download queue</h2>
{% endif %}
<h3>Total videos: {{ max_hits }}</h3>
<div class="dl-list {{ view_style }}">
{% if all_video_hits %}
{% for video in all_video_hits %}
<div class="dl-item {{ view_style }}" id="dl-{{ video.youtube_id }}">
<div class="dl-thumb {{ view_style }}">
<img src="/cache/{{ video.vid_thumb_url }}" alt="video_thumb">
{% if show_ignored_only %}
<span>ignored</span>
{% else %}
<span>queued</span>
{% endif %}
</div>
<div class="dl-desc">
<div class="dl-desc {{ view_style }}">
<h3>{{ video.title }}</h3>
{% if video.channel_indexed %}
<a href="{% url 'channel_id' video.channel_id %}">{{ video.channel_name }}</a>
@@ -43,13 +73,16 @@
<span>{{ video.channel_name }}</span>
{% endif %}
<p>Published: {{ video.published }} | Duration: {{ video.duration }} | {{ video.youtube_id }}</p>
<button data-id="{{ video.youtube_id }}" onclick="toIgnore(this)">Ignore</button>
<button id="{{ video.youtube_id }}" data-id="{{ video.youtube_id }}" onclick="downloadNow(this)">Download now</button>
{% if show_ignored_only %}
<button data-id="{{ video.youtube_id }}" onclick="forgetIgnore(this)">Forget</button>
<button data-id="{{ video.youtube_id }}" onclick="addSingle(this)">Add to queue</button>
{% else %}
<button data-id="{{ video.youtube_id }}" onclick="toIgnore(this)">Ignore</button>
<button id="{{ video.youtube_id }}" data-id="{{ video.youtube_id }}" onclick="downloadNow(this)">Download now</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<h3>No pending downloads</h3>
{% endif %}
</div>
<script type="text/javascript" src="{% static 'progress.js' %}"></script>

View File

@@ -6,18 +6,25 @@
</div>
<div class="info-box info-box-2">
<div class="sort">
<p>Sort order from <span class="settings-current">{{ sortorder }}</span>
<p>Sort by <span class="settings-current">{{ sort_by }}</span>
<select name="sort" id="sort" onchange="sortChange(this.value)">
<option value="" disabled selected> -- change sort order -- </option>
<option value="" disabled selected> -- change sort by -- </option>
<option value="published">date published</option>
<option value="downloaded">date downloaded</option>
<option value="views">views</option>
<option value="likes">likes</option>
</select>
</p>
<p>Hide watched videos <span class="settings-current">{{ hide_watched }}</span>
<select name="watched" id="watched" onchange="hideWatched(this.value)">
<option value="" disabled selected> -- change hide watched -- </option>
<option value="0">show watched videos</option>
<option value="1">hide watched videos</option>
<select name="sord-order" id="sort-order" onchange="sortChange(this.value)">
{% if sort_order == "asc" %}
<option value="asc" selected>asc</option>
{% else %}
<option value="asc">asc</option>
{% endif %}
{% if sort_order == "desc" %}
<option value="desc" selected>desc</option>
{% else %}
<option value="desc">desc</option>
{% endif %}
</select>
</p>
</div>
@@ -27,29 +34,48 @@
</div>
<form action="/" method="POST" id="search-box">
{% csrf_token %}
<input name="videoSearch" list="resultBox" type="text" id="searchInput" autocomplete="off">
{{ search_form }}
</form>
</div>
</div>
<div class="view-controls">
<div class="toggle">
<span>Hide watched videos:</span>
<div class="toggleBox">
<input
id="hide_watched" onclick="toggleCheckbox(this)" type="checkbox"
{% if hide_watched %}
checked
{% endif %}
>
<label for="" class="onbtn">On</label>
<label for="" class="ofbtn">Off</label>
</div>
</div>
<div class="view-icons">
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view">
</div>
</div>
<div id="player" class="video-player"></div>
<div class="video-list">
<div class="video-list {{ view_style }}">
{% if videos %}
{% for video in videos %}
<div class="video-item">
<a href="#player" data-src="/media/{{ video.source.media_url }}" data-thumb="/cache/videos/{{ video.source.youtube_id }}.jpg" data-title="{{ video.source.title }}" data-channel="{{ video.source.channel.channel_name }}" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap">
<div class="video-item {{ view_style }}">
<a href="#player" data-src="/media/{{ video.source.media_url }}" data-thumb="/cache/{{ video.source.vid_thumb_url }}" data-title="{{ video.source.title }}" data-channel="{{ video.source.channel.channel_name }}" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="/cache/videos/{{ video.source.youtube_id }}.jpg" alt="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb">
</div>
<div class="video-play">
<img src="{% static 'img/icon-play.svg' %}" alt="play-icon">
</div>
</div>
</a>
<div class="video-desc">
<div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.source.youtube_id }}" class="seen-icon">
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.source.youtube_id }}" onclick="isUnwatched(this.id)" class="seen-icon" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" id="{{ video.source.youtube_id }}" onclick="isWatched(this.id)" class="unseen-icon" title="Mark as watched.">
{% endif %}

View File

@@ -0,0 +1,46 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TA | Welcome</title>
<link rel="stylesheet" type="text/css" href="{% static 'css/style.css' %}">
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}"/>
{% if colors == "dark" %}
<link rel="stylesheet" href="{% static 'css/dark.css' %}">
{% else %}
<link rel="stylesheet" href="{% static 'css/light.css' %}">
{% endif %}
</head>
<body>
<div class="boxed-content login-page">
{% if colors == 'dark' %}
<img src="{% static 'img/logo-tube-archivist-dark.png' %}" alt="tube-archivist-logo">
{% endif %}
{% if colors == 'light' %}
<img src="{% static 'img/logo-tube-archivist-light.png' %}" alt="tube-archivist-banner">
{% endif %}
<h1>Tube Archivist</h1>
<h2>Your Self Hosted YouTube Media Server</h2>
{% if form_error %}
<p class="danger-zone">Failed to login.</p>
{% endif %}
<form action="/login/" method="POST" name="login">
{% csrf_token %}
{% for field in form %}
{{ field }}<br>
{% endfor %}
<input type="hidden" name="next" value="{{ request.GET.next }}" />
<button type="submit">Login</button>
</form>
<p class="login-links"><span><a href="https://github.com/bbilly1/tubearchivist" target="_blank">Github</a></span> <span><a href="https://github.com/bbilly1/tubearchivist#donate" target="_blank">Donate</a></span></p>
</div>
<div class="footer-colors">
<div class="col-1"></div>
<div class="col-2"></div>
<div class="col-3"></div>
</div>
</body>
</html>

View File

@@ -1,63 +1,39 @@
{% extends "home/base.html" %}
{% block content %}
<div class="title-bar">
<h1>Settings</h1>
<h1>User Configurations</h1>
</div>
<form action="/settings/" method="POST" name="settings-update">
<form action="/settings/" method="POST" name="user-update">
{% csrf_token %}
<div class="settings-group">
<h2>Color scheme</h2>
<div class="settings-item">
<p>Current color scheme: <span class="settings-current">{{ config.application.colors }}</span></p>
<i>Select your preferred color scheme between dark and light mode.</i><br>
<select name="application.colors" id="application.colors">
<option value="" disabled selected> -- change color scheme -- </option>
<option value="dark">dark mode</option>
<option value="light">light mode</option>
</select>
{{ user_form.colors }}
</div>
</div>
<div class="settings-group">
<h2>Archive View</h2>
<div class="settings-item">
<p>Current default Sort: <span class="settings-current">{{ config.archive.sort }}</span></p>
<i>Change how the home view and channels view sorts by defaults.</i><br>
<select name="archive.sort" id="archive.sort"">
<option value="" disabled selected> -- change sort order -- </option>
<option value="published">date published</option>
<option value="downloaded">date downloaded</option>
</select>
</div>
<div class="settings-item">
<p>Current default hide watched: <span class="settings-current">{{ config.archive.hide_watched }}</span></p>
<i>Show or hide watched videos by default.</i><br>
<select name="archive.hide_watched" id="archive.hide_watched"">
<option value="" disabled selected> -- change visibility -- </option>
<option value="0">show watched</option>
<option value="1">hide watched</option>
</select>
</div>
<div class="settings-item">
<p>Current default show subscribed channels only: <span class="settings-current">{{ config.archive.show_subed_only }}</span></p>
<i>Show subscribed channels only by default.</i><br>
<select name="archive.show_subed_only" id="archive.show_subed_only"">
<option value="" disabled selected> -- change visibility -- </option>
<option value="0">show subscribed only</option>
<option value="1">hide not subscribed</option>
</select>
</div>
<div class="settings-item">
<p>Current page size: <span class="settings-current">{{ config.archive.page_size }}</span></p>
<i>Result of videos showing in archive page</i><br>
<input type="number" name="archive.page_size" id="archive.page_size">
{{ user_form.page_size }}
</div>
</div>
<button type="submit" name="user-settings">Update User Configurations</button>
</form>
<div class="title-bar">
<h1>Application Configurations</h1>
</div>
<form action="/settings/" method="POST" name="application-update">
{% csrf_token %}
<div class="settings-group">
<h2 id="subscriptions">Subscriptions</h2>
<div class="settings-item">
<p>Current channel page size: <span class="settings-current">{{ config.subscriptions.channel_size }}</span></p>
<i>Recent videos to check on check pending, max recommended 50.</i><br>
<input type="number" name="subscriptions.channel_size" id="subscriptions.channel_size">
{{ app_form.subscriptions_channel_size }}
</div>
<div class="settings-item">
<p>Auto scan subscribed channels:</p>
@@ -73,17 +49,22 @@
<div class="settings-item">
<p>Current download limit: <span class="settings-current">{{ config.downloads.limit_count }}</span></p>
<i>Limit the number of videos getting downloaded on every run. 0 (zero) to deactivate.</i><br>
<input type="number" name="downloads.limit_count" id="downloads.limit_count">
{{ app_form.downloads_limit_count }}
</div>
<div class="settings-item">
<p>Current download speed limit: <span class="settings-current">{{ config.downloads.limit_speed }}</span></p>
<i>Limit download speed. 0 (zero) to deactivate.</i><br>
<input type="number" name="downloads.limit_speed" id="downloads.limit_speed"><span>KB/sec</span>
{{ app_form.downloads_limit_speed }}
</div>
<div class="settings-item">
<p>Current throttled rate limit: <span class="settings-current">{{ config.downloads.throttledratelimit }}</span></p>
<i>Assume the download is being throttled below this speed and restart. 0 (zero) to deactivate, e.g. 100KB/sec</i><br>
{{ app_form.downloads_throttledratelimit }}
</div>
<div class="settings-item">
<p>Current scraping sleep interval: <span class="settings-current">{{ config.downloads.sleep_interval }}</p>
<i>Seconds to sleep between calls to YouTube. Might be necessary to avoid throttling. Recommended 3.</i><br>
<input type="number" name="downloads.sleep_interval" id="downloads.sleep_interval">
{{ app_form.downloads_sleep_interval }}
</div>
<div class="settings-item">
<p>External downloader:</p>
@@ -100,34 +81,43 @@
<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<=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">0</span>: deactivate and download the best quality possible as decided by yt-dlp.</li>
</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>
<input type="text" name="downloads.format" id="downloads.format">
{{ app_form.downloads_format }}
<br>
</div>
<div class="settings-item">
<p>Current metadata embed setting: <span class="settings-current">{{ config.downloads.add_metadata }}</span></p>
<i>Metadata is not embedded into the downloaded files by default.</i><br>
<select name="downloads.add_metadata" id="downloads.add_metadata"">
<option value="" disabled selected> -- change metadata embed -- </option>
<option value="0">don't embed metadata</option>
<option value="1">embed metadata</option>
</select>
{{ app_form.downloads_add_metadata }}
</div>
<div class="settings-item">
<p>Current thumbnail embed setting: <span class="settings-current">{{ config.downloads.add_thumbnail }}</span></p>
<i>Embed thumbnail into the mediafile.</i><br>
{{ app_form.downloads_add_thumbnail }}
</div>
</div>
<button type="submit">Update Settings</button>
<button type="submit" name="application-settings">Update Application Configurations</button>
</form>
<div class="title-bar">
<h1>Actions</h1>
</div>
<div class="settings-group">
<h2>Manual media files import.</h2>
<p>Add files to the <span class="settings-current">cache/import</span> folder. Make sure to follow the instructions on <a href="https://github.com/bbilly1/tubearchivist#import-your-existing-library" target="_blank">Github</a>.</p>
<p>Add files to the <span class="settings-current">cache/import</span> folder. Make sure to follow the instructions in the Github <a href="https://github.com/bbilly1/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p>
<div id="manual-import">
<button onclick="manualImport()">Start import</button>
</div>
</div>
<div class="settings-group">
<h2>Embed thumbnails into media file.</h2>
<p>Set extracted youtube thumbnail as cover art of the media file.</p>
<div id="re-embed">
<button onclick="reEmbed()">Start process</button>
</div>
</div>
<div class="settings-group">
<h2>Backup database</h2>
<p>Export your database to a zip file stored at <span class="settings-current">cache/backup</span>.</p>
@@ -144,7 +134,21 @@
</div>
</div>
<div class="settings-group">
<p>Rescan filesystem.</p>
<i>Coming soon</i>
<h2>Rescan filesystem</h2>
<p><span class="danger-zone">Danger Zone</span>: This will delete the metadata of deleted videos from the filesystem.</p>
<p>Rescan your media folder looking for missing videos and clean up index. More infos on the Github <a href="https://github.com/bbilly1/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p>
<div id="fs-rescan">
<button onclick="fsRescan()">Rescan filesystem</button>
</div>
</div>
{% if request.user.is_superuser %}
<div class="title-bar">
<h1>Users</h1>
</div>
<div class="settings-group">
<h2>User Management</h2>
<p>Access the admin interface for basic user management functionality like adding and deleting users, changing passwords and more.</p>
<a href="/admin/"><button>Admin Interface</button></a>
</div>
{% endif %}
{% endblock content %}

View File

@@ -8,8 +8,8 @@
<div class="video-main">
<video
src="/media/{{ video.media_url }}"
poster="/cache/videos/{{ video.youtube_id }}.jpg" controls preload="false"
type='video/mp4' width="100%">
poster="/cache/{{ video.vid_thumb_url }}" controls preload="false"
type='video/mp4' width="100%" playsinline>
</video>
</div>
<div class="info-box info-box-3 padding-box">
@@ -34,7 +34,7 @@
<p>Last refreshed: {{ video.vid_last_refresh }}</p>
<p class="video-info-watched">Watched:
{% if video.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" class="seen-icon" id="{{ video.youtube_id }}">
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.youtube_id }}" onclick="isUnwatched(this.id)" class="seen-icon" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" id="{{ video.youtube_id }}" onclick="isWatched(this.id)" class="unseen-icon" title="Mark as watched.">
{% endif %}
@@ -45,6 +45,10 @@
<p>Youtube: Deactivated</p>
{% endif %}
<p>Video ID: {{ video.youtube_id }}</p>
<button onclick="deleteConfirm()" id="delete-item">Delete Video</button>
<div class="delete-confirm" id="delete-button">
<span>Are you sure? </span><button class="danger-button" onclick="deleteVideo(this)" data-id="{{ video.youtube_id }}" data-redirect = "{{ video.channel.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
</div>
</div>
</div>
<div class="info-box-item">
@@ -52,7 +56,13 @@
<p>Views: {{ video.stats.view_count|intcomma }}</p>
<p class="thumb-icon like"><img src="{% static 'img/icon-thumb.svg' %}" alt="thumbs-up">: {{ video.stats.like_count|intcomma }}</p>
<p class="thumb-icon dislike"><img src="{% static 'img/icon-thumb.svg' %}" alt="thumbs-down">: {{ video.stats.dislike_count|intcomma }}</p>
<p>Rating: {{ video.stats.average_rating }}</p>
{% if video.stats.average_rating %}
<p class="rating-stars">Rating:
{% for star in video.stats.average_rating %}
<img src="/static/img/icon-star-{{ star }}.svg" alt="{{ star }}">
{% endfor %}
</p>
{% endif %}
</div>
</div>
</div>

View File

@@ -1,3 +0,0 @@
from django.test import TestCase # noqa: F401 - Unused import
# Create your tests here.

View File

View File

@@ -0,0 +1,42 @@
"""All test classes"""
from django.test import TestCase
class URLTests(TestCase):
"""test if all expected URL are there"""
def test_home_view(self):
"""check homepage"""
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
def test_about_view(self):
"""check about page"""
response = self.client.get("/about/")
self.assertEqual(response.status_code, 200)
def test_downloads_view(self):
"""check downloads page"""
response = self.client.get("/downloads/")
self.assertEqual(response.status_code, 200)
def test_channel_view(self):
"""check channel page"""
response = self.client.get("/channel/")
self.assertEqual(response.status_code, 200)
def test_settings_view(self):
"""check settings page"""
response = self.client.get("/settings/")
self.assertEqual(response.status_code, 200)
def test_progress_view(self):
"""check ajax progress endpoint"""
response = self.client.get("/downloads/progress/")
self.assertEqual(response.status_code, 200)
def test_process_view(self):
"""check process ajax endpoint"""
response = self.client.get("/process/")
self.assertEqual(response.status_code, 200)

View File

@@ -1,5 +1,8 @@
""" all home app urls """
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import LogoutView
from django.urls import path
from home.views import (
AboutView,
@@ -7,24 +10,38 @@ from home.views import (
ChannelView,
DownloadView,
HomeView,
LoginView,
SettingsView,
VideoView,
process,
progress,
)
from . import views
urlpatterns = [
path("", HomeView.as_view(), name="home"),
path("", login_required(HomeView.as_view()), name="home"),
path("login/", LoginView.as_view(), name="login"),
path(
"logout/",
LogoutView.as_view(),
{"next_page": settings.LOGOUT_REDIRECT_URL},
name="logout",
),
path("about/", AboutView.as_view(), name="about"),
path("downloads/", DownloadView.as_view(), name="downloads"),
path("settings/", SettingsView.as_view(), name="settings"),
path("process/", views.process, name="process"),
path("downloads/progress", views.progress, name="progress"),
path("channel/", ChannelView.as_view(), name="channel"),
path(
"downloads/", login_required(DownloadView.as_view()), name="downloads"
),
path("settings/", login_required(SettingsView.as_view()), name="settings"),
path("process/", login_required(process), name="process"),
path("downloads/progress/", login_required(progress), name="progress"),
path("channel/", login_required(ChannelView.as_view()), name="channel"),
path(
"channel/<slug:channel_id_detail>/",
ChannelIdView.as_view(),
login_required(ChannelIdView.as_view()),
name="channel_id",
),
path("video/<slug:video_id>/", VideoView.as_view(), name="video"),
path(
"video/<slug:video_id>/",
login_required(VideoView.as_view()),
name="video",
),
]

View File

@@ -8,27 +8,38 @@ import json
import urllib.parse
from time import sleep
from django import forms
from django.contrib.auth import login
from django.contrib.auth.forms import AuthenticationForm
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.utils.http import urlencode
from django.views import View
from home.forms import (
AddToQueueForm,
ApplicationSettingsForm,
ChannelSearchForm,
CustomAuthForm,
SubscribeToChannelForm,
UserSettingsForm,
VideoSearchForm,
)
from home.src.config import AppConfig
from home.src.download import ChannelSubscription, PendingList
from home.src.helper import (
get_dl_message,
get_message,
process_url_list,
set_message,
)
from home.src.index import WatchState
from home.src.helper import RedisArchivist, RedisQueue, UrlListParser
from home.src.index import WatchState, YoutubeChannel, YoutubeVideo
from home.src.searching import Pagination, SearchForm, SearchHandler
from home.tasks import (
download_pending,
download_single,
extrac_dl,
kill_dl,
re_sync_thumbs,
rescan_filesystem,
run_backup,
run_manual_import,
run_restore_backup,
subscribe_to,
update_subscribed,
)
@@ -43,7 +54,8 @@ class HomeView(View):
def get(self, request):
"""return home search results"""
colors, sort_order, hide_watched = self.read_config()
user_id = request.user.id
view_config = self.read_config(user_id)
# handle search
search_get = request.GET.get("search", False)
if search_get:
@@ -52,49 +64,63 @@ class HomeView(View):
search_encoded = False
# define page size
page_get = int(request.GET.get("page", 0))
pagination_handler = Pagination(page_get, search_encoded)
pagination_handler = Pagination(
page_get, user_id, search_get=search_encoded
)
url = self.ES_URL + "/ta_video/_search"
data = self.build_data(
pagination_handler, sort_order, search_get, hide_watched
pagination_handler,
view_config["sort_by"],
view_config["sort_order"],
search_get,
view_config["hide_watched"],
)
search = SearchHandler(url, data)
videos_hits = search.get_data()
max_hits = search.max_hits
pagination_handler.validate(max_hits)
search_form = VideoSearchForm()
context = {
"search_form": search_form,
"videos": videos_hits,
"pagination": pagination_handler.pagination,
"sortorder": sort_order,
"hide_watched": hide_watched,
"colors": colors,
"sort_by": view_config["sort_by"],
"sort_order": view_config["sort_order"],
"hide_watched": view_config["hide_watched"],
"colors": view_config["colors"],
"view_style": view_config["view_style"],
}
return render(request, "home/home.html", context)
@staticmethod
def build_data(pagination_handler, sort_order, search_get, hide_watched):
def build_data(
pagination_handler, sort_by, sort_order, search_get, hide_watched
):
"""build the data dict for the search query"""
page_size = pagination_handler.pagination["page_size"]
page_from = pagination_handler.pagination["page_from"]
# overwrite sort_by to match key
if sort_by == "views":
sort_by = "stats.view_count"
elif sort_by == "likes":
sort_by = "stats.like_count"
elif sort_by == "downloaded":
sort_by = "date_downloaded"
data = {
"size": page_size,
"from": page_from,
"query": {"match_all": {}},
"sort": [
{"published": {"order": "desc"}},
{"date_downloaded": {"order": "desc"}},
],
"sort": [{sort_by: {"order": sort_order}}],
}
# define sort
if sort_order == "downloaded":
del data["sort"][0]
if search_get:
del data["sort"]
if hide_watched:
data["query"] = {"term": {"player.watched": {"value": False}}}
if search_get:
del data["sort"]
query = {
"multi_match": {
"query": search_get,
@@ -108,21 +134,73 @@ class HomeView(View):
return data
@staticmethod
def read_config():
def read_config(user_id):
"""read needed values from redis"""
config_handler = AppConfig().config
colors = config_handler["application"]["colors"]
sort_order = get_message("sort_order")
hide_watched = get_message("hide_watched")
return colors, sort_order, hide_watched
config_handler = AppConfig(user_id)
view_key = f"{user_id}:view:home"
view_style = RedisArchivist().get_message(view_key)["status"]
if not view_style:
view_style = config_handler.config["default_view"]["home"]
sort_by = RedisArchivist().get_message(f"{user_id}:sort_by")["status"]
if not sort_by:
sort_by = config_handler.config["archive"]["sort_by"]
sort_order_key = f"{user_id}:sort_order"
sort_order = RedisArchivist().get_message(sort_order_key)["status"]
if not sort_order:
sort_order = config_handler.config["archive"]["sort_order"]
hide_watched_key = f"{user_id}:hide_watched"
hide_watched = RedisArchivist().get_message(hide_watched_key)["status"]
view_config = {
"colors": config_handler.colors,
"view_style": view_style,
"sort_by": sort_by,
"sort_order": sort_order,
"hide_watched": hide_watched,
}
return view_config
@staticmethod
def post(request):
"""handle post from search form"""
post_data = dict(request.POST)
search_query = post_data["videoSearch"][0]
search_url = "/?" + urlencode({"search": search_query})
return redirect(search_url, permanent=True)
search_form = VideoSearchForm(data=request.POST)
if search_form.is_valid():
search_query = request.POST.get("searchInput")
print(search_query)
search_url = "/?" + urlencode({"search": search_query})
return redirect(search_url, permanent=True)
return redirect("home")
class LoginView(View):
"""resolves to /login/
Greeting and login page
"""
@staticmethod
def get(request):
"""handle get requests"""
failed = bool(request.GET.get("failed"))
colors = AppConfig(request.user.id).colors
form = CustomAuthForm()
context = {"colors": colors, "form": form, "form_error": failed}
return render(request, "home/login.html", context)
@staticmethod
def post(request):
"""handle login post request"""
form = AuthenticationForm(data=request.POST)
if form.is_valid():
next_url = request.POST.get("next") or "home"
user = form.get_user()
login(request, user)
return redirect(next_url)
return redirect("/login?failed=true")
class AboutView(View):
@@ -133,8 +211,7 @@ class AboutView(View):
@staticmethod
def get(request):
"""handle http get"""
config = AppConfig().config
colors = config["application"]["colors"]
colors = AppConfig(request.user.id).colors
context = {"title": "About", "colors": colors}
return render(request, "home/about.html", context)
@@ -146,67 +223,101 @@ class DownloadView(View):
def get(self, request):
"""handle get requests"""
config = AppConfig().config
colors = config["application"]["colors"]
user_id = request.user.id
view_config = self.read_config(user_id)
page_get = int(request.GET.get("page", 0))
pagination_handler = Pagination(page_get)
pagination_handler = Pagination(page_get, user_id)
url = config["application"]["es_url"] + "/ta_download/_search"
data = self.build_data(pagination_handler)
search = SearchHandler(url, data, cache=False)
url = view_config["es_url"] + "/ta_download/_search"
data = self.build_data(
pagination_handler, view_config["show_ignored_only"]
)
search = SearchHandler(url, data)
videos_hits = search.get_data()
max_hits = search.max_hits
if videos_hits:
all_pending = [i["source"] for i in videos_hits]
all_video_hits = [i["source"] for i in videos_hits]
pagination_handler.validate(max_hits)
pagination = pagination_handler.pagination
else:
all_pending = False
all_video_hits = False
pagination = False
add_form = AddToQueueForm()
context = {
"pending": all_pending,
"add_form": add_form,
"all_video_hits": all_video_hits,
"max_hits": max_hits,
"pagination": pagination,
"title": "Downloads",
"colors": colors,
"colors": view_config["colors"],
"show_ignored_only": view_config["show_ignored_only"],
"view_style": view_config["view_style"],
}
return render(request, "home/downloads.html", context)
@staticmethod
def build_data(pagination_handler):
def read_config(user_id):
"""read config vars"""
config_handler = AppConfig(user_id)
view_key = f"{user_id}:view:downloads"
view_style = RedisArchivist().get_message(view_key)["status"]
if not view_style:
view_style = config_handler.config["default_view"]["downloads"]
ignored = RedisArchivist().get_message(f"{user_id}:show_ignored_only")
show_ignored_only = ignored["status"]
es_url = config_handler.config["application"]["es_url"]
view_config = {
"es_url": es_url,
"colors": config_handler.colors,
"view_style": view_style,
"show_ignored_only": show_ignored_only,
}
return view_config
@staticmethod
def build_data(pagination_handler, show_ignored_only):
"""build data dict for search"""
page_size = pagination_handler.pagination["page_size"]
page_from = pagination_handler.pagination["page_from"]
if show_ignored_only:
filter_view = "ignore"
else:
filter_view = "pending"
data = {
"size": page_size,
"from": page_from,
"query": {"term": {"status": {"value": "pending"}}},
"sort": [{"timestamp": {"order": "desc"}}],
"query": {"term": {"status": {"value": filter_view}}},
"sort": [{"timestamp": {"order": "asc"}}],
}
return data
@staticmethod
def post(request):
"""handle post requests"""
download_post = dict(request.POST)
if "vid-url" in download_post.keys():
url_str = download_post["vid-url"]
print("adding to queue")
youtube_ids = process_url_list(url_str)
if not youtube_ids:
to_queue = AddToQueueForm(data=request.POST)
if to_queue.is_valid():
url_str = request.POST.get("vid_url")
print(url_str)
try:
youtube_ids = UrlListParser(url_str).process_list()
except ValueError:
# failed to process
print(url_str)
print(f"failed to parse: {url_str}")
mess_dict = {
"status": "downloading",
"level": "error",
"title": "Failed to extract links.",
"message": "",
"message": "Not a video, channel or playlist ID or URL",
}
set_message("progress:download", mess_dict)
RedisArchivist().set_message("progress:download", mess_dict)
return redirect("downloads")
print(youtube_ids)
@@ -223,26 +334,56 @@ class ChannelIdView(View):
def get(self, request, channel_id_detail):
"""get method"""
es_url, colors = self.read_config()
context = self.get_channel_videos(request, channel_id_detail, es_url)
context.update({"colors": colors})
# es_url, colors, view_style = self.read_config()
view_config = self.read_config(user_id=request.user.id)
context = self.get_channel_videos(
request, channel_id_detail, view_config
)
context.update(view_config)
return render(request, "home/channel_id.html", context)
@staticmethod
def read_config():
def read_config(user_id):
"""read config file"""
config = AppConfig().config
es_url = config["application"]["es_url"]
colors = config["application"]["colors"]
return es_url, colors
config_handler = AppConfig(user_id)
config = config_handler.config
def get_channel_videos(self, request, channel_id_detail, es_url):
view_key = f"{user_id}:view:home"
view_style = RedisArchivist().get_message(view_key)["status"]
if not view_style:
view_style = config_handler.config["default_view"]["home"]
sort_by = RedisArchivist().get_message(f"{user_id}:sort_by")["status"]
if not sort_by:
sort_by = config["archive"]["sort_by"]
sort_order_key = f"{user_id}:sort_order"
sort_order = RedisArchivist().get_message(sort_order_key)["status"]
if not sort_order:
sort_order = config["archive"]["sort_order"]
hide_watched_key = f"{user_id}:hide_watched"
hide_watched = RedisArchivist().get_message(hide_watched_key)["status"]
view_config = {
"colors": config_handler.colors,
"es_url": config["application"]["es_url"],
"view_style": view_style,
"sort_by": sort_by,
"sort_order": sort_order,
"hide_watched": hide_watched,
}
return view_config
def get_channel_videos(self, request, channel_id_detail, view_config):
"""get channel from video index"""
page_get = int(request.GET.get("page", 0))
pagination_handler = Pagination(page_get)
pagination_handler = Pagination(page_get, request.user.id)
# get data
url = es_url + "/ta_video/_search"
data = self.build_data(pagination_handler, channel_id_detail)
url = view_config["es_url"] + "/ta_video/_search"
data = self.build_data(
pagination_handler, channel_id_detail, view_config
)
search = SearchHandler(url, data)
videos_hits = search.get_data()
max_hits = search.max_hits
@@ -254,7 +395,7 @@ class ChannelIdView(View):
else:
# get details from channel index when when no hits
channel_info, channel_name = self.get_channel_info(
channel_id_detail, es_url
channel_id_detail, view_config["es_url"]
)
videos_hits = False
pagination = False
@@ -270,21 +411,41 @@ class ChannelIdView(View):
return context
@staticmethod
def build_data(pagination_handler, channel_id_detail):
def build_data(pagination_handler, channel_id_detail, view_config):
"""build data dict for search"""
page_size = pagination_handler.pagination["page_size"]
page_from = pagination_handler.pagination["page_from"]
sort_by = view_config["sort_by"]
sort_order = view_config["sort_order"]
# overwrite sort_by to match key
if sort_by == "views":
sort_by = "stats.view_count"
elif sort_by == "likes":
sort_by = "stats.like_count"
elif sort_by == "downloaded":
sort_by = "date_downloaded"
data = {
"size": page_size,
"from": page_from,
"size": pagination_handler.pagination["page_size"],
"from": pagination_handler.pagination["page_from"],
"query": {
"term": {"channel.channel_id": {"value": channel_id_detail}}
"bool": {
"must": [
{
"term": {
"channel.channel_id": {
"value": channel_id_detail
}
}
}
]
}
},
"sort": [
{"published": {"order": "desc"}},
{"date_downloaded": {"order": "desc"}},
],
"sort": [{sort_by: {"order": sort_order}}],
}
if view_config["hide_watched"]:
to_append = {"term": {"player.watched": {"value": False}}}
data["query"]["bool"]["must"].append(to_append)
return data
@staticmethod
@@ -307,79 +468,78 @@ class ChannelView(View):
def get(self, request):
"""handle http get requests"""
es_url, colors = self.read_config()
user_id = request.user.id
view_config = self.read_config(user_id=user_id)
page_get = int(request.GET.get("page", 0))
pagination_handler = Pagination(page_get)
page_size = pagination_handler.pagination["page_size"]
page_from = pagination_handler.pagination["page_from"]
pagination_handler = Pagination(page_get, user_id)
# get
url = es_url + "/ta_channel/_search"
url = view_config["es_url"] + "/ta_channel/_search"
data = {
"size": page_size,
"from": page_from,
"size": pagination_handler.pagination["page_size"],
"from": pagination_handler.pagination["page_from"],
"query": {"match_all": {}},
"sort": [{"channel_name.keyword": {"order": "asc"}}],
}
show_subed_only = get_message("show_subed_only")
if show_subed_only:
if view_config["show_subed_only"]:
data["query"] = {"term": {"channel_subscribed": {"value": True}}}
search = SearchHandler(url, data)
channel_hits = search.get_data()
max_hits = search.max_hits
pagination_handler.validate(search.max_hits)
search_form = ChannelSearchForm()
subscribe_form = SubscribeToChannelForm()
context = {
"search_form": search_form,
"subscribe_form": subscribe_form,
"channels": channel_hits,
"max_hits": max_hits,
"max_hits": search.max_hits,
"pagination": pagination_handler.pagination,
"show_subed_only": show_subed_only,
"show_subed_only": view_config["show_subed_only"],
"title": "Channels",
"colors": colors,
"colors": view_config["colors"],
"view_style": view_config["view_style"],
"running": view_config["running"],
}
return render(request, "home/channel.html", context)
@staticmethod
def read_config():
def read_config(user_id):
"""read config file"""
config = AppConfig().config
es_url = config["application"]["es_url"]
colors = config["application"]["colors"]
return es_url, colors
config_handler = AppConfig(user_id)
view_key = f"{user_id}:view:channel"
view_style = RedisArchivist().get_message(view_key)["status"]
running = RedisArchivist().get_message("progress:subscribe")["status"]
if not view_style:
view_style = config_handler.config["default_view"]["channel"]
def post(self, request):
sub_only_key = f"{user_id}:show_subed_only"
show_subed_only = RedisArchivist().get_message(sub_only_key)["status"]
view_config = {
"es_url": config_handler.config["application"]["es_url"],
"view_style": view_style,
"show_subed_only": show_subed_only,
"colors": config_handler.colors,
"running": running,
}
return view_config
@staticmethod
def post(request):
"""handle http post requests"""
subscriptions_post = dict(request.POST)
print(subscriptions_post)
subscriptions_post = dict(request.POST)
if "subscribe" in subscriptions_post.keys():
sub_str = subscriptions_post["subscribe"]
try:
youtube_ids = process_url_list(sub_str)
self.subscribe_to(youtube_ids)
except ValueError:
print("parsing subscribe ids failed!")
print(sub_str)
subscribe_form = SubscribeToChannelForm(data=request.POST)
if subscribe_form.is_valid():
RedisArchivist().set_message(
"progress:subscribe", {"status": "subscribing"}
)
url_str = request.POST.get("subscribe")
print(url_str)
subscribe_to.delay(url_str)
sleep(1)
return redirect("channel", permanent=True)
@staticmethod
def subscribe_to(youtube_ids):
"""process the subscribe ids"""
for youtube_id in youtube_ids:
if youtube_id["type"] == "video":
to_sub = youtube_id["url"]
vid_details = PendingList().get_youtube_details(to_sub)
channel_id_sub = vid_details["channel_id"]
elif youtube_id["type"] == "channel":
channel_id_sub = youtube_id["url"]
else:
raise ValueError("failed to subscribe to: " + youtube_id)
ChannelSubscription().change_subscribe(
channel_id_sub, channel_subscribed=True
)
print("subscribed to: " + channel_id_sub)
class VideoView(View):
"""resolves to /video/<video-id>/
@@ -388,24 +548,44 @@ class VideoView(View):
def get(self, request, video_id):
"""get single video"""
es_url, colors = self.read_config()
es_url, colors = self.read_config(user_id=request.user.id)
url = f"{es_url}/ta_video/_doc/{video_id}"
data = None
look_up = SearchHandler(url, data)
video_hit = look_up.get_data()
video_data = video_hit[0]["source"]
try:
rating = video_data["stats"]["average_rating"]
video_data["stats"]["average_rating"] = self.star_creator(rating)
except KeyError:
video_data["stats"]["average_rating"] = False
video_title = video_data["title"]
context = {"video": video_data, "title": video_title, "colors": colors}
return render(request, "home/video.html", context)
@staticmethod
def read_config():
def read_config(user_id):
"""read config file"""
config = AppConfig().config
es_url = config["application"]["es_url"]
colors = config["application"]["colors"]
config_handler = AppConfig(user_id)
es_url = config_handler.config["application"]["es_url"]
colors = config_handler.colors
return es_url, colors
@staticmethod
def star_creator(rating):
"""convert rating float to stars"""
stars = []
for _ in range(1, 6):
if rating >= 0.75:
stars.append("full")
elif 0.25 < rating < 0.75:
stars.append("half")
else:
stars.append("empty")
rating = rating - 1
return stars
class SettingsView(View):
"""resolves to /settings/
@@ -416,21 +596,38 @@ class SettingsView(View):
@staticmethod
def get(request):
"""read and display current settings"""
config = AppConfig().config
colors = config["application"]["colors"]
config_handler = AppConfig(request.user.id)
colors = config_handler.colors
context = {"title": "Settings", "config": config, "colors": colors}
user_form = UserSettingsForm()
app_form = ApplicationSettingsForm()
context = {
"title": "Settings",
"config": config_handler.config,
"colors": colors,
"user_form": user_form,
"app_form": app_form,
}
return render(request, "home/settings.html", context)
@staticmethod
def post(request):
"""handle form post to update settings"""
form_post = dict(request.POST)
del form_post["csrfmiddlewaretoken"]
print(form_post)
config_handler = AppConfig()
config_handler.update_config(form_post)
form_response = forms.Form(request.POST)
if form_response.is_valid():
form_post = dict(request.POST)
print(form_post)
del form_post["csrfmiddlewaretoken"]
config_handler = AppConfig()
if "application-settings" in form_post:
del form_post["application-settings"]
config_handler.update_config(form_post)
elif "user-settings" in form_post:
del form_post["user-settings"]
config_handler.set_user_config(form_post, request.user.id)
return redirect("settings", permanent=True)
@@ -440,15 +637,16 @@ def progress(request):
"""endpoint for download progress ajax calls"""
config = AppConfig().config
cache_dir = config["application"]["cache_dir"]
json_data = get_dl_message(cache_dir)
json_data = RedisArchivist().get_dl_message(cache_dir)
return JsonResponse(json_data)
def process(request):
"""handle all the buttons calls via POST ajax"""
if request.method == "POST":
current_user = request.user.id
post_dict = json.loads(request.body.decode())
post_handler = PostData(post_dict)
post_handler = PostData(post_dict, current_user)
if post_handler.to_exec:
task_result = post_handler.run_task()
return JsonResponse(task_result)
@@ -462,9 +660,10 @@ class PostData:
handover long running tasks to celery
"""
def __init__(self, post_dict):
def __init__(self, post_dict, current_user):
self.post_dict = post_dict
self.to_exec, self.exec_val = list(post_dict.items())[0]
self.current_user = current_user
def run_task(self):
"""execute and return task result"""
@@ -476,18 +675,28 @@ class PostData:
"""map dict key and return function to execute"""
exec_map = {
"watched": self.watched,
"un_watched": self.un_watched,
"change_view": self.change_view,
"rescan_pending": self.rescan_pending,
"ignore": self.ignore,
"dl_pending": self.dl_pending,
"queue": self.queue_handler,
"unsubscribe": self.unsubscribe,
"sort_order": self.sort_order,
"hide_watched": self.hide_watched,
"show_subed_only": self.show_subed_only,
"dlnow": self.dlnow,
"show_ignored_only": self.show_ignored_only,
"forgetIgnore": self.forget_ignore,
"addSingle": self.add_single,
"manual-import": self.manual_import,
"re-embed": self.re_embed,
"db-backup": self.db_backup,
"db-restore": self.db_restore,
"fs-rescan": self.fs_rescan,
"channel-search": self.channel_search,
"delete-video": self.delete_video,
"delete-channel": self.delete_channel,
}
return exec_map[self.to_exec]
@@ -497,6 +706,19 @@ class PostData:
WatchState(self.exec_val).mark_as_watched()
return {"success": True}
def un_watched(self):
"""mark as unwatched"""
WatchState(self.exec_val).mark_as_unwatched()
return {"success": True}
def change_view(self):
"""process view changes in home, channel, and downloads"""
origin, new_view = self.exec_val.split(":")
key = f"{self.current_user}:view:{origin}"
print(f"change view: {key} to {new_view}")
RedisArchivist().set_message(key, {"status": new_view}, expire=False)
return {"success": True}
@staticmethod
def rescan_pending():
"""look for new items in subscribed channels"""
@@ -506,17 +728,38 @@ class PostData:
def ignore(self):
"""ignore from download queue"""
print("ignore video")
id_to_ignore = self.exec_val
print("ignore video " + id_to_ignore)
handler = PendingList()
ignore_list = self.exec_val
handler.ignore_from_pending([ignore_list])
handler.ignore_from_pending([id_to_ignore])
# also clear from redis queue
RedisQueue("dl_queue").clear_item(id_to_ignore)
return {"success": True}
@staticmethod
def dl_pending():
"""start the download queue"""
print("download pending")
download_pending.delay()
running = download_pending.delay()
task_id = running.id
print("set task id: " + task_id)
RedisArchivist().set_message("dl_queue_id", task_id, expire=False)
return {"success": True}
def queue_handler(self):
"""queue controls from frontend"""
to_execute = self.exec_val
if to_execute == "stop":
print("stopping download queue")
RedisQueue("dl_queue").clear()
elif to_execute == "kill":
task_id = RedisArchivist().get_message("dl_queue_id")
if not isinstance(task_id, str):
task_id = False
else:
print("brutally killing " + task_id)
kill_dl(task_id)
return {"success": True}
def unsubscribe(self):
@@ -530,29 +773,66 @@ class PostData:
def sort_order(self):
"""change the sort between published to downloaded"""
sort_order = self.exec_val
set_message("sort_order", sort_order, expire=False)
sort_order = {"status": self.exec_val}
if self.exec_val in ["asc", "desc"]:
RedisArchivist().set_message(
f"{self.current_user}:sort_order", sort_order, expire=False
)
else:
RedisArchivist().set_message(
f"{self.current_user}:sort_by", sort_order, expire=False
)
return {"success": True}
def hide_watched(self):
"""toggle if to show watched vids or not"""
hide_watched = bool(int(self.exec_val))
print(f"hide watched: {hide_watched}")
set_message("hide_watched", hide_watched, expire=False)
key = f"{self.current_user}:hide_watched"
message = {"status": bool(int(self.exec_val))}
print(f"toggle {key}: {message}")
RedisArchivist().set_message(key, message, expire=False)
return {"success": True}
def show_subed_only(self):
"""show or hide subscribed channels only on channels page"""
show_subed_only = bool(int(self.exec_val))
print(f"show subed only: {show_subed_only}")
set_message("show_subed_only", show_subed_only, expire=False)
key = f"{self.current_user}:show_subed_only"
message = {"status": bool(int(self.exec_val))}
print(f"toggle {key}: {message}")
RedisArchivist().set_message(key, message, expire=False)
return {"success": True}
def dlnow(self):
"""start downloading single vid now"""
youtube_id = self.exec_val
print("downloading: " + youtube_id)
download_single.delay(youtube_id=youtube_id)
running = download_single.delay(youtube_id=youtube_id)
task_id = running.id
print("set task id: " + task_id)
RedisArchivist().set_message("dl_queue_id", task_id, expire=False)
return {"success": True}
def show_ignored_only(self):
"""switch view on /downloads/ to show ignored only"""
show_value = self.exec_val
key = f"{self.current_user}:show_ignored_only"
value = {"status": show_value}
print(f"Filter download view ignored only: {show_value}")
RedisArchivist().set_message(key, value, expire=False)
return {"success": True}
def forget_ignore(self):
"""delete from ta_download index"""
youtube_id = self.exec_val
print("forgetting from download index: " + youtube_id)
PendingList().delete_from_pending(youtube_id)
return {"success": True}
def add_single(self):
"""add single youtube_id to download queue"""
youtube_id = self.exec_val
print("add vid to dl queue: " + youtube_id)
PendingList().delete_from_pending(youtube_id)
youtube_ids = UrlListParser(youtube_id).process_list()
extrac_dl.delay(youtube_ids)
return {"success": True}
@staticmethod
@@ -562,6 +842,13 @@ class PostData:
run_manual_import.delay()
return {"success": True}
@staticmethod
def re_embed():
"""rewrite thumbnails into media files"""
print("start video thumbnail embed process")
re_sync_thumbs.delay()
return {"success": True}
@staticmethod
def db_backup():
"""backup es to zip from settings page"""
@@ -576,9 +863,28 @@ class PostData:
run_restore_backup.delay()
return {"success": True}
@staticmethod
def fs_rescan():
"""start file system rescan task"""
print("start filesystem scan")
rescan_filesystem.delay()
return {"success": True}
def channel_search(self):
"""search for channel name as_you_type"""
search_query = self.exec_val
print("searching for: " + search_query)
search_results = SearchForm().search_channels(search_query)
return search_results
def delete_video(self):
"""delete media file, metadata and thumb"""
youtube_id = self.exec_val
YoutubeVideo(youtube_id).delete_media_file()
return {"success": True}
def delete_channel(self):
"""delete channel and all matching videos"""
channel_id = self.exec_val
YoutubeChannel(channel_id).delete_channel()
return {"success": True}

View File

@@ -1,9 +1,9 @@
beautifulsoup4==4.10.0
celery==5.1.2
Django==3.2.7
Pillow==8.3.2
Django==3.2.8
Pillow==8.4.0
redis==3.5.3
requests==2.26.0
uWSGI==2.0.19.1
uWSGI==2.0.20
whitenoise==5.3.0
yt_dlp==2021.9.2
yt_dlp==2021.10.22

View File

@@ -2,9 +2,11 @@
--main-bg: #00202f;
--highlight-bg: #00293b;
--highlight-error: #990202;
--highlight-error-light: #c44343;
--highlight-bg-transparent: #00293baf;
--main-font: #eeeeee;
--accent-font-dark: #259485;
--accent-font-light: #97d4c8;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%) contrast(90%);
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%) contrast(120%);
}

View File

@@ -1,10 +1,12 @@
:root {
--main-bg: #eeeeee;
--highlight-bg: #d9e0d9;
--highlight-error: #f79c9c;
--highlight-error: #990202;
--highlight-error-light: #c44343;
--highlight-bg-transparent: #00293baf;
--main-font: #00202f;
--accent-font-dark: #259485;
--accent-font-light: #35b399;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%) contrast(90%);
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%) contrast(120%);
}

View File

@@ -1,12 +1,12 @@
@font-face {
font-family: 'Sen-Bold';
src: url('../font/Sen-Bold.ttf.woff');
src: url('../font/Sen-Bold.woff');
font-family: 'Sen-Bold';
}
@font-face {
font-family: 'Sen-Regular';
src: url('../font/Sen-Regular.ttf.woff');
src: url('../font/Sen-Regular.woff');
font-family: 'Sen-Regular';
}
@@ -62,7 +62,7 @@ ul {
margin-left: 20px;
}
td, span {
td, span, label {
font-family: Sen-Regular, sans-serif;
color: var(--main-font);
}
@@ -138,6 +138,86 @@ button:hover {
text-decoration: underline;
}
/* toggle on-off */
.toggle {
display: flex;
align-items: center;
}
.toggleBox > input[type="checkbox"] {
position: relative;
width: 70px;
height: 30px;
background-color: var(--accent-font-dark);
border-color: var(--accent-font-dark);
appearance: none;
border-radius: 15px;
transition: 0.4s;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.toggleBox > input:checked[type="checkbox"] {
background-color: var(--accent-font-light);
border-color: var(--accent-font-light);
}
.toggleBox > input[type="checkbox"]::before {
z-index: 2;
position: absolute;
content: "";
left: 0;
top: 0;
width: 30px;
height: 30px;
background-color: white;
border-radius: 50%;
transform: scale(1.1);
transition: 0.4s;
}
.toggleBox > input:checked[type="checkbox"]::before {
left: 40px;
}
.toggleBox {
margin-left: 10px;
position: relative;
display: inline;
}
.toggleBox > label {
position: absolute;
color: var(--main-font);
pointer-events: none;
}
.toggleBox > .onbtn {
bottom: 15px;
left: 15px;
font-family: Sen-Regular, sans-serif;
}
.toggleBox > .ofbtn {
bottom: 15px;
right: 15px;
font-family: Sen-Regular, sans-serif;
color: var(--main-font);
}
/* delete button */
.delete-confirm {
display: none;
}
.danger-button {
background-color: var(--highlight-error);
}
.danger-button:hover {
background-color: var(--highlight-error-light);
}
/* navigation */
.top-nav {
display: block;
@@ -170,13 +250,13 @@ button:hover {
.nav-icons img {
width: 40px;
padding: 0 10px;
padding: 0 5px;
filter: var(--img-filter);
}
/* top of page */
.title-bar {
padding: 25px 0;
padding: 20px 0;
}
.sort {
@@ -211,6 +291,26 @@ button:hover {
filter: var(--img-filter);
}
.view-controls {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 2px solid;
border-color: var(--accent-font-dark);
margin: 15px 0;
}
.view-icons {
display: flex;
justify-content: end;
}
.view-icons img {
width: 30px;
margin: 5px 10px;
cursor: pointer;
filter: var(--img-filter);
}
#search-box {
display: none;
flex: auto;
@@ -261,16 +361,27 @@ button:hover {
/* video list */
.video-list {
.video-list.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 1rem;
}
.video-list.list {
display: grid;
grid-template-columns: unset;
grid-gap: 1rem;
}
.video-item {
overflow: hidden;
}
.video-item.list {
display: grid;
grid-template-columns: 25% auto;
}
.video-thumb img {
width: 100%;
}
@@ -301,12 +412,25 @@ button:hover {
padding: 8px;
}
.video-desc {
.video-desc.grid {
padding: 10px;
height: 100%;
background-color: var(--highlight-bg);
}
.video-desc.list {
padding: 10px;
height: unset;
background-color: var(--highlight-bg);
display: flex;
flex-wrap: wrap-reverse;
align-content: center;
}
.video-desc > div {
width: 100%;
}
.video-desc img {
width: 20px;
margin-right: 10px;
@@ -329,15 +453,12 @@ button:hover {
}
.unseen-icon,
.seen-icon,
.close-button {
cursor: pointer;
filter: var(--img-filter);
}
.seen-icon {
filter: var(--img-filter);
}
.video-more {
text-decoration: underline;
text-align: right;
@@ -362,6 +483,7 @@ button:hover {
.info-box {
display: grid;
grid-gap: 1rem;
margin: 1rem 0;
}
.info-box-3 {
@@ -392,6 +514,69 @@ button:hover {
margin-bottom: 10px;
}
/* login */
.login-page {
display: flex;
flex-wrap: wrap;
justify-content: center;
text-align: center;
align-content: center;
}
.login-page > * {
width: 100%;
}
.login-page img {
width: 100%;
max-width: 200px;
max-height: 200px;
margin-bottom: 40px;
}
.login-page form {
margin: 30px 0;
}
.login-page input {
min-width: 200px;
}
.login-page button,
.login-page .danger-zone {
width: 210px;
margin-top: 5px;
}
.login-links a {
text-decoration: underline;
margin: 30px 0;
padding: 20px;
}
.footer-colors {
grid-row-start: 2;
grid-row-end: 3;
display: flex;
}
.footer-colors div {
padding: 20px 0;
width: 33.33%;
}
.col-1 {
background-color: var(--highlight-bg);
}
.col-2 {
background-color: var(--accent-font-dark);
}
.col-3 {
background-color: var(--accent-font-light);
}
/* video page */
.video-main video {
max-height: 70vh;
@@ -406,7 +591,8 @@ button:hover {
margin-left: 5px;
}
.thumb-icon img {
.thumb-icon img,
.rating-stars img {
width: 20px;
margin: 0;
filter: var(--img-filter);
@@ -417,14 +603,41 @@ button:hover {
}
/* channel overview page */
.channel-item {
.channel-list.list {
display: block;
}
.channel-list.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
.channel-item.list {
padding: 20px 0;
}
.channel-item.grid > .info-box {
display: block;
}
.channel-banner img {
width: 100%;
}
.channel-banner.grid {
overflow: hidden;
}
.channel-banner.list img {
width: 100%;
}
.channel-banner.grid img {
width: 250%;
transform: translateX(-30%);
}
/* download page */
.icon-text {
@@ -438,30 +651,92 @@ button:hover {
cursor: pointer;
}
.dl-item {
.dl-list.list {
display: block;
}
.dl-list.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 1rem;
}
.dl-item.list {
display: flex;
margin: 15px 0;
align-items: center;
background-color: var(--highlight-bg);
}
.dl-item.grid {
display: flex;
flex-wrap: wrap;
background-color: var(--highlight-bg);
}
.dl-check {
width: 30px;
}
.dl-item:hover > .dl-thumb span {
opacity: 1;
}
.dl-thumb {
position: relative;
}
.dl-thumb span {
position: absolute;
top: 5px;
left: 5px;
background-color: var(--accent-font-light);
padding: 5px;
opacity: 0;
transition: 300ms ease-in-out;
}
.dl-thumb.list {
width: 25%;
}
.dl-thumb.grid {
width: 100%;
}
.dl-item img {
width: 100%;
}
.dl-desc {
padding-left: 15px;
.dl-desc.list {
padding: 0 15px;
width: 75%;
}
.dl-desc.grid {
padding: 15px;
width: 100%;
}
.dl-control-icons {
display: flex;
justify-content: center;
padding: 10px 0;
}
.dl-control-icons img {
width: 30px;
cursor: pointer;
margin: 5px;
}
#stop-icon {
filter: var(--img-filter);
}
#kill-icon {
filter: var(--img-filter-error);
}
/* status message */
.download-progress {
@@ -478,6 +753,10 @@ button:hover {
background-color: var(--highlight-error);
}
.download-progress.error h3 {
color: #fff;
}
/* settings */
.settings-group {
background-color: var(--highlight-bg);
@@ -495,6 +774,7 @@ button:hover {
.danger-zone {
background-color: var(--highlight-error);
color: #fff;
padding: 3px;
}
@@ -567,9 +847,18 @@ button:hover {
.boxed-content {
width: 90%;
}
.video-list {
.video-list.grid,
.dl-list.grid,
.channel-list.grid {
grid-template-columns: 1fr 1fr;
}
.dl-thumb.list {
width: 35%;
}
.video-item.list {
display: grid;
grid-template-columns: 35% auto;
}
.two-col {
display: block;
}
@@ -583,15 +872,26 @@ button:hover {
* {
word-wrap: anywhere;
}
.video-list {
.video-list.grid,
.dl-list.grid,
.channel-list.grid,
.video-item.list {
grid-template-columns: 1fr;
}
.video-desc.grid {
height: unset;
display: flex;
flex-wrap: wrap-reverse;
}
.boxed-content {
width: 95%;
}
.footer {
text-align: center;
}
.toggle {
flex-wrap: wrap;
}
.top-nav {
flex-wrap: wrap-reverse;
display: flex;

View File

@@ -0,0 +1,94 @@
Copyright (c) 2015, Kosal Sen, Philatype (<http://philatype.com>),
with Reserved Font Name Sen.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="500"
viewBox="0 0 132.29197 132.29167"
version="1.1"
id="svg1303"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Icons_exit.svg">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.66442107"
inkscape:cx="161.45413"
inkscape:cy="207.61753"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1169"
inkscape:window-height="893"
inkscape:window-x="729"
inkscape:window-y="13"
inkscape:window-maximized="0" />
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-164.70764)">
<g
id="g855"
transform="matrix(1.9016362,0,0,1.9016362,-197.93838,-58.9418)">
<path
inkscape:connector-curvature="0"
id="rect1208"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
d="m 124.57603,151.92962 c -0.0433,2.30016 2.0751,4.19245 4.75007,4.24278 l 30.26401,0.43007 -6.00195,5.78023 3.43246,3.56154 10.2778,-9.9006 0.002,0.002 3.5183,-3.3908 -3.42991,-3.564 -9.8737,-10.24989 -3.51834,3.39083 5.84388,6.06803 -30.35875,-0.43185 c -2.67494,-0.0503 -4.86301,1.76094 -4.90629,4.06112 z m -17.65039,-32.01644 v 64.95883 h 7.44347 v -58.27707 h 26.3896 v 18.5229 h 7.44296 v -25.20466 z m 33.83307,39.75416 v 25.20467 h 7.44296 v -25.20467 z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="2000"
height="2000"
viewBox="0 0 529.16666 529.16735"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Gridview.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35729063"
inkscape:cx="901.7564"
inkscape:cy="1021.9111"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1">
<sodipodi:guide
position="247.25932,291.92959"
orientation="1,0"
id="guide853"
inkscape:locked="false" />
<sodipodi:guide
position="337.22901,167.98535"
orientation="0,1"
id="guide855"
inkscape:locked="false" />
<sodipodi:guide
position="266.76325,305.4565"
orientation="0,1"
id="guide857"
inkscape:locked="false" />
<sodipodi:guide
position="257.79774,279.50371"
orientation="0,1"
id="guide861"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,232.16736)">
<g
id="g873"
transform="matrix(1.3431799,0,0,1.3431799,-84.854433,26.13855)"
style="stroke:none">
<rect
ry="7.445024"
rx="7.445024"
y="-121.39048"
x="79.903137"
height="113.24854"
width="167.35619"
id="rect815"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.56300002;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.12600005, 0.56300002999999998;stroke-dashoffset:0;stroke-opacity:0.22508042;paint-order:markers fill stroke" />
<rect
ry="7.445024"
rx="7.445024"
y="-121.7837"
x="273.05484"
height="113.24854"
width="167.35619"
id="rect815-4"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.56300002;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.12600006, 0.56300004000000003;stroke-dashoffset:0;stroke-opacity:0.22508042;paint-order:markers fill stroke" />
<rect
ry="7.445024"
rx="7.445024"
y="17.882772"
x="79.903137"
height="113.24854"
width="167.35619"
id="rect815-2"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.56300002;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.12600006, 0.56300004000000003;stroke-dashoffset:0;stroke-opacity:0.22508042;paint-order:markers fill stroke" />
<rect
ry="7.445024"
rx="7.445024"
y="17.453594"
x="273.05484"
height="113.24854"
width="167.35619"
id="rect815-7"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.56300002;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.12600006, 0.56300004000000003;stroke-dashoffset:0;stroke-opacity:0.22508042;paint-order:markers fill stroke" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="2000"
height="2000"
viewBox="0 0 529.16666 529.16735"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Listview.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.42053519"
inkscape:cx="851.82064"
inkscape:cy="1105.7974"
inkscape:document-units="mm"
inkscape:current-layer="g873"
showgrid="false"
units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1">
<sodipodi:guide
position="247.25932,291.92959"
orientation="1,0"
id="guide853"
inkscape:locked="false" />
<sodipodi:guide
position="337.22901,167.98535"
orientation="0,1"
id="guide855"
inkscape:locked="false" />
<sodipodi:guide
position="266.76325,305.4565"
orientation="0,1"
id="guide857"
inkscape:locked="false" />
<sodipodi:guide
position="257.79774,279.50371"
orientation="0,1"
id="guide861"
inkscape:locked="false" />
<sodipodi:guide
position="22.413775,336.67894"
orientation="1,0"
id="guide926"
inkscape:locked="false" />
<sodipodi:guide
position="503.71355,217.50031"
orientation="1,0"
id="guide928"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,232.16736)">
<g
id="g873"
transform="matrix(1.3431799,0,0,1.3431799,-84.854433,26.13855)">
<rect
ry="7.445024"
rx="7.445024"
y="-121.34892"
x="79.944702"
height="70.107315"
width="358.24551"
id="rect815"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.64810181;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.29620369, 0.64810185;stroke-dashoffset:0;stroke-opacity:0.22508042;paint-order:markers fill stroke" />
<rect
ry="7.4450235"
rx="7.4450235"
y="-31.167784"
x="79.861153"
height="70.107315"
width="358.32907"
id="rect815-20"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.64817739;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.29635485, 0.64817743;stroke-dashoffset:0;stroke-opacity:0.22508042;paint-order:markers fill stroke" />
<rect
ry="7.4450235"
rx="7.4450231"
y="60.024326"
x="79.908524"
height="70.106117"
width="358.28171"
id="rect815-8"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.64812905;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.29625813, 0.64812907;stroke-dashoffset:0;stroke-opacity:0.22508042;paint-order:markers fill stroke" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="500"
height="500"
viewBox="0 0 132.29197 132.29167"
version="1.1"
id="svg1303"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
sodipodi:docname="icon-star-empty.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="2.4005076"
inkscape:cx="40.40812"
inkscape:cy="211.20533"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="3840"
inkscape:window-height="2112"
inkscape:window-x="0"
inkscape:window-y="48"
inkscape:window-maximized="1"
inkscape:pagecheckerboard="0" />
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-164.70764)">
<path
id="path1255"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
inkscape:transform-center-y="-6.4703827"
d="M 250 21.158203 L 189.78906 194.37891 L 6.4375 198.11523 L 152.57617 308.9082 L 99.470703 484.43945 L 250 379.69141 L 400.5293 484.43945 L 347.42383 308.9082 L 493.5625 198.11523 L 310.21094 194.37891 L 250 21.158203 z M 250 96.408203 L 291.69141 216.34766 L 418.64453 218.93555 L 317.45703 295.65039 L 354.22852 417.18945 L 250 344.66211 L 145.77148 417.18945 L 182.54297 295.65039 L 81.355469 218.93555 L 208.30859 216.34766 L 250 96.408203 z "
transform="matrix(0.26458394,0,0,0.26458394,0,164.70749)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="500"
viewBox="0 0 132.29197 132.29167"
version="1.1"
id="svg1303"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Icons_star.svg">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.0105705"
inkscape:cx="61.891881"
inkscape:cy="148.25167"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-164.70764)">
<path
sodipodi:type="star"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
id="path1255"
sodipodi:sides="5"
sodipodi:cx="66.145981"
sodipodi:cy="237.32387"
sodipodi:r1="67.758904"
sodipodi:r2="27.103561"
sodipodi:arg1="-1.5707963"
sodipodi:arg2="-0.9424778"
inkscape:flatsided="false"
inkscape:rounded="-3.46945e-18"
inkscape:randomized="0"
d="m 66.145983,169.56496 15.931071,45.83167 48.511476,0.98859 L 91.923,245.69933 105.97366,292.14197 66.145981,264.42743 26.318295,292.14197 40.368962,245.69933 1.7034347,216.38521 50.214907,215.39663 Z"
inkscape:transform-center-y="-6.4703827" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="500"
height="500"
viewBox="0 0 132.29197 132.29167"
version="1.1"
id="svg1303"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
sodipodi:docname="icon-star-half.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.0105705"
inkscape:cx="44.034533"
inkscape:cy="168.22181"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="3840"
inkscape:window-height="2078"
inkscape:window-x="0"
inkscape:window-y="82"
inkscape:window-maximized="1"
inkscape:pagecheckerboard="0" />
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-164.70764)">
<path
id="path1255"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
inkscape:transform-center-x="1.1220981e-06"
inkscape:transform-center-y="-6.4703827"
d="M 250 21.158203 L 189.78906 194.37891 L 6.4375 198.11523 L 152.57617 308.9082 L 99.470703 484.43945 L 250 379.69141 L 400.5293 484.43945 L 347.42383 308.9082 L 493.5625 198.11523 L 310.21094 194.37891 L 250 21.158203 z M 250 96.408203 L 291.69141 216.34766 L 418.64453 218.93555 L 317.45703 295.65039 L 354.22852 417.18945 L 250 344.66211 L 250 96.408203 z "
transform="matrix(0.26458394,0,0,0.26458394,0,164.70749)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="500"
viewBox="0 0 132.29197 132.29167"
version="1.1"
id="svg1303"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Icons_stop.svg">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.0105705"
inkscape:cx="43.182711"
inkscape:cy="168.09972"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-164.70764)">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
id="rect836"
width="118.86465"
height="118.86465"
x="6.7136617"
y="171.42116"
rx="10.00003"
ry="10.00003" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -15,9 +15,13 @@ function checkMessage() {
req.open('GET', '/downloads/progress', true);
req.onload = function() {
var dlProgress = req.response;
// var dlStatus = dlProgress['status'];
if (dlProgress['status']) {
buildDownloadMessage(dlProgress);
handleInterval();
// if (dlStatus == 'downloading') {
// buildDownloadIcons();
// };
};
};
req.send();
@@ -28,7 +32,7 @@ function handleInterval() {
var watchDownload = setInterval(function() {
var req = new XMLHttpRequest();
req.responseType = 'json';
req.open('GET', '/downloads/progress', true);
req.open('GET', '/downloads/progress/', true);
req.onload = function() {
var dlProgress = req.response;
if (dlProgress['status']) {
@@ -70,4 +74,33 @@ function buildDownloadMessage(dlProgress) {
message.appendChild(title);
message.appendChild(messageText);
box.appendChild(message);
if (dlStatus == 'downloading' && dlLevel != 'error') {
box.appendChild(buildDownloadIcons());
};
};
// add dl control icons
function buildDownloadIcons() {
var iconBox = document.createElement('div');
iconBox.classList = 'dl-control-icons';
// stop icon
var stopIcon = document.createElement('img');
stopIcon.setAttribute('id', "stop-icon");
stopIcon.setAttribute('title', "Stop Download Queue");
stopIcon.setAttribute('src', "/static/img/icon-stop.svg");
stopIcon.setAttribute('alt', "stop icon");
stopIcon.setAttribute('onclick', 'stopQueue()');
// kill icon
var killIcon = document.createElement('img');
killIcon.setAttribute('id', "kill-icon");
killIcon.setAttribute('title', "Kill Download Queue");
killIcon.setAttribute('src', "/static/img/icon-close.svg");
killIcon.setAttribute('alt', "kill icon");
killIcon.setAttribute('onclick', 'killQueue()');
// stich together
iconBox.appendChild(stopIcon);
iconBox.appendChild(killIcon);
return iconBox
}

View File

@@ -8,24 +8,6 @@ function sortChange(sortValue) {
}, 500);
}
function hideWatched(hideValue) {
var payload = JSON.stringify({'hide_watched': hideValue});
sendPost(payload);
setTimeout(function(){
location.reload();
return false;
}, 500);
}
function showSubscribedOnly(showValue) {
var payload = JSON.stringify({'show_subed_only': showValue});
sendPost(payload);
setTimeout(function(){
location.reload();
return false;
}, 500);
}
function isWatched(youtube_id) {
var payload = JSON.stringify({'watched': youtube_id});
sendPost(payload);
@@ -33,16 +15,57 @@ function isWatched(youtube_id) {
seenIcon.setAttribute('src', "/static/img/icon-seen.svg");
seenIcon.setAttribute('alt', 'seen-icon');
seenIcon.setAttribute('id', youtube_id);
seenIcon.setAttribute('title', "Mark as unwatched");
seenIcon.setAttribute('onclick', "isUnwatched(this.id)");
seenIcon.classList = 'seen-icon';
document.getElementById(youtube_id).replaceWith(seenIcon);
}
function isUnwatched(youtube_id) {
var payload = JSON.stringify({'un_watched': youtube_id});
sendPost(payload);
var unseenIcon = document.createElement('img');
unseenIcon.setAttribute('src', "/static/img/icon-unseen.svg");
unseenIcon.setAttribute('alt', 'unseen-icon');
unseenIcon.setAttribute('id', youtube_id);
unseenIcon.setAttribute('title', "Mark as watched");
unseenIcon.setAttribute('onclick', "isWatched(this.id)");
unseenIcon.classList = 'unseen-icon';
document.getElementById(youtube_id).replaceWith(unseenIcon);
}
function unsubscribe(channel_id) {
var payload = JSON.stringify({'unsubscribe': channel_id});
sendPost(payload);
document.getElementById(channel_id).remove();
}
function changeView(image) {
var sourcePage = image.getAttribute("data-origin");
var newView = image.getAttribute("data-value");
var payload = JSON.stringify({'change_view': sourcePage + ":" + newView});
sendPost(payload);
setTimeout(function(){
location.reload();
return false;
}, 500);
}
function toggleCheckbox(checkbox) {
// pass checkbox id as key and checkbox.checked as value
var toggleId = checkbox.id;
var toggleVal = checkbox.checked;
var payloadDict = {};
payloadDict[toggleId] = toggleVal;
var payload = JSON.stringify(payloadDict);
sendPost(payload);
setTimeout(function(){
var currPage = window.location.pathname;
window.location.replace(currPage);
return false;
}, 500);
}
// download page buttons
function rescanPending() {
var payload = JSON.stringify({'rescan_pending': true});
@@ -79,6 +102,35 @@ function downloadNow(button) {
}, 500);
}
function forgetIgnore(button) {
var youtube_id = button.getAttribute('data-id');
var payload = JSON.stringify({'forgetIgnore': youtube_id});
sendPost(payload);
document.getElementById("dl-" + youtube_id).remove();
}
function addSingle(button) {
var youtube_id = button.getAttribute('data-id');
var payload = JSON.stringify({'addSingle': youtube_id});
sendPost(payload);
document.getElementById("dl-" + youtube_id).remove();
setTimeout(function(){
handleInterval();
}, 500);
}
function stopQueue() {
var payload = JSON.stringify({'queue': 'stop'});
sendPost(payload);
document.getElementById('stop-icon').remove();
}
function killQueue() {
var payload = JSON.stringify({'queue': 'kill'});
sendPost(payload);
document.getElementById('kill-icon').remove();
}
// settings page buttons
function manualImport() {
var payload = JSON.stringify({'manual-import': true});
@@ -91,6 +143,17 @@ function manualImport() {
toReplace.appendChild(message);
}
function reEmbed() {
var payload = JSON.stringify({'re-embed': true});
sendPost(payload);
// clear button
var message = document.createElement('p');
message.innerText = 'processing thumbnails';
var toReplace = document.getElementById('re-embed');
toReplace.innerHTML = '';
toReplace.appendChild(message);
}
function dbBackup() {
var payload = JSON.stringify({'db-backup': true});
sendPost(payload)
@@ -104,7 +167,7 @@ function dbBackup() {
function dbRestore() {
var payload = JSON.stringify({'db-restore': true});
sendPost(payload)
sendPost(payload);
// clear button
var message = document.createElement('p');
message.innerText = 'restoring from backup';
@@ -113,6 +176,51 @@ function dbRestore() {
toReplace.appendChild(message);
}
function fsRescan() {
var payload = JSON.stringify({'fs-rescan': true});
sendPost(payload);
// clear button
var message = document.createElement('p');
message.innerText = 'File system scan in progress';
var toReplace = document.getElementById('fs-rescan');
toReplace.innerHTML = '';
toReplace.appendChild(message);
}
// delete from file system
function deleteConfirm() {
to_show = document.getElementById("delete-button");
document.getElementById("delete-item").style.display = 'none';
to_show.style.display = "block";
}
function deleteVideo(button) {
var to_delete = button.getAttribute("data-id");
var to_redirect = button.getAttribute("data-redirect");
var payload = JSON.stringify({"delete-video": to_delete});
sendPost(payload);
setTimeout(function(){
var redirect = "/channel/" + to_redirect;
window.location.replace(redirect);
return false;
}, 1000);
}
function deleteChannel(button) {
var to_delete = button.getAttribute("data-id");
var payload = JSON.stringify({"delete-channel": to_delete});
sendPost(payload);
setTimeout(function(){
window.location.replace("/channel/");
return false;
}, 1000);
}
function cancelDelete() {
document.getElementById("delete-button").style.display = 'none';
document.getElementById("delete-item").style.display = 'block';
}
// player
function createPlayer(button) {
var mediaUrl = button.getAttribute('data-src');
@@ -133,6 +241,7 @@ function createPlayer(button) {
videoPlayer.setAttribute('controls', true);
videoPlayer.setAttribute('autoplay', true);
videoPlayer.setAttribute('width', '100%');
videoPlayer.setAttribute('playsinline', true);
videoPlayer.setAttribute('poster', mediaThumb);
playerElement.appendChild(videoPlayer);
// title bar
@@ -274,7 +383,7 @@ function showSearch() {
} else {
searchBox.style.display = "";
}
var inputBox = document.getElementById('searchInput');
var inputBox = document.getElementById('id_searchInput');
inputBox.focus();
}

View File

@@ -1,8 +1,8 @@
[uwsgi]
; chdir = /app
module=config.wsgi:application
master=True
pidfile=/tmp/project-master.pid
vacuum=True
max-requests=5000
socket = :8080
module = config.wsgi:application
master = True
pidfile = /tmp/project-master.pid
vacuum = True
max-requests = 5000
socket = :8080
buffer-size = 8192