diff --git a/.github/workflows/docker_hub.yml b/.github/workflows/docker_hub.yml new file mode 100644 index 0000000..94ac86e --- /dev/null +++ b/.github/workflows/docker_hub.yml @@ -0,0 +1,29 @@ +name: ci + +on: + push: + branches: + - 'main' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Build and push + uses: docker/build-push-action@v5 + with: + push: true + tags: maksii/toloka2mediaserver:latest diff --git a/.gitignore b/.gitignore index 82f9275..ff9ad8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# ghostwriter +*.md.backup + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -106,10 +109,8 @@ ipython_config.py #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +# https://pdm.fming.dev/#use-with-ide .pdm.toml -.pdm-python -.pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -160,3 +161,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +/data/* +cookie.txt diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cfca52c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Web", + "type": "debugpy", + "request": "launch", + "module": "app", + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..384fa95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Use an official Python runtime as a parent image +FROM python:3 + +# Set the working directory in the container +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the Python script and any other necessary files +COPY . . + +# Add the config folder as a volume +VOLUME /app/toloka2MediaServer/data + +# Define the default cron schedule +ENV CRON_SCHEDULE="0 8 * * *" + +# Add the cron job to run toloka2transmission +ADD crontab /etc/cron.d/cron-job +RUN chmod 0644 /etc/cron.d/cron-job +RUN touch /var/log/cron.log + +# Start cron service +CMD cron && tail -f /var/log/cron.log + +# Make port available to the world outside this container +EXPOSE 5000 + +# Define environment variable +ENV PORT 5000 + +# Start-up script to run both cron and the web server +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh +ENTRYPOINT ["docker-entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 83f42ff..4482b84 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,351 @@ -# Toloka2MediaServerWeb -Web client for Toloka2MediaServer +# Toloka2MediaServer [![GPLv3 License](https://img.shields.io/badge/License-GPL%20v3-yellow.svg)](https://opensource.org/licenses/) + +

+ + + +

+ +## English Section +The primary goal of this project is to address naming issues for Ukrainian localization studios that create regional naming conventions for shows/anime. Additionally, Toloka follows the rule that ongoing series/anime should be in a single release. As a result of these actions, none of the modern *arr suites or media servers are capable of parsing files and automating the download process effectively. + +This project is specifically tailored to use the Toloka torrent tracker and a custom-made [toloka2python by CakesTwix](https://github.com/CakesTwix/toloka2python) library to establish connections, find torrents, and gather additional metadata. Adapting it to work with other trackers may require some effort on your part. Support for Jackett/Prowlarr is currently not planned as it would necessitate adjustments in their Toloka implementation. + +The scripts in this project make direct API calls to the torrent clients like Transmission and qBittorrent to adjust the torrent name, folder name, and file name according to the following naming convention: +- Torrent/Folder: `SeriesName Season [Quality] [Language] [Subs] [ReleaseGroup]` +- File: `SeriesName SeasonEpisode [Quality] [Language] [Subs]-ReleaseGroup.extension` + +Any future documentation will be provided in Ukrainian. Please use a translator if needed or create new issues if you require further assistance. + +## UA Section +Консольна утиліта для докачування нових серій аніме з Toloka. +Для скачування торрент-файлів використовується власна бібліотека toloka2python! + +> У мене на даний момент немає бажання писати під інші торрент-трекери або щось крім аніме. Я написав суто для себе і роздав вихідний код, щоб ви могли самостійно змінити код і поширювати його далі! Слався Open Source! + +Чому я зробив цей скрипт? Хочу дивитися онгоінги і не думати над постійним перейменуванням для свого медіа-сервера Jellyfin, оскільки у кожного свій "стандарт" і тільки одиниці дотримуються стандарту "S01E01", який підтримує мій медіа-сервер. +Наразі можна качати торренти, де одна директорія (Один сезон), в якому знаходяться серії + +``` +The Girl I Like Forgot Her Glasses (S1) +├── Episode S1E01.mkv +├── Episode S1E02.mkv +├── Episode S1E03.mkv +├── Episode S1E04.mkv +├── Episode S1E05.mkv +├── Episode S1E06.mkv +├── Episode S1E07.mkv +└── Episode S1E08.mkv +``` + + +## Огляд Інтерфейсу Користувача + +### Інтерфейс Командного Рядка (CLI) + +Застосунок надає інтерфейс командного рядка (CLI) для користувачів, які віддають перевагу безпосередньому взаємодії з програмним забезпеченням через їхній термінал. Нижче наведено приклад виконання звичайної команди та її виводу. + +#### Приклад Команди + +Ось як запустити приклад команди: + +```bash +python -m toloka2MediaServer -a "Kimetsu no Yaiba: Hashira Geiko-hen" +``` + +#### Вивід + +``` +0 : Вбивця демонів: навчання Хашіра (Сезон 4, 03 з XX) / Kimetsu no Yaiba: Hashira Geiko-hen (2024) WEBDLRip 1080p H.265 Ukr/Jap | sub Ukr - t678861 +Enter the index of the desired torrent: 0 +Default:KimetsunoYaibaHashiraGeikohen. Enter the codename: +Enter the season number: 5 +Enter the file extension, e.g., ".mkv": +Default: /esata/Downloads/toloka/tr:. Enter the download directory path. +Default: Kimetsu no Yaiba: Hashira Geiko-hen (2024). Enter the directory name for the downloaded files: +Enter the release group name, or it will default to the torrent's author: +Default: [WEBRip-1080p][UK+JA][Ukr Sub]. Enter additional metadata tags: +``` + +**Скріншот:** + +![CLI Скріншот](assets/cli.png) + +### Веб-Інтерфейс Користувача (Web UI) + +Для користувачів, які віддають перевагу графічному інтерфейсу, застосунок також включає веб-інтерфейс. Нижче наведено знімок екрану, який показує основний інтерфейс. + +#### Головна Сторінка + +Головна сторінка надає зручний інтерфейс для доступу до всіх функцій застосунку. + +**Скріншот:** + +![Web UI Скріншот](assets/webapp.png) + +## Огляд работи + +**Перед змінами:** +![Web UI Скріншот](assets/files-before.png) +**Після змін:** +![Web UI Скріншот](assets/files-after.png) + +**Перед змінами:** +![Web UI Скріншот](assets/Info-before.png) +**Після змін:** +![Web UI Скріншот](assets/Info-after.png) + +### Використання/Приклади +Цей блок містить приклади використання команд для `toloka2MediaServer`. Коментарі надають пояснення щодо деяких параметрів та їх використання. +* **Допомога** + ```bash + python -m toloka2MediaServer --help + ``` +* **Додати новий торрент вручну** + ```bash + python -m toloka2MediaServer -a "Назва торрента роздачі" + ``` +* **Додати новий торрент автоматично** + ```bash + python -m toloka2MediaServer --add --url https://toloka.to/t675888 --season 02 --index 2 --correction 0 --title "Tsukimichi -Moonlit Fantasy-" + ``` +* **Оновити всі торренти** + ```bash + python -m toloka2MediaServer + ``` +* **Завантажити нові серії, якщо торрент оновився** + ```bash + python -m toloka2MediaServer -с CODENAME + ``` + > Коднейм береться з файлу titles.ini, про нього буде вказано пізніше +* **Отримати список чисел із рядка** + ```bash + python -m toloka2MediaServer -n "text1 123" + ``` + > Це необхідно для перейменування файлів у торренті для визначення номера серії у Jellyfin або Plex. В конфігурації потрібно вказати, в якому індексі знаходиться номер серії. + +> **Примітка:** У торренті береться відразу Директорія/Файл.mkv, наприклад: +Horimiya - Piece [WEBDL 1080p HEVC]/Horimiya - Piece - 01 (WEBDL 1080p HEVC AAC) Ukr DVO SUB.mkv + +## Crontab (Every day at 8:00) +```bash +crontab -e +``` +> 0 8 * * * cd /path/to/toloka2MediaServer/ && python3 -m toloka2MediaServer + +## Розгортання за допомогою Docker + +Дотримуйтесь цих кроків для розгортання `Toloka2MediaServer` за допомогою Docker: + +### Передумови + +Переконайтеся, що Docker встановлено на вашій системі. Ви можете завантажити його з [офіційного сайту Docker](https://www.docker.com/get-started). + +### Клонування репозиторію + +Спочатку клонуйте репозиторій на ваш локальний комп'ютер: + +```bash +cd ~ +git clone https://github.com/maksii/Toloka2MediaServer +cd Toloka2MediaServer +``` + +### Файли конфігурації + +Перед побудовою образу Docker створіть і налаштуйте необхідні файли конфігурації: + +```bash +mkdir -p /home/appconfig + +# Створіть і відредагуйте файл app.ini +nano /home/appconfig/app.ini + +# Створіть і відредагуйте файл titles.ini +nano /home/appconfig/titles.ini +``` + +Переконайтеся, що ви заповнили файли `app.ini` та `titles.ini` відповідно до вимог вашого додатку. + +### Побудова образу Docker + +Побудуйте образ Docker за допомогою наступної команди: + +```bash +docker build -t toloka2mediaserver . +``` + +### Запуск контейнера Docker + +Запустіть ваш контейнер Docker за допомогою наступної команди: + +```bash +docker run -d -p 5000:5000 -v /home/appconfig:/app/toloka2MediaServer/data --name toloka toloka2mediaserver +``` + +Ця команда запустить контейнер у відокремленому режимі, відображатиме порт 5000 контейнера на порт 5000 на хості і приєднає створену вами директорію конфігурації `/home/appconfig` до `/app/toloka2MediaServer/data` всередині контейнера. + +### Перевірка розгортання + +Після запуску контейнера ви можете перевірити, що додаток працює, відвідавши: + +``` +http://localhost:5000 +``` + +Замініть `localhost` на IP-адресу вашого сервера, якщо ви звертаєтесь з іншої машини. + +## Розгортання за допомогою готового образу Docker + +Цей розділ пояснює, як розгорнути `Toloka2MediaServer` використовуючи готовий образ з Docker Hub. + +### Використання Docker + +1. **Завантаження образу Docker** + Завантажте готовий образ з Docker Hub за допомогою наступної команди: + + ```bash + docker pull maksii/toloka2mediaserver:latest + ``` + + +2. **Запуск контейнера** + Використовуйте наступну команду для запуску контейнера: + + ```bash + docker run -d -p 5000:5000 -v /path/to/your/config:/app/toloka2MediaServer/data --name toloka maksii/toloka2mediaserver:latest + ``` + + Замініть `/path/to/your/config` на шлях до вашої папки конфігурації. + +### Використання Portainer + +Якщо ви використовуєте Portainer для управління контейнерами Docker, ви можете легко розгорнути `Toloka2MediaServer` як стек: + +1. **Логін в Portainer** + Увійдіть у вашу панель керування Portainer . + +2. **Створення стека** + Перейдіть до розділу "Stacks" і натисніть "Add Stack". + +3. **Конфігурація стека** + Дайте ім'я вашому стеку і вставте наступний YAML конфіг у поле "Web editor": + + ```yaml + version: '3.8' + services: + toloka2mediaserver: + image: maksii/toloka2mediaserver:latest + ports: + - "5000:5000" + volumes: + - /path/to/your/config:/app/toloka2MediaServer/data + restart: unless-stopped + ``` + + Замініть `/path/to/your/config` на шлях до вашої папки конфігурації. + +4. **Розгортання стека** + Натисніть "Deploy the stack" для запуску вашого додатку. + +## Configs + +* ### app.ini +```ini +[Python] +# NOTSET +# DEBUG +# INFO +# WARNING +# ERROR +# CRITICAL +logging = INFO + +[transmission] +username = Імя користувача +password = Пароль +port = 9091 +host = localhost +protocol = http +rpc = /transmission/rpc +category = sonarr +tag = tolokaAnime + +[qbittorrent] +username = Імя користувача +password = Пароль +port = 8080 +host = 192.168.40.22 +protocol = http +tag = toloka +category = sonarr + +[Toloka] +username = +password = +client = qbittorrent +default_download_dir = /media/HDD/Jellyfin/Anime +default_meta = [WEBRip-1080p][UK+JA][Ukr Sub] +wait_time = 10 +client_wait_time = 2 +``` +* ### titles.ini +```ini +[ArknightsTouin] +episode_index = 2 +season_number = 02 +ext_name = .mkv +torrent_name = "Arknights: Touin Kiro (2022)" +download_dir = /media/HDD/Jellyfin/Anime +publishdate = 24-05-23 21:32 +release_group = InariDuB +meta = [WEBRip-1080p][UK+JA][Ukr Sub] +hash = 97e3023362ebb41263f3266ac3a72cc56eda0885 +adjusted_episode_number = -8 +guid = t678205 + +[Tsukimichi] +episode_index = 2 +season_number = 02 +ext_name = .mkv +torrent_name = "Tsukimichi -Moonlit Fantasy- (2021)" +download_dir = /media/HDD/Jellyfin/Anime +publishdate = 24-05-28 17:16 +release_group = FanVoxUA +meta = [WEBRip-1080p][UK][Ukr Sub] +hash = 8bcb2b32b4885e6c4a03f909486a03f26a4c9a62 +adjusted_episode_number = 0 +guid = t675888 +``` + +### Конфігурація епізодів аніме + + +| Властивість | ArknightsTouin | Tsukimichi | Визначення | +|------------------------|-----------------------------------------------------|------------------------------------------------|----------------------------------------------------------------------| +| episode_index | 2 | 2 | Індекс, що вказує номер епізоду(звідки брати номер епізоду) | +| season_number | 02 | 02 | Номер сезону | +| ext_name | .mkv | .mkv | Формат файлу | +| torrent_name | "Arknights: Touin Kiro (2022)" | "Tsukimichi -Moonlit Fantasy- (2021)" | Базове ім'я для генерації назви торрента, тек та файлів | +| download_dir | | /media/HDD/Jellyfin/Anime | Директорія для завантаження медіа (використовується в Transmission) | +| publishdate | 2024-05-23 | 2024-05-21 | Системне значення для визначення оновлень торренту | +| release_group | InariDuB | FanVoxUA | Реліз група або автор роздачі | +| meta | [WEBRip-1080p][UK+JA][Ukr Sub] | [WEBRip-1080p][UK][Ukr Sub] | Додаткові метадані, які будуть додані у назву | +| hash | 97e...0885 | 12 | Системне значення - ID торрент файлу для майбутнього пошуку | +| adjusted_episode_number | -8 | 0 | Коригування номера епізоду сезону для абсолютного або азіатського неймінгу | +| guid | t678205 | t675888 | Системне значення для ідентифікації конкретного аніме у списку | + + + +## Authors + +- [@CakesTwix](https://www.github.com/CakesTwix) + + +

+ + +## License + +- [GPL-v3](https://choosealicense.com/licenses/gpl-3.0/) + diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..c213c81 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,7 @@ +from app import app +import os + +if __name__ == '__main__': + # Set the default port to 5000 if not specified + port = int(os.getenv('PORT', 5000)) + app.run(host='0.0.0.0', port=port) \ No newline at end of file diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..a865d1b --- /dev/null +++ b/app/app.py @@ -0,0 +1,227 @@ +import logging +from flask import Flask, jsonify, request, render_template, session, Response +import requests + +import toloka2MediaServer +import toloka2MediaServer.config_parser + + +app_config, titles_config, application_config = toloka2MediaServer.config_parser.load_configurations( + app_config_path='data/app.ini', + title_config_path='data/titles.ini' + ) + +config = toloka2MediaServer.model.Config( + toloka=toloka2MediaServer.config_parser.get_toloka_client(application_config), + app_config=app_config, + titles_config=titles_config, + application_config=application_config +) + +config.client = toloka2MediaServer.config_parser.dynamic_client_init(config) + +app = Flask(__name__) +app.secret_key = 'your_secret_key' # Set this to a strong secret value + +logger = logging.basicConfig( + filename='toloka2MediaServer/data/app_web.log', # Name of the file where logs will be written + filemode='a', # Append mode, which will append the logs to the file if it exists + format='%(asctime)s - %(levelname)s - %(message)s', # Format of the log messages + level=logging.DEBUG #log level from config +) +class RequestData: + url: str = "" + season: int = 0 + index: int = 0 + correction: int = 0 + title: str = "" + codename: str = "" + force: bool = False + def __init__(self, url = "", season = 0, index = 0, correction = 0, title = "", codename = "", force=False): + self.url = url + self.season = season + self.index = index + self.correction = correction + self.title = title + self.codename = codename + self.force = force + + +@app.route('/', methods=['GET']) +def index(): + titles = toloka2MediaServer.config.update_titles() + # Creating a list of dictionaries, each containing the data for the selected keys + data = [] + codenames =[] + keys = ['torrent_name', 'publish_date', 'guid'] + for section in titles.sections(): + codenames.append(section) + section_data = {'codename': section} + for key in keys: + section_data[key] = titles.get(section, key) + data.append(section_data) + + # Define column headers and rename them + columns = { + 'codename': 'Codename', + 'torrent_name': 'Name', + 'publish_date': 'Last Updated', + 'guid': 'URL' + } + output = session.pop('output', {}) + return render_template('index.html', data=data, columns=columns, codenames=codenames, output=output) + +@app.route('/get_titles', methods=['GET']) +def get_titles(): + titles = toloka2MediaServer.config.update_titles() + + # Extract sections from the ConfigParser object + sections = {} + for section in titles.sections(): + options = {} + for option in titles.options(section): + options[option] = titles.get(section, option) + sections[section] = options + + # Convert the sections data to JSON format + response = jsonify(sections) + + # Return the JSON response + return response + +@app.route('/get_torrents', methods=['GET']) +def get_torrents(): + # Extract the search parameter from the URL query string + search_query = request.args.get('query', default=None, type=str) + + if search_query: + # Convert data to JSON format + torrents = toloka2MediaServer.main_logic.search_torrents(search_query, logger) + response = jsonify(torrents) + + # Return the JSON response + return response, 200 + else: + return [] + +@app.route('/get_torrent', methods=['GET']) +def get_torrent(): + # Extract the search parameter from the URL query string + id = request.args.get('id', default=None, type=str) + + if id: + # Convert data to JSON format + torrent = toloka2MediaServer.main_logic.get_torrent(id, logger) + response = jsonify(torrent) + + # Return the JSON response + return response, 200 + else: + return [] + +@app.route('/add_torrent', methods=['GET']) +def add_torrent(): + # Extract the search parameter from the URL query string + id = request.args.get('id', default=None, type=str) + + if id: + # Convert data to JSON format + toloka2MediaServer.main_logic.add_torrent(id, logger) + + + # Return the JSON response + return [], 200 + else: + return [] + +@app.route('/add_release', methods=['POST']) +def add_release(): + # Process the URL to add release + try: + requestData = RequestData( + url = request.form['url'], + season = request.form['season'], + index = int(request.form['index']), + correction = int(request.form['correction']), + title = request.form['title'], + ) + + + #--add --url https://toloka.to/t675888 --season 02 --index 2 --correction 0 --title "Tsukimichi -Moonlit Fantasy-" + + operation_result = toloka2MediaServer.main_logic.add_release_by_url(requestData, logger) + output = serialize_operation_result(operation_result) + output = jsonify(output) + + return output, 200 + except Exception as e: + message = f'Error: {str(e)}' + return jsonify({"error": message}), 200 + +@app.route('/update_release', methods=['POST']) +def update_release(): + # Process the name to update release + try: + requestData = RequestData( + codename = request.form['codename'] + ) + operation_result = toloka2MediaServer.main_logic.update_release_by_name(requestData, requestData.codename, logger) + output = serialize_operation_result(operation_result) + output = jsonify(output) + + return output, 200 + except Exception as e: + message = f'Error: {str(e)}' + return jsonify({"error": message}), 200 + +@app.route('/update_all_releases', methods=['POST']) +def update_all_releases(): + # Process to update all releases + try: + requestData = RequestData() + operation_result = toloka2MediaServer.main_logic.update_releases(requestData, logger) + output = serialize_operation_result(operation_result) + output = jsonify(output) + + return output, 200 + except Exception as e: + message = f'Error: {str(e)}' + return jsonify({"error": message}), 200 + +def serialize_operation_result(operation_result): + return { + "operation_type": operation_result.operation_type.name if operation_result.operation_type else None, + "torrent_references": [str(torrent) for torrent in operation_result.torrent_references], + "titles_references": [str(titles) for titles in operation_result.titles_references], + "status_message": operation_result.status_message, + "response_code": operation_result.response_code.name if operation_result.response_code else None, + "operation_logs": operation_result.operation_logs, + "start_time": operation_result.start_time.isoformat() if operation_result.start_time else None, + "end_time": operation_result.end_time.isoformat() if operation_result.end_time else None + } + +@app.route('/image/') +def proxy_image(): + #Get the full URL from the query parameter + url = request.args.get('url') + if not url: + return "No URL provided", 400 + + # Normalize the URL + if url.startswith('//'): + url = 'https:' + url # Assume https if protocol is missing + elif not url.startswith(('http://', 'https://')): + url = 'https://' + url # Assume https if only hostname is provided + + # Send a GET request to the image URL + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36' + } + response = requests.get(url, headers=headers, stream=True) + + # Check if the request was successful + if response.status_code != 200: + return "Failed to fetch image", response.status_code + + # Stream the response content directly to the client + return Response(response.iter_content(chunk_size=1024), content_type=response.headers['Content-Type']) \ No newline at end of file diff --git a/app/static/color-modes.js b/app/static/color-modes.js new file mode 100644 index 0000000..c5054f5 --- /dev/null +++ b/app/static/color-modes.js @@ -0,0 +1,81 @@ +/*! + * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under the Creative Commons Attribution 3.0 Unported License. + */ + +(() => { + 'use strict' + + const getStoredTheme = () => localStorage.getItem('theme') + const setStoredTheme = theme => localStorage.setItem('theme', theme) + + const getPreferredTheme = () => { + const storedTheme = getStoredTheme() + if (storedTheme) { + return storedTheme + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + + const setTheme = theme => { + if (theme === 'auto') { + document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) + } else { + document.documentElement.setAttribute('data-bs-theme', theme) + } + } + + setTheme(getPreferredTheme()) + + const showActiveTheme = (theme, focus = false) => { + const themeSwitcher = document.querySelector('#bd-theme') + + if (!themeSwitcher) { + return + } + + const themeSwitcherText = document.querySelector('#bd-theme-text') + const activeThemeIcon = document.querySelector('.theme-icon-active use') + const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`) + const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href') + + document.querySelectorAll('[data-bs-theme-value]').forEach(element => { + element.classList.remove('active') + element.setAttribute('aria-pressed', 'false') + }) + + btnToActive.classList.add('active') + btnToActive.setAttribute('aria-pressed', 'true') + activeThemeIcon.setAttribute('href', svgOfActiveBtn) + const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})` + themeSwitcher.setAttribute('aria-label', themeSwitcherLabel) + + if (focus) { + themeSwitcher.focus() + } + } + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const storedTheme = getStoredTheme() + if (storedTheme !== 'light' && storedTheme !== 'dark') { + setTheme(getPreferredTheme()) + } + }) + + window.addEventListener('DOMContentLoaded', () => { + showActiveTheme(getPreferredTheme()) + + document.querySelectorAll('[data-bs-theme-value]') + .forEach(toggle => { + toggle.addEventListener('click', () => { + const theme = toggle.getAttribute('data-bs-theme-value') + setStoredTheme(theme) + setTheme(theme) + showActiveTheme(theme, true) + }) + }) + }) + })() + \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..58e974c --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,103 @@ +main > .container-fluid { + padding: 60px 15px 0; + } + .bd-placeholder-img { + font-size: 1.125rem; + text-anchor: middle; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + } + + @media (min-width: 768px) { + .bd-placeholder-img-lg { + font-size: 3.5rem; + } + } + + .b-example-divider { + width: 100%; + height: 3rem; + background-color: rgba(0, 0, 0, .1); + border: solid rgba(0, 0, 0, .15); + border-width: 1px 0; + box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15); + } + + .b-example-vr { + flex-shrink: 0; + width: 1.5rem; + height: 100vh; + } + + .bi { + vertical-align: -.125em; + fill: currentColor; + } + + .nav-scroller { + position: relative; + z-index: 2; + height: 2.75rem; + overflow-y: hidden; + } + + .nav-scroller .nav { + display: flex; + flex-wrap: nowrap; + padding-bottom: 1rem; + margin-top: -1px; + overflow-x: auto; + text-align: center; + white-space: nowrap; + -webkit-overflow-scrolling: touch; + } + + .btn-bd-primary { + --bd-violet-bg: #712cf9; + --bd-violet-rgb: 112.520718, 44.062154, 249.437846; + + --bs-btn-font-weight: 600; + --bs-btn-color: var(--bs-white); + --bs-btn-bg: var(--bd-violet-bg); + --bs-btn-border-color: var(--bd-violet-bg); + --bs-btn-hover-color: var(--bs-white); + --bs-btn-hover-bg: #6528e0; + --bs-btn-hover-border-color: #6528e0; + --bs-btn-focus-shadow-rgb: var(--bd-violet-rgb); + --bs-btn-active-color: var(--bs-btn-hover-color); + --bs-btn-active-bg: #5a23c8; + --bs-btn-active-border-color: #5a23c8; + } + + .bd-mode-toggle { + z-index: 1500; + } + + .bd-mode-toggle .dropdown-menu .active .bi { + display: block !important; + } + +.card { + margin-top: 20px; +} +.full-width-button { + width: 100%; +} +.result-list { + position: absolute; + margin-top: 2px; + background: white; + border: 1px solid #ccc; + border-radius: 0.25rem; + box-shadow: 0 8px 16px rgba(0,0,0,0.1); + z-index: 1000; +} +.list-group-item { + display: flex; + justify-content: space-between; +} + +.card-body { + min-height: 190px; +} \ No newline at end of file diff --git a/app/static/titles.js b/app/static/titles.js new file mode 100644 index 0000000..f5af457 --- /dev/null +++ b/app/static/titles.js @@ -0,0 +1,304 @@ +$(document).ready(function() { + var table = $('#dataTableTitles').DataTable({ + ajax: { + url: '/get_titles', + dataSrc: function(json) { + var result = []; + Object.keys(json).forEach(function(key) { + var item = json[key]; + item.codename = key; + result.push(item); + }); + return result; + } + }, + columns: [ + { data: 'codename', title: 'Codename', visible: true }, + { data: 'torrent_name', title: 'Torrent Name', visible: true }, + { data: 'adjusted_episode_number', title: 'Adjusted Episode Number', visible: false }, + { data: 'download_dir', title: 'Download Dir', visible: false }, + { data: 'episode_index', title: 'Episode Index', visible: false }, + { data: 'ext_name', title: 'Ext Name', visible: false }, + { data: 'guid', title: 'GUID', render: function(data, type, row) { + return `${data}`; + }, visible: true }, + { data: 'hash', title: 'Hash', visible: false }, + { data: 'meta', title: 'Meta', visible: false }, + { data: 'publish_date', title: 'Publish Date', visible: true }, + { data: 'release_group', title: 'Release Group', visible: false }, + { data: 'season_number', title: 'Season Number' , visible: false}, + { data: null, title: 'Actions', orderable: false, render: function(data, type, row) { + return ` + + + + `; + }, visible: true } + ], + order: [[9, 'des']], + columnDefs: [ + { + searchPanes: { + show: true + }, + targets: [1, 8, 10] + } + ], + layout: { + topStart: { + buttons: [ + { + extend: 'colvis', + postfixButtons: ['colvisRestore'], + text: '', + titleAttr: 'Column Visibility' + + }, + { + extend: 'searchPanes', + className: 'btn btn-secondary', + config: { + cascadePanes: true + } + + }, + { + action: function ( e, dt, node, config ) {dt.ajax.reload();}, + text: '', + titleAttr: 'Refresh' + }, + { + extend: 'pageLength', + className: 'btn btn-secondary' + } + ] + }, + top1End: + { + buttons:[ + { + text: 'Add', + className: 'btn btn-primary', + action: function () { + const leftSideAdd = document.querySelector('#leftSideAdd'); + leftSideAdd.classList.toggle('d-none'); + const rightSideTitles = document.querySelector('#rightSideTitles'); + rightSideTitles.classList.toggle('col-md-12'); + rightSideTitles.classList.toggle('col-md-8'); + } + }, + { + text: 'Update All', + className: 'btn btn-primary', + action: function ( e, dt, node, config) { + node[0].disabled = true; + node[0].innerHTML = ' Loading...'; + + fetch('/update_all_releases', { method: 'POST' }) + .then(response => response.json()) + .then(result => { + node[0].innerHTML = ' Update All'; + node[0].disabled = false; + + const bsOperationOffcanvas = new bootstrap.Offcanvas('#offcanvasOperationResults'); + generateOffCanvas(result); // Display operation status + bsOperationOffcanvas.toggle(); + window.refreshTable(); + }); + } + } + ] + } + }, + language: { + search: "_INPUT_", + searchPlaceholder: "Search records" + } + }); + + window.refreshTable = function() { + table.ajax.reload(); + }; + + document.querySelector('#dataTableTitles tbody').addEventListener('click', function(event) { + let target = event.target; + // Traverse up to find the element with 'action-update' class + while (target && !target.classList.contains('action-update')) { + if (target === this) { // Stop if we reach the container without finding the class + return; + } + target = target.parentNode; + } + if (target.classList.contains('action-update')) { + let tr = target.closest('tr'); + let row = table.row(tr).data(); + + target.disabled = true; + target.innerHTML = ' Loading...'; + + // Assuming `codename` is a property of the row data + let formData = new FormData(); + formData.append('codename', row.codename); + + fetch('/update_release', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(detail => { + target.innerHTML = ' Update'; + target.disabled = false; + + const bsOperationOffcanvas = new bootstrap.Offcanvas('#offcanvasOperationResults'); + generateOffCanvas(detail); // Display operation status + bsOperationOffcanvas.toggle(); + window.refreshTable(); + }) + .catch(error => { + console.error('Error:', error); + target.innerHTML = ' Update'; + target.disabled = false; + }); + } + }); + + const urlButton = document.querySelector('#urlButton'); + const filenameIndex = document.querySelector('#filenameIndex'); + const filenameIndexGroup = document.querySelector('#filenameIndexGroup'); + const cutButton = document.querySelector('#cutButton'); + const releaseTitle = document.querySelector('#releaseTitle'); + const submitButton = document.querySelector('#submitButton'); + const releaseForm = document.querySelector('#releaseForm'); + + urlButton.addEventListener('click', () => { + filenameIndexGroup.classList.toggle("d-none"); + }); + + filenameIndex.addEventListener('input', extractNumbers); + function extractNumbers() { + const input = filenameIndex.value; + const numbers = input.split('').map((ch) => (ch >= '0' && ch <= '9') ? ch : ' ').join('').trim().split(/\s+/); + const resultList = document.querySelector('#numberList'); + resultList.innerHTML = ''; + + numbers.forEach((number, index) => { + if (number !== '') { + const item = document.createElement('div'); + item.className = 'list-group-item'; + item.textContent = `Index: ${index+1}, Number: ${number}`; + item.addEventListener('click', () => { + document.querySelector('#index').value = index + 1; + resultList.style.display = 'none'; + }); + resultList.appendChild(item); + } + }); + + resultList.style.display = numbers.join('').length === 0 ? 'none' : 'block'; + } + + cutButton.addEventListener('click', () => { + const delimiterIndex = releaseTitle.value.search(/[\/|]/); + if (delimiterIndex !== -1) { + releaseTitle.value = releaseTitle.value.substring(delimiterIndex + 1); + } + }); + + releaseForm.addEventListener('submit', async (e) => { + e.preventDefault(); + submitButton.innerHTML = ' Loading...'; + submitButton.disabled = true; + const formData = new FormData(releaseForm); + const response = await fetch('/add_release', { + method: 'POST', + body: formData + }); + const result = await response.json(); + submitButton.innerHTML = 'Submit'; + submitButton.disabled = false; + + const bsOperationOffcanvas = new bootstrap.Offcanvas('#offcanvasOperationResults') + generateOffCanvas(result); // Display operation status + bsOperationOffcanvas.toggle() + window.refreshTable(); + }); + + releaseForm.addEventListener('submit', async (e) => { + e.preventDefault(); + submitButton.innerHTML = ' Loading...'; + submitButton.disabled = true; + const formData = new FormData(releaseForm); + const response = await fetch('/add_release', { + method: 'POST', + body: formData + }); + const result = await response.json(); + submitButton.innerHTML = 'Submit'; + submitButton.disabled = false; + + const bsOperationOffcanvas = new bootstrap.Offcanvas('#offcanvasOperationResults') + generateOffCanvas(result); // Display operation status + bsOperationOffcanvas.toggle() + window.refreshTable(); + }); + + function generateOffCanvas(response) { + if (!response) return; // Exit if no response data + + // Determine alert and badge classes based on the response code + const alertClass = response.response_code === 'SUCCESS' ? 'alert-success' : + response.response_code === 'FAILURE' ? 'alert-danger' : 'alert-warning'; + const badgeClass = response.response_code === 'SUCCESS' ? 'bg-success' : + response.response_code === 'FAILURE' ? 'bg-danger' : 'bg-warning'; + + // Helper function to generate list items for accordion + function generateListItems(items) { + return items.map(item => `
  • ${item}
  • `).join(''); + } + + // Generate accordion HTML + function generateAccordion(id, headingText, items) { + return ` +
    +
    +

    + +

    +
    +
    +
      + ${generateListItems(items)} +
    +
    +
    +
    +
    + `; + } + + // Generate the entire card with accordions + const cardHTML = ` +
    +
    +
    Operation Type: ${response.operation_type.replace('_', ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
    +

    Response Code: ${response.response_code}

    +

    Start Time: ${response.start_time}

    +

    End Time: ${response.end_time}

    + ${response.titles_references ? generateAccordion('TitlesAccordion', 'Titles References', response.titles_references) : ''} + ${response.torrent_references ? generateAccordion('torrentAccordion', 'Torrent References', response.torrent_references) : ''} + ${response.operation_logs ? generateAccordion('logsAccordion', 'Operation Logs', response.operation_logs) : ''} +
    +

    Create an issue on github if something wrong.

    +
    +
    + `; + + // Insert the generated HTML into a predefined container in your HTML + document.getElementById('offcanvasBody').innerHTML = cardHTML; + } + +}); + + diff --git a/app/static/toloka.js b/app/static/toloka.js new file mode 100644 index 0000000..1140944 --- /dev/null +++ b/app/static/toloka.js @@ -0,0 +1,258 @@ +$(document).ready(function () { + var initialized = false; + var table; + + // Handle form submission event + $('.d-flex[role="search"]').on('submit', function (e) { + e.preventDefault(); + var query = $(this).find('input[type="search"]').val(); + const bsOffcanvas = new bootstrap.Offcanvas('#offcanvasTopSearchResults') + bsOffcanvas.toggle() + if (!initialized) { + // Initialize DataTable + table = $('#torrentTable').DataTable({ + ajax: { + url: "/get_torrents?query=" + query, + dataSrc: function(json) { + var result = json; + return result; + } + }, + columns: [ + { + className: 'details-control', + orderable: false, + data: null, + defaultContent: '', + render: function () { + return ' '; + }, + width: "15px" + }, + { data: "forum", title: 'Forum', visible: true }, + { data: "name", title: 'Title', visible: true }, + { data: "author", title: 'Author', visible: true }, + { data: "date", title: 'Last Updated', visible: true }, + { data: "answers", title: 'answers', visible: false }, + { data: "forum_url", title: 'forum_url', visible: false }, + { data: "leechers", title: 'leechers', visible: false }, + { data: "seeders", title: 'seeders', visible: false }, + { data: "size", title: 'size', visible: false }, + { data: "status", title: 'status', visible: false }, + { data: "torrent_url", title: 'torrent_url', visible: false }, + { data: "url", title: 'url', render: function(data, type, row) { + return `${data}`; + }, visible: true }, + { data: "verify", title: 'verify', visible: false }, + { data: null, title: 'Actions', orderable: false, render: function(data, type, row) { + return ` + + + + `; + }, visible: true } + ], + order: [[4, 'des']], + columnDefs: [ + { + searchPanes: { + show: true + }, + targets: [1, 3] + } + ], + layout: { + topStart: { + buttons: [ + { + extend: 'colvis', + postfixButtons: ['colvisRestore'], + text: '', + titleAttr: 'Column Visibility' + + }, + { + extend: 'searchPanes', + config: { + cascadePanes: true + } + } + ] + } + } + }); + + $('#torrentTable tbody').on('click', 'td.details-control', function () { + var tr = $(this).closest('tr'); + var row = table.row(tr); + + if (row.child.isShown()) { + row.child.hide(); + tr.removeClass('shown'); + } else { + var data = row.data(); + row.child(formatLoading()).show(); + tr.addClass('shown'); + + $.ajax({ + url: '/get_torrent?id=' + data.url, + type: 'GET', + success: function (detail) { + var childData = formatDetail(detail, data); + row.child(childData).show(); + tr.data('childData', detail); + } + }); + } + }); + + $('#torrentTable tbody').on('click', '.action-download, .action-copy, .action-add', function () { + var tr = $(this).closest('tr'); + var row = table.row(tr); + var data = row.data(); + var childData = tr.data('childData'); + + switch (true) { + case $(this).hasClass('action-download'): + performDownloadAction(data, childData); + break; + case $(this).hasClass('action-copy'): + performCopyAction(data, childData); + break; + case $(this).hasClass('action-add'): + performAddAction(data, childData); + break; + } + }); + + initialized = true; + $('#torrentTable').show(); + } else { + table.ajax.url('/get_torrents?query=' + query).load(); + } + }); + + function formatLoading() { + return '
    ' + + '
    ' + + 'Loading...' + + '
    ' + + '
    '; + } + + function formatDetail(detail, parentData) { + let fileItems = detail.files.map(file => ` +
  • +
    +
    ${file.folder_name}
    + ${file.file_name} +
    + ${file.size} +
  • + `).join(''); + + return ` +
    +
    +
    +
    +
    + ... +
    + +
    +
    +
    +
    +
    ${detail.author}
    +

    ${detail.name}

    +

    ${detail.description}

    +

    Last updated ${detail.date}

    +
    +
    +
    +
      ${fileItems}
    +
    +
    +
    +
    +
    + `; + } + + const bsOffcanvas = new bootstrap.Offcanvas('#offcanvasTopSearchResults') + + function performAddAction(rowData, childData) { + console.log('Add action triggered', rowData, childData); + + $.ajax({ + url: '/add_torrent?id=' + rowData.torrent_url, + type: 'GET', + success: function (detail) { + console.log('Not implemented YET', detail); + } + }); + + document.querySelector('#offcanvasTopSearchResults > div.offcanvas-header > button').click() + } + + function performCopyAction(rowData, childData) { + console.log('Copy action triggered', rowData, childData); + bsOffcanvas.hide() + + // Select the element with ID 'leftSideAdd' and ensure it does not have 'd-none' + const leftSideAdd = document.querySelector('#leftSideAdd'); + leftSideAdd.classList.remove('d-none'); + + // Select the element with ID 'rightSideTitles' and adjust its classes + const rightSideTitles = document.querySelector('#rightSideTitles'); + // Ensure 'col-md-8' is present + rightSideTitles.classList.add('col-md-8'); + // Ensure 'col-md-12' is not present + rightSideTitles.classList.remove('col-md-12'); + + document.querySelector('#releaseTitle').value = rowData.name; + document.querySelector('#tolokaUrl').value = `https://toloka.to/${rowData.url}`; + if(childData != null) + { + filePath = `${childData.files[0].folder_name}/${childData.files[0].file_name}` + var input = document.querySelector('#filenameIndex'); + document.querySelector('#filenameIndexGroup').classList.toggle("d-none"); + input.value = filePath + + const event = new Event('input', { + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(event); + } + document.querySelector('#offcanvasTopSearchResults > div.offcanvas-header > button').click() + } + + function performDownloadAction(rowData, childData) { + console.log('Download action triggered', rowData, childData); + var url = `https://toloka.to/${rowData.torrent_url}` + + downloadFile(url); + document.querySelector('#offcanvasTopSearchResults > div.offcanvas-header > button').click() + } + + function downloadFile(url) { + const link = document.createElement('a'); + link.href = url; + link.download = true; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +}); \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..e490e5e --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,241 @@ + + + + + + Toloka2MediaServer v2024.06.02.1611 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + +
    + + +
    +
    + +
    +
    + +
    +
    +
    +
    Add by toloka URL
    +
    +
    +
    + + +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    Added Titles
    +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    Search Results
    + +
    +
    + +
    +
    +
    +
    +
    Search Results
    + +
    +
    +
    +
    +
    + + +
    + + + + \ No newline at end of file diff --git a/app/templates/result.html b/app/templates/result.html new file mode 100644 index 0000000..4d76a1d --- /dev/null +++ b/app/templates/result.html @@ -0,0 +1,39 @@ + + + + + + Result - Toloka2MediaServer + + + + +
    +

    Result

    +

    Here is the output of your request:

    + + Try Again +
    + + + + + \ No newline at end of file diff --git a/crontab b/crontab new file mode 100644 index 0000000..2561cdb --- /dev/null +++ b/crontab @@ -0,0 +1 @@ +$CRON_SCHEDULE root python3 -m toloka2MediaServer \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..b807bce --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Start cron +cron +# Start the web server +exec python -m toloka2MediaServerWeb \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c98fbff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +#git+https://github.com/CakesTwix/toloka2python +git+https://github.com/maksii/toloka2python +git+https://github.com/maksii/Toloka2MediaServer.git@extract +Flask \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..e69de29