├── .github └── images │ └── jizzarr.png ├── .gitignore ├── LICENSE ├── README.md ├── jizzarr ├── app.py ├── config.py ├── models.py ├── search_stash.py ├── watcher.py └── web │ ├── static │ ├── Jdownloader.png │ ├── collection.css │ ├── config.css │ ├── favicon.ico │ ├── index.css │ ├── logs.css │ ├── script.js │ ├── stash.png │ ├── styles.css │ └── tpdblogo.png │ └── templates │ ├── collection.html │ ├── config.html │ ├── index.html │ ├── logs.html │ └── stats.html ├── poetry.lock └── pyproject.toml /.github/images/jizzarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Serechops/Jizzarr/ab59265a44f80a192dfd243d2ab20be3cface2f6/.github/images/jizzarr.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | .vscode 3 | .idea 4 | __pycache__/ 5 | .coverage 6 | /htmlcov/ 7 | cov.xml 8 | coverage.xml 9 | UNKNOWN.egg-info/ 10 | dist/ 11 | node_modules 12 | .env 13 | .flakeheaven_cache 14 | .ruff_cache 15 | Thumbs.db 16 | jizzarr/instance 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jizzarr Jizzarr 2 | 3 | ## Introduction 4 | Jizzarr is a web application designed to manage and organize adult content metadata. It has the ability to integrate with both TPDB and Stash. 5 | 6 | ## Releases 7 | 8 | Please check the [Releases](https://github.com/Serechops/Jizzarr/releases) page for the latest Jizzarr binaries. 9 | 10 | ## Key Features 11 | 12 | 1. API Integration 13 | - **ThePornDB (TPDB) Integration**: Fetches scene details and metadata from TPDB. 14 | - **Stash Integration**: Allows population of sites and scenes from the Stash service. 15 | 16 | ### 2. Scene Matching 17 | Jizzarr offers a powerful scene matching feature using fuzzy logic and UUID tags. It supports: 18 | - Matching scenes based on title, date, performers, and duration. 19 | - Using custom UUID tags embedded in video files for precise matching. 20 | 21 | ### 3. Library Directory Management 22 | Jizzarr allows setting up and managing library directories. It can: 23 | - Scan and match directories with site names. 24 | - Automatically update site directories with new files. 25 | 26 | ### 4. User Interface 27 | - **Configuration Page**: Set up and manage endpoints, API keys, and library directories. 28 | - **Collection Page**: View and manage the collection of sites and scenes. 29 | - **Statistics Page**: Display comprehensive statistics about the collection. 30 | - **Logs Page**: View and download application logs. 31 | 32 | ### 5. System Tray Integration 33 | A system tray icon provides easy access to open the application in a browser and quit the application. 34 | 35 | ## Getting Started 36 | 37 | ### Prerequisites 38 | - Python 3.9 or later 39 | - Poetry 40 | 41 | ### Installation 42 | 43 | 1. Clone the repository: 44 | ```sh 45 | git clone https://github.com/your-repository/jizzarr.git 46 | cd jizzarr 47 | ``` 48 | 49 | 2. Install the required packages: 50 | ```sh 51 | poetry install 52 | ``` 53 | 54 | ### Running the Application 55 | 56 | 1. Start the application: 57 | ```sh 58 | poetry run python app.py 59 | ``` 60 | 61 | 2. Open your browser and navigate to `http://127.0.0.1:6900`. 62 | 63 | ## Usage 64 | 65 | ### Configuration 66 | - Navigate to the configuration page to set up Stash and TPDB API keys, endpoints, and download folder. 67 | - Add or remove library directories. 68 | 69 | ### Collection Management 70 | - View and manage sites and scenes in your collection. 71 | - Add new sites and scenes manually or populate from Stash. 72 | 73 | ### Scene Matching 74 | - Use the scene matching feature to automatically match video files with scenes in the database. 75 | - View potential matches and manually match scenes if necessary. 76 | 77 | ### Logs 78 | - Monitor application logs in real-time. 79 | - Download logs for offline review or troubleshooting. 80 | 81 | ### Statistics 82 | - View detailed statistics about the number of scenes, total and collected duration, average site rating, and more. 83 | 84 | ## License 85 | This project is licensed under the Unilicense. 86 | 87 | ## Acknowledgments 88 | - To Gykes and having this crazy inspiration! 89 | - ThePornDB and Stash for their comprehensive adult content databases and media management system. 90 | -------------------------------------------------------------------------------- /jizzarr/config.py: -------------------------------------------------------------------------------- 1 | class Config: 2 | SQLALCHEMY_DATABASE_URI = 'sqlite:///jizzarr.db' 3 | SQLALCHEMY_TRACK_MODIFICATIONS = False 4 | SQLALCHEMY_ENGINE_OPTIONS = { 5 | 'pool_size': 20, 6 | 'max_overflow': 30, 7 | 'pool_timeout': 30, 8 | 'pool_recycle': 1800, 9 | } 10 | -------------------------------------------------------------------------------- /jizzarr/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from flask_sqlalchemy import SQLAlchemy 4 | from sqlalchemy import Index 5 | from sqlalchemy.dialects.sqlite import JSON 6 | from sqlalchemy.orm import configure_mappers 7 | 8 | db = SQLAlchemy() 9 | 10 | 11 | class Config(db.Model): 12 | id = db.Column(db.Integer, primary_key=True) 13 | key = db.Column(db.String, unique=True, nullable=False) 14 | value = db.Column(db.String, nullable=False) 15 | 16 | 17 | class Site(db.Model): 18 | id = db.Column(db.Integer, primary_key=True) 19 | uuid = db.Column(db.String, unique=True, nullable=False) 20 | name = db.Column(db.String, nullable=False) 21 | url = db.Column(db.String) 22 | description = db.Column(db.Text) 23 | rating = db.Column(db.Float) 24 | network = db.Column(db.String) 25 | parent = db.Column(db.String) 26 | logo = db.Column(db.String) 27 | home_directory = db.Column(db.String) 28 | scenes = db.relationship('Scene', backref='site', lazy=True) 29 | 30 | 31 | class Scene(db.Model): 32 | id = db.Column(db.Integer, primary_key=True) 33 | site_id = db.Column(db.Integer, db.ForeignKey('site.id'), nullable=False) 34 | title = db.Column(db.String, nullable=False) 35 | date = db.Column(db.String) 36 | duration = db.Column(db.Integer) 37 | image = db.Column(db.String) 38 | performers = db.Column(JSON) 39 | status = db.Column(db.String) 40 | local_path = db.Column(db.String) 41 | year = db.Column(db.Integer) 42 | episode_number = db.Column(db.Integer) 43 | slug = db.Column(db.String) 44 | overview = db.Column(db.Text) 45 | credits = db.Column(JSON) 46 | release_date_utc = db.Column(db.String) 47 | images = db.Column(JSON) 48 | trailer = db.Column(db.String) 49 | genres = db.Column(JSON) 50 | foreign_guid = db.Column(db.String) 51 | foreign_id = db.Column(db.Integer) 52 | url = db.Column(db.String(255)) 53 | 54 | def __init__(self, site_id, title, date, duration, image, performers, status, local_path, year, episode_number, slug, overview, credits, release_date_utc, images, trailer, genres, foreign_guid, foreign_id, url): 55 | self.site_id = site_id 56 | self.title = title 57 | self.date = date 58 | self.duration = duration 59 | self.image = image 60 | self.performers = performers 61 | self.status = status 62 | self.local_path = local_path 63 | self.year = year 64 | self.episode_number = episode_number 65 | self.slug = slug 66 | self.overview = overview 67 | self.credits = credits 68 | self.release_date_utc = release_date_utc 69 | self.images = images 70 | self.trailer = trailer 71 | self.genres = genres 72 | self.foreign_guid = foreign_guid 73 | self.foreign_id = foreign_id 74 | self.url = url 75 | 76 | def to_dict(self): 77 | return { 78 | 'id': self.id, 79 | 'site_id': self.site_id, 80 | 'title': self.title, 81 | 'date': self.date, 82 | 'duration': self.duration, 83 | 'image': self.image, 84 | 'performers': self.performers, 85 | 'status': self.status, 86 | 'local_path': self.local_path, 87 | 'year': self.year, 88 | 'episode_number': self.episode_number, 89 | 'slug': self.slug, 90 | 'overview': self.overview, 91 | 'credits': self.credits, 92 | 'release_date_utc': self.release_date_utc, 93 | 'images': self.images, 94 | 'trailer': self.trailer, 95 | 'genres': self.genres, 96 | 'foreign_guid': self.foreign_guid, 97 | 'foreign_id': self.foreign_id, 98 | 'url': self.url, 99 | } 100 | 101 | 102 | class LibraryDirectory(db.Model): 103 | id = db.Column(db.Integer, primary_key=True) 104 | path = db.Column(db.String, nullable=False, unique=True) 105 | 106 | 107 | # Configure mappers to use confirm_deleted_rows=False 108 | configure_mappers() 109 | for mapper in db.Model.registry.mappers: 110 | mapper.confirm_deleted_rows = False 111 | 112 | # Add indexing for the Scene model 113 | Index('idx_scene_foreign_guid', Scene.foreign_guid) 114 | 115 | 116 | class Log(db.Model): 117 | id = db.Column(db.Integer, primary_key=True) 118 | level = db.Column(db.String, nullable=False) 119 | message = db.Column(db.Text, nullable=False) 120 | timestamp = db.Column(db.DateTime, default=datetime.datetime.now(datetime.timezone.utc), nullable=False) 121 | -------------------------------------------------------------------------------- /jizzarr/search_stash.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import requests 4 | from flask import Flask 5 | 6 | from jizzarr.models import db, Site, Scene 7 | 8 | 9 | def create_app(): 10 | app = Flask(__name__) 11 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///jizzarr.db' 12 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 13 | db.init_app(app) 14 | return app 15 | 16 | 17 | def get_scenes_for_site(site_uuid): 18 | app = create_app() 19 | with app.app_context(): 20 | site = Site.query.filter_by(uuid=site_uuid).first() 21 | if not site: 22 | print(f'Site not found for UUID: {site_uuid}') 23 | return {'error': 'Site not found'}, 404 24 | 25 | print(f'Site found: {site.name}') 26 | 27 | scenes = Scene.query.filter_by(site_id=site.id).all() 28 | scene_list = [] 29 | for scene in scenes: 30 | scene_list.append({ 31 | 'id': scene.id, 32 | 'title': scene.title, 33 | 'date': scene.date, 34 | 'duration': scene.duration, 35 | 'image': scene.image, 36 | 'performers': scene.performers, 37 | 'status': scene.status, 38 | 'local_path': scene.local_path, 39 | 'year': scene.year, 40 | 'episode_number': scene.episode_number, 41 | 'slug': scene.slug, 42 | 'overview': scene.overview, 43 | 'credits': scene.credits, 44 | 'release_date_utc': scene.release_date_utc, 45 | 'images': scene.images, 46 | 'trailer': scene.trailer, 47 | 'genres': scene.genres, 48 | 'foreign_guid': scene.foreign_guid, 49 | 'foreign_id': scene.foreign_id 50 | }) 51 | 52 | return scene_list 53 | 54 | 55 | def search_stash_for_matches(scenes): 56 | stash_endpoint = "http://192.168.1.54:9999/graphql" 57 | stash_headers = { 58 | "Accept-Encoding": "gzip, deflate, br", 59 | "Content-Type": "application/json", 60 | "Accept": "application/json" 61 | } 62 | 63 | for scene in scenes: 64 | foreign_guid = scene.get('foreign_guid') 65 | if not foreign_guid: 66 | continue 67 | 68 | print(f'Searching Stash for ForeignGUID: {foreign_guid}') 69 | 70 | query = { 71 | "query": f""" 72 | query FindScenes {{ 73 | findScenes( 74 | scene_filter: {{ 75 | stash_id_endpoint: {{ 76 | stash_id: "{foreign_guid}" 77 | modifier: EQUALS 78 | }} 79 | }} 80 | ) {{ 81 | scenes {{ 82 | title 83 | files {{ 84 | path 85 | }} 86 | }} 87 | }} 88 | }} 89 | """ 90 | } 91 | 92 | try: 93 | response = requests.post(stash_endpoint, json=query, headers=stash_headers) 94 | if response.status_code != 200: 95 | print(f'Error {response.status_code}: {response.text}') 96 | continue 97 | 98 | result = response.json() 99 | matched_scenes = result['data']['findScenes']['scenes'] 100 | 101 | if matched_scenes: 102 | matched_scene = matched_scenes[0] # Assuming first match is the desired one 103 | title = matched_scene['title'] 104 | file_path = matched_scene['files'][0]['path'] if matched_scene['files'] else 'No file path' 105 | print(f'Match found for ForeignGUID {foreign_guid}: Title: {title}, File Path: {file_path}') 106 | 107 | except requests.exceptions.RequestException as e: 108 | print(f'Request failed: {e}') 109 | 110 | 111 | def main(): 112 | if len(sys.argv) != 2: 113 | print("Usage: python get_scenes.py ") 114 | sys.exit(1) 115 | 116 | site_uuid = sys.argv[1] 117 | scenes = get_scenes_for_site(site_uuid) 118 | if isinstance(scenes, dict) and 'error' in scenes: 119 | print(scenes['error']) 120 | else: 121 | search_stash_for_matches(scenes) 122 | 123 | 124 | if __name__ == "__main__": 125 | main() 126 | -------------------------------------------------------------------------------- /jizzarr/watcher.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import re 5 | import shutil 6 | import time 7 | from pathlib import Path 8 | 9 | import requests 10 | from flask import Flask 11 | from mutagen.mp4 import MP4, MP4Tags 12 | from sqlalchemy import create_engine, inspect 13 | from sqlalchemy.orm import sessionmaker 14 | from watchdog.events import FileSystemEventHandler 15 | from watchdog.observers import Observer 16 | 17 | from jizzarr.models import db, Site, Scene, Config 18 | 19 | logging.basicConfig(level=logging.INFO) 20 | logger = logging.getLogger(__name__) 21 | 22 | # Flask application for database context 23 | main_path = Path(__file__).parent.parent.resolve() / 'jizzarr' 24 | app = Flask(__name__) 25 | # Using an absolute path to the database file 26 | db_path = main_path / 'instance' / 'jizzarr.db' 27 | app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}' 28 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 29 | db.init_app(app) 30 | 31 | 32 | def initialize_database(): 33 | with app.app_context(): 34 | db.create_all() 35 | # Log the existing tables 36 | inspector = inspect(db.engine) 37 | tables = inspector.get_table_names() 38 | if 'site' not in tables: 39 | logger.error("The 'site' table does not exist. Ensure database initialization.") 40 | raise Exception("Database not properly initialized. 'site' table is missing.") 41 | 42 | 43 | class Watcher: 44 | DIRECTORY_TO_WATCH = None 45 | 46 | def __init__(self): 47 | self.observer = Observer() 48 | 49 | def run(self): 50 | if not self.DIRECTORY_TO_WATCH: 51 | logger.error("No directory to watch") 52 | return 53 | self.scan_existing_files(self.DIRECTORY_TO_WATCH) # Scan existing files on start 54 | event_handler = Handler() 55 | self.observer.schedule(event_handler, self.DIRECTORY_TO_WATCH, recursive=True) 56 | self.observer.start() 57 | logger.info(f"Started watching directory: {self.DIRECTORY_TO_WATCH}") 58 | try: 59 | while True: 60 | time.sleep(5) 61 | except KeyboardInterrupt: 62 | self.observer.stop() 63 | logger.info("Observer stopped") 64 | self.observer.join() 65 | 66 | @staticmethod 67 | def scan_existing_files(directory): 68 | logger.info(f"Scanning existing files in directory: {directory}") 69 | for root, _, files in os.walk(directory): 70 | for file in files: 71 | file_path = os.path.join(root, file) 72 | if file_path.endswith(('.mp4', '.m4v', '.mov', '.json')): 73 | logger.info(f"Processing existing file: {file_path}") 74 | Handler().process_file(file_path, directory) 75 | 76 | 77 | class Handler(FileSystemEventHandler): 78 | def process(self, event): 79 | if event.is_directory: 80 | return None 81 | elif event.event_type == 'created': 82 | logger.info(f"New file detected: {event.src_path}") 83 | 84 | # Ignore .part files 85 | if event.src_path.endswith('.part'): 86 | logger.info(f"Ignoring .part file: {event.src_path}") 87 | return 88 | 89 | # Wait for the file to be completely written 90 | time.sleep(5) # Adjust the delay as necessary 91 | 92 | # Ensure the file exists and is not empty 93 | if not os.path.exists(event.src_path) or os.path.getsize(event.src_path) == 0: 94 | logger.warning(f"File {event.src_path} does not exist or is empty after waiting") 95 | return 96 | 97 | if event.src_path.endswith(('.mp4', '.m4v', '.mov', '.json')): 98 | logger.info(f"Detected new file: {event.src_path}") 99 | self.process_file(event.src_path, Watcher.DIRECTORY_TO_WATCH) 100 | 101 | def on_created(self, event): 102 | logger.info(f"on_created event detected: {event.src_path}") 103 | self.process(event) 104 | 105 | @staticmethod 106 | def process_file(file_path, download_dir): 107 | if file_path.endswith('.json'): 108 | process_json_file(file_path, download_dir) 109 | else: 110 | uuid = fetch_custom_tag(file_path) 111 | if uuid: 112 | matched = match_scene_by_uuid(uuid, file_path) 113 | if matched: 114 | logger.info(f"Automatically matched file {file_path} by UUID {uuid}") 115 | else: 116 | logger.info(f"No match found for file {file_path} with UUID {uuid}") 117 | 118 | 119 | def fetch_custom_tag(file_path): 120 | try: 121 | video = MP4(file_path) 122 | if "----:com.apple.iTunes:UUID" in video: 123 | return video["----:com.apple.iTunes:UUID"][0] # Retrieve the custom UUID tag 124 | else: 125 | return None 126 | except Exception: 127 | return None 128 | 129 | 130 | def match_scene_by_uuid(uuid, file_path): 131 | with app.app_context(): 132 | engine = create_engine(f'sqlite:///{db_path}') 133 | session = sessionmaker(bind=engine)() 134 | 135 | try: 136 | matching_scene = session.query(Scene).filter_by(foreign_guid=uuid.decode('utf-8')).first() 137 | if matching_scene: 138 | matching_scene.local_path = file_path 139 | matching_scene.status = 'Found' 140 | session.commit() 141 | logger.info(f"Automatically matched scene ID: {matching_scene.id} with file: {file_path}") 142 | return True 143 | else: 144 | logger.info(f"No match found for UUID: {uuid}") 145 | return False 146 | except Exception as e: 147 | logger.error(f"Failed to match file {file_path} with UUID {uuid}: {e}") 148 | return False 149 | finally: 150 | session.close() 151 | 152 | 153 | def process_json_file(json_file_path, download_dir): 154 | with app.app_context(): 155 | engine = create_engine(f'sqlite:///{db_path}') 156 | session = sessionmaker(bind=engine)() 157 | 158 | try: 159 | json_file_path = str(json_file_path) # Ensure the path is a string 160 | if json_file_path.endswith('.json'): 161 | with open(json_file_path, 'r') as f: 162 | data = json.load(f) 163 | 164 | scene_url = data['URL'] 165 | original_filename = data['filename'] 166 | metadata = fetch_metadata(scene_url) 167 | 168 | if metadata: 169 | new_filename = construct_filename(metadata, original_filename) 170 | original_filepath = os.path.join(download_dir, original_filename) 171 | new_filepath = os.path.join(download_dir, new_filename) 172 | 173 | # Only rename if the original file exists 174 | if os.path.exists(original_filepath): 175 | try: 176 | os.rename(original_filepath, new_filepath) 177 | logger.info(f"Renamed {original_filename} to {new_filename}") 178 | 179 | # Tag the file with metadata before moving 180 | tag_file_with_metadata(new_filepath, metadata) 181 | logger.info(f"Metadata tagged for file {new_filepath}") 182 | 183 | # Update the JSON file with the new filename 184 | data['filename'] = new_filename 185 | new_json_filepath = os.path.join(download_dir, new_filename.replace(new_filename.split('.')[-1], 'json')) 186 | os.rename(json_file_path, new_json_filepath) 187 | with open(new_json_filepath, 'w') as f: 188 | json.dump(data, f, indent=4) 189 | logger.info(f"Updated and renamed JSON file {json_file_path} to {new_json_filepath} with new filename {new_filename}") 190 | 191 | # Move the files and update paths 192 | new_file_path, new_json_file_path = move_files_to_site_directory(new_filepath, new_json_filepath, metadata['site']['name'], session) 193 | 194 | # Added delay after moving the file 195 | time.sleep(2) 196 | 197 | # Directly match the scene using the scene URL 198 | if new_file_path: 199 | match_scene_with_uuid(metadata['foreign_guid'], new_file_path, session) 200 | else: 201 | logger.error(f"Failed to move file {new_filepath}") 202 | 203 | return new_filename 204 | except Exception as e: 205 | logger.error(f"Failed to rename file: {e}") 206 | else: 207 | logger.error(f"File {original_filename} not found.") 208 | return None 209 | else: 210 | logger.error("Failed to fetch metadata.") 211 | return None 212 | finally: 213 | session.close() 214 | 215 | 216 | def fetch_metadata(scene_url): 217 | response = requests.post('http://localhost:6900/get_metadata', json={'scene_url': scene_url}) 218 | if response.status_code == 200: 219 | return response.json() 220 | else: 221 | logger.error(f"Failed to fetch metadata: {response.status_code}") 222 | return None 223 | 224 | 225 | def construct_filename(metadata, original_filename): 226 | performers = ', '.join([performer['name'] for performer in metadata['performers']]) 227 | file_extension = original_filename[original_filename.rfind('.'):] 228 | new_filename = f"{metadata['site']['name']} - {metadata['date']} - {metadata['title']} - {performers}" 229 | return sanitize_filename(new_filename + file_extension) 230 | 231 | 232 | def tag_file_with_metadata(file_path, metadata): 233 | try: 234 | logger.info(f"Tagging file {file_path} with metadata: {metadata}") 235 | video = MP4(file_path) 236 | video_tags = video.tags or MP4Tags() 237 | video_tags["\xa9nam"] = metadata['title'] 238 | video_tags["\xa9ART"] = ', '.join([performer['name'] for performer in metadata['performers']]) 239 | video_tags["\xa9alb"] = metadata['site']['name'] 240 | video_tags["\xa9day"] = metadata['date'] 241 | video_tags["desc"] = metadata['site']['name'] 242 | video_tags["----:com.apple.iTunes:UUID"] = [bytes(metadata['foreign_guid'], 'utf-8')] # Add custom UUID tag 243 | video.tags = video_tags 244 | video.save() 245 | except KeyError as e: 246 | logger.error(f"Metadata missing key: {e}") 247 | except Exception as e: 248 | logger.error(f"Failed to tag metadata: {e}") 249 | 250 | 251 | def sanitize_filename(filename): 252 | sanitized = re.sub(r'[<>:"/\\|?*]', '', filename) 253 | sanitized = re.sub(r'\s+', ' ', sanitized) 254 | sanitized = sanitized.strip() 255 | return sanitized 256 | 257 | 258 | def move_files_to_site_directory(file_path, json_file_path, site_name, session): 259 | try: 260 | site = session.query(Site).filter_by(name=site_name).first() 261 | if not site or not site.home_directory: 262 | logger.error(f"Site {site_name} not found or has no home directory set.") 263 | return None, None 264 | 265 | try: 266 | destination_dir = Path(site.home_directory) 267 | destination_dir.mkdir(parents=True, exist_ok=True) 268 | 269 | new_file_path = destination_dir / Path(file_path).name 270 | new_json_file_path = destination_dir / Path(json_file_path).name 271 | 272 | shutil.move(file_path, new_file_path) 273 | shutil.move(json_file_path, new_json_file_path) 274 | 275 | logger.info(f"Moved {file_path} to {new_file_path}") 276 | logger.info(f"Moved {json_file_path} to {new_json_file_path}") 277 | 278 | return str(new_file_path), str(new_json_file_path) 279 | 280 | except Exception as e: 281 | logger.error(f"Failed to move files: {e}") 282 | return None, None 283 | finally: 284 | session.close() 285 | 286 | 287 | def match_scene_with_uuid(uuid, file_path, session): 288 | try: 289 | logger.info(f"Matching file {file_path} with UUID {uuid}") 290 | scene = session.query(Scene).filter_by(foreign_guid=uuid).first() 291 | 292 | if scene: 293 | scene.local_path = file_path 294 | scene.status = 'Found' 295 | session.commit() 296 | logger.info(f"Automatically matched file {file_path} with UUID {uuid}") 297 | else: 298 | logger.error(f"No match found for UUID {uuid}") 299 | except Exception as e: 300 | logger.error(f"Failed to match file {file_path} with UUID {uuid}: {e}") 301 | 302 | 303 | def main(): 304 | with app.app_context(): 305 | initialize_database() 306 | download_folder = Config.query.filter_by(key='downloadFolder').first() 307 | if download_folder: 308 | Watcher.DIRECTORY_TO_WATCH = download_folder.value 309 | watcher = Watcher() 310 | watcher.run() 311 | 312 | 313 | if __name__ == '__main__': 314 | main() 315 | -------------------------------------------------------------------------------- /jizzarr/web/static/Jdownloader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Serechops/Jizzarr/ab59265a44f80a192dfd243d2ab20be3cface2f6/jizzarr/web/static/Jdownloader.png -------------------------------------------------------------------------------- /jizzarr/web/static/collection.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Montserrat', Arial, sans-serif; 3 | background-color: #121212; 4 | color: #ffffff; 5 | } 6 | 7 | .container { 8 | max-width: 1920px; 9 | margin: 0 auto; 10 | padding: 20px; 11 | position: relative; 12 | } 13 | 14 | #site-collection { 15 | margin-top: 30px; 16 | margin-bottom: 20px; 17 | } 18 | 19 | .site-card { 20 | background-color: #1e1e1e; 21 | color: #ffffff; 22 | border-radius: 8px; 23 | padding: 10px; 24 | margin-bottom: 10px; 25 | margin-top: 50px; 26 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); 27 | display: flex; 28 | align-items: center; 29 | transition: background-color 0.3s; 30 | cursor: pointer; 31 | position: relative; 32 | } 33 | 34 | .no-results { 35 | border: 2px solid red !important; 36 | } 37 | 38 | .site-card:hover { 39 | background-color: #333333; 40 | } 41 | 42 | .site-title { 43 | text-align: center; 44 | } 45 | 46 | .site-card img { 47 | max-width: 100%; 48 | max-height: 100px; 49 | object-fit: contain; 50 | margin-right: 10px; 51 | border-radius: 4px; 52 | justify-content: center; 53 | } 54 | 55 | .site-card h2 { 56 | margin: 0; 57 | display: none; 58 | } 59 | 60 | .site-card .no-logo h2 { 61 | display: block; 62 | } 63 | 64 | .site-card button { 65 | background-color: #ff5f6d; 66 | color: white; 67 | border: none; 68 | border-radius: 4px; 69 | cursor: pointer; 70 | display: block; 71 | margin-top: 20px; 72 | } 73 | 74 | button.btn.btn-danger.remove-site-button { 75 | position: absolute; 76 | bottom: 1px; 77 | left: 1px; 78 | } 79 | 80 | .site-card button.remove { 81 | background-color: red; 82 | } 83 | 84 | .highlight-missing { 85 | background-color: red !important; 86 | } 87 | 88 | .highlight-found { 89 | background-color: green !important; 90 | } 91 | 92 | th, td { 93 | padding: 10px; 94 | text-align: left; 95 | border-bottom: 1px solid #444; 96 | } 97 | 98 | th { 99 | background-color: #333; 100 | color: white; 101 | } 102 | 103 | .file-path-icon { 104 | color: green; 105 | margin-right: 5px; 106 | font-size: 1em; 107 | } 108 | 109 | .match-controls { 110 | display: flex; 111 | justify-content: center; 112 | gap: 5px; 113 | margin-top: 10px; 114 | 115 | } 116 | 117 | .scene-count { 118 | text-align: center; 119 | margin-bottom: 10px; 120 | margin-top: 10px; 121 | font-size: 1.2em; 122 | } 123 | 124 | .site-logo img { 125 | max-height: 200px; 126 | object-fit: cover; 127 | border-radius: 8px; 128 | } 129 | 130 | #compare-container.compare-container { 131 | text-align: center; 132 | } 133 | 134 | #back-button-container { 135 | margin-top: 25px; 136 | } 137 | 138 | .btn-dark-mode { 139 | background-color: #333; 140 | color: white; 141 | } 142 | 143 | .btn-dark-mode:hover { 144 | background-color: #444; 145 | } 146 | 147 | .table-dark-mode { 148 | background-color: #1e1e1e; 149 | color: white; 150 | } 151 | 152 | #site-logo { 153 | text-align: center; 154 | max-width: 100%; 155 | max-height: 100%; 156 | } 157 | 158 | .action-buttons { 159 | display: flex; 160 | align-items: center; 161 | justify-content: flex-start; 162 | gap: 5px; /* Adjust this value as needed */ 163 | } 164 | 165 | .highlight-found { 166 | background-color: green; 167 | color: white; 168 | } 169 | 170 | .favicon { 171 | width: 25px; 172 | height: 25px; 173 | margin-right: 8px; 174 | vertical-align: middle; 175 | } 176 | 177 | .custom-navbar { 178 | width: 100%; 179 | position: fixed; 180 | top: 0; 181 | z-index: 1000; 182 | background-color: rgba(0, 0, 0, 0.5); 183 | padding: 10px 0; 184 | display: flex; 185 | justify-content: center; 186 | align-items: center; 187 | backdrop-filter: blur(6px); 188 | } 189 | 190 | .custom-navbar a { 191 | color: #ffffff; 192 | text-decoration: none; 193 | margin: 0 10px; 194 | padding: 5px 10px; 195 | } 196 | 197 | .custom-navbar a:hover { 198 | background-color: #495057; 199 | border-radius: 4px; 200 | } 201 | 202 | .status.found { 203 | color: green; 204 | } 205 | 206 | .progress-bar { 207 | position: absolute; 208 | bottom: 10px; 209 | left: 50%; 210 | transform: translateX(-40%); 211 | width: 75%; 212 | background-color: rgba(0, 0, 0, 0.5); 213 | border-radius: 5px; 214 | height: 20px; 215 | } 216 | 217 | .progress-bar-inner { 218 | height: 100%; 219 | background-color: #007bff; 220 | border-radius: 5px; 221 | transition: width 0.3s; 222 | } 223 | 224 | .progress-bar-text { 225 | position: absolute; 226 | top: 50%; 227 | left: 50%; 228 | transform: translate(-50%, -50%); 229 | color: #fff; 230 | font-weight: bold; 231 | } 232 | 233 | .pagnination-controls { 234 | top: 1px; 235 | position: absolute; 236 | } 237 | 238 | .mb-3 { 239 | margin-top: 25px; 240 | } 241 | 242 | .jdownloader-icon { 243 | width: 24px; 244 | height: 24px; 245 | margin-left: 30px; 246 | cursor: pointer; 247 | vertical-align: middle; 248 | } 249 | 250 | .badge.bg-success { 251 | background-color: #28a745; 252 | color: #fff; 253 | padding: 0.5em; 254 | border-radius: 0.25em; 255 | font-size: 0.9em; 256 | margin-right: 0.5em; 257 | } 258 | 259 | #status { 260 | height: 100px; 261 | max-width: 100%; 262 | overflow-y: auto; 263 | border: 1px solid #ccc; 264 | padding: 10px; 265 | background-color: black; 266 | color: white; 267 | margin: 20px 0; 268 | } 269 | 270 | #status div { 271 | padding: 5px; 272 | border-bottom: 1px solid #eee; 273 | } 274 | 275 | #status div:last-child { 276 | border-bottom: none; 277 | } 278 | 279 | /* Fix the button size */ 280 | #get-all-urls-button { 281 | width: 200px; /* Adjust the width as needed */ 282 | height: 40px; /* Set a fixed height */ 283 | white-space: nowrap; 284 | text-align: center; 285 | line-height: 40px; /* Align text vertically */ 286 | overflow: hidden; /* Hide any overflow content */ 287 | padding: 0 10px; /* Add some padding for better appearance */ 288 | box-sizing: border-box; /* Ensure padding and border are included in the element's total width and height */ 289 | } 290 | 291 | /* Ensure the icon does not resize */ 292 | #get-all-urls-button .fa { 293 | display: inline-block; 294 | vertical-align: middle; 295 | } 296 | 297 | /* Ensure the SVG icon maintains its size */ 298 | .svg-inline--fa { 299 | width: 16px; /* Set a fixed width for the icon */ 300 | height: 16px; /* Set a fixed height for the icon */ 301 | vertical-align: middle; /* Align with text */ 302 | } 303 | 304 | /* Ensure g.missing does not resize */ 305 | g.missing { 306 | width: 16px; /* Set a fixed width if needed */ 307 | height: 16px; /* Set a fixed height if needed */ 308 | display: inline-block; 309 | vertical-align: middle; 310 | } 311 | 312 | /* Prevent the button from resizing its height */ 313 | #get-all-urls-button { 314 | height: 40px; /* Set a fixed height */ 315 | line-height: 40px; /* Align text vertically */ 316 | overflow: hidden; /* Hide any overflow content */ 317 | padding: 0 10px; /* Add some padding for better appearance */ 318 | box-sizing: border-box; /* Ensure padding and border are included in the element's total width and height */ 319 | } 320 | 321 | @media (max-width: 768px) { 322 | .container { 323 | padding: 10px; 324 | } 325 | 326 | .site-card { 327 | flex-direction: column; 328 | align-items: flex-start; 329 | } 330 | 331 | .site-card img { 332 | max-width: 100%; 333 | margin: 0 0 10px 0; 334 | } 335 | 336 | .match-controls { 337 | flex-direction: column; 338 | gap: 10px; 339 | } 340 | 341 | .table-dark-mode th, .table-dark-mode td { 342 | padding: 5px; 343 | font-size: 0.8em; 344 | } 345 | 346 | .action-buttons { 347 | flex-direction: column; 348 | gap: 5px; 349 | } 350 | 351 | .progress-bar { 352 | transform: translateY(-50px); 353 | left: 50px; 354 | } 355 | 356 | .btn { 357 | width: 100%; 358 | margin-bottom: 5px; 359 | } 360 | } 361 | 362 | @media (max-width: 611px) { 363 | .site-card { 364 | padding: 10px 5px; 365 | } 366 | 367 | .match-controls { 368 | gap: 5px; 369 | } 370 | 371 | .btn { 372 | width: 100%; 373 | margin-bottom: 5px; 374 | } 375 | 376 | #search-input { 377 | margin-top: 50px; 378 | } 379 | 380 | .progress-bar { 381 | transform: translateY(-50px); 382 | left: 50px; 383 | } 384 | } 385 | 386 | @media (max-width: 530px) { 387 | .site-card { 388 | padding: 10px 5px; 389 | } 390 | 391 | .match-controls { 392 | gap: 5px; 393 | } 394 | 395 | .btn { 396 | width: 100%; 397 | margin-bottom: 5px; 398 | } 399 | 400 | #search-input { 401 | margin-top: 75px; 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /jizzarr/web/static/config.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Montserrat', Arial, sans-serif; 3 | background-color: #121212; 4 | color: #ffffff; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | .favicon { 10 | width: 25px; 11 | height: 25px; 12 | margin-right: 8px; 13 | vertical-align: middle; 14 | } 15 | 16 | .custom-navbar { 17 | width: 100%; 18 | position: fixed; 19 | top: 0; 20 | z-index: 1000; 21 | background-color: rgba(0, 0, 0, 0.5); 22 | padding: 10px 0; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | backdrop-filter: blur(6px); 27 | } 28 | 29 | .custom-navbar a { 30 | color: #ffffff; 31 | text-decoration: none; 32 | margin: 0 10px; 33 | padding: 5px 10px; 34 | } 35 | 36 | .custom-navbar a:hover { 37 | background-color: #495057; 38 | border-radius: 4px; 39 | } 40 | 41 | .container { 42 | max-width: 100%; /* Full width */ 43 | padding: 20px; 44 | margin: 0 auto; 45 | } 46 | 47 | .config-wrapper { 48 | display: flex; 49 | justify-content: center; 50 | } 51 | 52 | .config-container { 53 | padding: 20px; 54 | background-color: #1e1e1e; 55 | border-radius: 8px; 56 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); 57 | width: 100%; /* Use full width */ 58 | max-width: 1600px; /* Maximum width of the config container */ 59 | } 60 | 61 | h1 { 62 | text-align: center; 63 | margin-bottom: 20px; 64 | margin-top: 100px; 65 | } 66 | 67 | .form-group { 68 | margin-bottom: 20px; 69 | width: 100%; /* Full width */ 70 | position: relative; 71 | } 72 | 73 | .form-label { 74 | display: block; 75 | margin-bottom: 5px; 76 | font-weight: 500; 77 | } 78 | 79 | .form-control-dark { 80 | background-color: #333; 81 | border: 1px solid #444; 82 | color: #ffffff; 83 | padding: 10px; 84 | border-radius: 4px; 85 | width: 100%; /* Full width */ 86 | box-sizing: border-box; 87 | } 88 | 89 | .btn-primary-dark { 90 | background-color: #1a73e8; 91 | border: none; 92 | color: #ffffff; 93 | padding: 10px 20px; 94 | border-radius: 4px; 95 | width: 100%; 96 | cursor: pointer; 97 | font-weight: 500; 98 | margin-top: 20px; 99 | } 100 | 101 | .btn-primary-dark:hover { 102 | background-color: #155ab6; 103 | } 104 | 105 | .btn-show { 106 | position: absolute; 107 | top: 40%; 108 | right: 10px; 109 | transform: translateX(15%); 110 | background-color: #1a73e8; 111 | border: none; 112 | color: #ffffff; 113 | padding: 10px 20px; 114 | border-radius: 4px; 115 | cursor: pointer; 116 | font-weight: 500; 117 | } 118 | 119 | .btn-show:hover { 120 | background-color: #155ab6; 121 | } 122 | 123 | .path { 124 | color: lime; 125 | } 126 | 127 | .scrollable-container { 128 | max-height: 400px; 129 | overflow-y: auto; 130 | border: 1px solid #ccc; 131 | padding: 10px; 132 | } 133 | 134 | /* Media Queries */ 135 | @media (max-width: 1200px) { 136 | .container { 137 | max-width: 100%; 138 | margin-top: 10px; 139 | } 140 | } 141 | 142 | @media (max-width: 992px) { 143 | .container { 144 | max-width: 100%; 145 | margin-top: 10px; 146 | } 147 | } 148 | 149 | @media (max-width: 768px) { 150 | .container { 151 | max-width: 100%; 152 | margin-top: 10px; 153 | } 154 | } 155 | 156 | @media (max-width: 576px) { 157 | .container { 158 | max-width: 100%; 159 | padding: 0 10px; 160 | margin-top: 10px; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /jizzarr/web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Serechops/Jizzarr/ab59265a44f80a192dfd243d2ab20be3cface2f6/jizzarr/web/static/favicon.ico -------------------------------------------------------------------------------- /jizzarr/web/static/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Montserrat', Arial, sans-serif; 3 | background-color: #121212; 4 | color: #ffffff; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | .container { 10 | margin-top: 75px; 11 | max-width: 80%; 12 | } 13 | 14 | .favicon { 15 | width: 25px; 16 | height: 25px; 17 | margin-right: 8px; 18 | vertical-align: middle; 19 | } 20 | 21 | .custom-navbar { 22 | width: 100%; 23 | position: fixed; 24 | top: 0; 25 | z-index: 1000; 26 | background-color: rgba(0, 0, 0, 0.5); 27 | padding: 10px 0; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | backdrop-filter: blur(6px); 32 | } 33 | 34 | .custom-navbar a { 35 | color: #ffffff; 36 | text-decoration: none; 37 | margin: 0 10px; 38 | padding: 5px 10px; 39 | } 40 | 41 | .custom-navbar a:hover { 42 | background-color: #495057; 43 | border-radius: 4px; 44 | } 45 | 46 | .form-control-dark { 47 | background-color: #333; 48 | border-color: #444; 49 | color: #ffffff; 50 | width: 100%; 51 | } 52 | 53 | .btn-primary-dark { 54 | background-color: #1a73e8; 55 | border-color: #1a73e8; 56 | } 57 | 58 | .card { 59 | background-color: #1e1e1e; 60 | color: #ffffff; 61 | border: 1px solid #333; 62 | } 63 | 64 | .card .btn { 65 | color: #ffffff; 66 | } 67 | 68 | .input-group-centered { 69 | display: flex; 70 | justify-content: center; 71 | flex-direction: column; 72 | align-items: center; 73 | margin-bottom: 1rem; 74 | } 75 | 76 | .search-container { 77 | width: 50%; 78 | } 79 | 80 | .tpdb-logo { 81 | max-height: 50px; 82 | display: inline-block; 83 | } 84 | 85 | .infographic { 86 | position: fixed; 87 | top: 75px; 88 | left: 10px; 89 | background-color: #1e1e1e; 90 | padding: 10px; 91 | border-radius: 8px; 92 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); 93 | text-align: center; 94 | } 95 | 96 | .infographic h4, .infographic p { 97 | margin: 0; 98 | padding: 0; 99 | } 100 | 101 | .legend-container { 102 | display: flex; 103 | justify-content: space-around; 104 | margin-bottom: 10px; 105 | } 106 | 107 | .legend-item { 108 | display: flex; 109 | align-items: center; 110 | font-size: 14px; 111 | } 112 | 113 | .legend-color { 114 | width: 12px; 115 | height: 12px; 116 | display: inline-block; 117 | margin-right: 5px; 118 | } 119 | 120 | .legend-collected { 121 | background-color: #00b09b; 122 | } 123 | 124 | .legend-missing { 125 | background-color: #ff5f6d; 126 | } 127 | 128 | #collectionChart { 129 | width: 200px; 130 | height: 200px; 131 | } 132 | 133 | #progress-bar-container { 134 | display: none; 135 | position: fixed; 136 | top: 10px; 137 | right: 10px; 138 | width: 300px; 139 | background-color: #333; 140 | border-radius: 8px; 141 | overflow: hidden; 142 | align-items: center; 143 | padding: 5px; 144 | z-index: 1000; 145 | } 146 | 147 | #stash-logo { 148 | width: 30px; 149 | height: 30px; 150 | margin-right: 10px; /* Space between logo and progress bar */ 151 | } 152 | 153 | #progress-bar { 154 | width: 100%; 155 | height: 25px; 156 | background-color: #444; 157 | flex-grow: 1; /* Ensure the progress bar fills the remaining space */ 158 | } 159 | 160 | #progress-bar-inner { 161 | height: 100%; 162 | width: 0; 163 | background-color: #1a73e8; 164 | text-align: center; 165 | color: white; 166 | line-height: 25px; /* Center the text vertically */ 167 | font-weight: bold; 168 | } 169 | 170 | .modal-footer .btn { 171 | background-color: red; 172 | } 173 | 174 | .list-group-item h5 { 175 | color: orange; 176 | } 177 | 178 | /* Fix the button size */ 179 | #search-stash { 180 | width: 200px; /* Adjust the width as needed */ 181 | height: 40px; /* Set a fixed height */ 182 | white-space: nowrap; 183 | text-align: center; 184 | line-height: 40px; /* Align text vertically */ 185 | overflow: hidden; /* Hide any overflow content */ 186 | padding: 0 10px; /* Add some padding for better appearance */ 187 | box-sizing: border-box; /* Ensure padding and border are included in the element's total width and height */ 188 | } 189 | 190 | /* Ensure the icon does not resize */ 191 | #search-stash .fa { 192 | display: inline-block; 193 | vertical-align: middle; 194 | } 195 | 196 | /* Ensure the SVG icon maintains its size */ 197 | .svg-inline--fa { 198 | width: 16px; /* Set a fixed width for the icon */ 199 | height: 16px; /* Set a fixed height for the icon */ 200 | vertical-align: middle; /* Align with text */ 201 | } 202 | 203 | /* Prevent the button from resizing its height */ 204 | #search-stash { 205 | height: 40px; /* Set a fixed height */ 206 | line-height: 40px; /* Align text vertically */ 207 | overflow: hidden; /* Hide any overflow content */ 208 | padding: 0 10px; /* Add some padding for better appearance */ 209 | box-sizing: border-box; /* Ensure padding and border are included in the element's total width and height */ 210 | } 211 | 212 | /* Media Queries */ 213 | @media (max-width: 1200px) { 214 | .container { 215 | max-width: 90%; 216 | margin-top: 75px; 217 | } 218 | 219 | .search-container { 220 | width: 60%; 221 | } 222 | 223 | #progress-bar-container { 224 | width: 250px; 225 | } 226 | } 227 | 228 | @media (max-width: 992px) { 229 | .container { 230 | max-width: 95%; 231 | margin-top: 75px; 232 | } 233 | 234 | .search-container { 235 | width: 70%; 236 | } 237 | 238 | #progress-bar-container { 239 | max-height: 30px; 240 | max-width: 200px; 241 | transform: translateY(120%); 242 | } 243 | } 244 | 245 | @media (max-width: 768px) { 246 | .container { 247 | margin-top: 75px; 248 | } 249 | 250 | .search-container { 251 | width: 80%; 252 | } 253 | 254 | #progress-bar-container { 255 | transform: translateY(120%); 256 | max-height: 30px; 257 | max-width: 120px; 258 | } 259 | } 260 | 261 | @media (max-width: 576px) { 262 | .container { 263 | margin-top: 120px; 264 | } 265 | 266 | .search-container { 267 | width: 90%; 268 | } 269 | 270 | 271 | #progress-bar-container { 272 | transform: translateY(210%); 273 | max-height: 30px; 274 | max-width: 235px; 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /jizzarr/web/static/logs.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Montserrat', Arial, sans-serif; 3 | background-color: #121212; 4 | color: #ffffff; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | .container { 10 | margin-top: 75px; 11 | max-width: 80%; 12 | } 13 | 14 | .favicon { 15 | width: 25px; 16 | height: 25px; 17 | margin-right: 8px; 18 | vertical-align: middle; 19 | } 20 | 21 | .custom-navbar { 22 | width: 100%; 23 | position: fixed; 24 | top: 0; 25 | z-index: 1000; 26 | background-color: rgba(0, 0, 0, 0.5); 27 | padding: 10px 0; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | backdrop-filter: blur(6px); 32 | } 33 | 34 | .custom-navbar a { 35 | color: #ffffff; 36 | text-decoration: none; 37 | margin: 0 10px; 38 | padding: 5px 10px; 39 | } 40 | 41 | .custom-navbar a:hover { 42 | background-color: #495057; 43 | border-radius: 4px; 44 | } 45 | 46 | table { 47 | width: 100%; 48 | border-collapse: collapse; 49 | } 50 | 51 | th, td { 52 | border: 1px solid #333; 53 | padding: 8px; 54 | text-align: left; 55 | } 56 | 57 | th { 58 | background-color: #1e1e1e; 59 | color: #ffffff; 60 | } 61 | 62 | .log-level { 63 | font-weight: bold; 64 | } 65 | 66 | .log-message { 67 | white-space: pre-wrap; 68 | } 69 | 70 | .btn-primary-dark { 71 | background-color: #1a73e8; 72 | border-color: #1a73e8; 73 | color: #ffffff; 74 | } 75 | 76 | .highlight { 77 | background-color: yellow; 78 | color: black; 79 | } 80 | 81 | .btn-danger-dark { 82 | background-color: red; 83 | } 84 | -------------------------------------------------------------------------------- /jizzarr/web/static/script.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function () { 2 | const searchButton = document.getElementById('search-button'); 3 | const searchInput = document.getElementById('search-input'); 4 | const siteDetails = document.getElementById('site-details'); 5 | const scenesGrid = document.getElementById('scenes-grid'); 6 | const pagination = document.getElementById('pagination'); 7 | const loadingIndicator = document.getElementById('loading-indicator'); 8 | const searchResults = document.getElementById('search-results'); 9 | const siteCollectionContainer = document.getElementById('site-collection'); 10 | const sceneCollectionContainer = document.getElementById('scene-collection'); 11 | const compareButton = document.getElementById('compare-button'); 12 | const directoryInput = document.getElementById('directory-input'); 13 | const searchStashButton = document.getElementById('search-stash'); 14 | const progressBarContainer = document.getElementById('progress-bar-container'); 15 | const progressBarInner = document.getElementById('progress-bar-inner'); 16 | 17 | let progressInterval; 18 | 19 | searchStashButton.addEventListener('click', async function () { 20 | // Show loading indicator when search-stash button is pressed 21 | loadingIndicator.classList.remove('hidden'); 22 | progressBarContainer.style.display = 'block'; 23 | updateProgressBar(0); 24 | startProgressUpdate(); 25 | 26 | try { 27 | const response = await fetch('/populate_from_stash', { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | } 32 | }); 33 | 34 | if (!response.ok) { 35 | throw new Error(`Error ${response.status}: ${await response.text()}`); 36 | } 37 | 38 | const result = await response.json(); 39 | console.log('Sites and scenes fetched from Stash:', result); 40 | Toastify({ 41 | text: 'Sites and scenes successfully populated from Stash.', 42 | duration: 3000, 43 | close: true, 44 | gravity: "top", 45 | position: "right", 46 | backgroundColor: "linear-gradient(to right, #00b09b, #96c93d)" 47 | }).showToast(); 48 | displayCollection(); // Refresh the collection display 49 | } catch (error) { 50 | console.error('Error fetching from Stash:', error); 51 | Toastify({ 52 | text: `Error fetching from Stash: ${error.message}`, 53 | duration: 3000, 54 | close: true, 55 | gravity: "top", 56 | position: "right", 57 | backgroundColor: "linear-gradient(to right, #ff5f6d, #ffc371)" 58 | }).showToast(); 59 | } 60 | 61 | // Hide loading indicator once fetching is complete 62 | loadingIndicator.classList.add('hidden'); 63 | progressBarContainer.style.display = 'none'; 64 | clearInterval(progressInterval); 65 | }); 66 | 67 | function startProgressUpdate() { 68 | progressInterval = setInterval(async () => { 69 | const response = await fetch('/progress'); 70 | if (response.ok) { 71 | const progress = await response.json(); 72 | const percentage = Math.round((progress.completed / progress.total) * 100); 73 | updateProgressBar(percentage); 74 | } 75 | }, 10000); // Update progress every second 76 | } 77 | 78 | function updateProgressBar(percentage) { 79 | progressBarInner.style.width = percentage + '%'; 80 | progressBarInner.innerText = percentage + '%'; 81 | } 82 | 83 | let currentScenes = []; 84 | let currentPage = 1; 85 | const scenesPerPage = 16; 86 | 87 | async function getApiKey() { 88 | try { 89 | const response = await fetch('/get_tpdb_api_key'); 90 | if (response.ok) { 91 | const data = await response.json(); 92 | return data.tpdbApiKey; 93 | } else { 94 | console.error('Failed to fetch API key:', response.status); 95 | return null; 96 | } 97 | } catch (error) { 98 | console.error('Error fetching API key:', error); 99 | return null; 100 | } 101 | } 102 | 103 | // Fetch data from API 104 | async function fetchData(endpoint) { 105 | const apiKey = await getApiKey(); 106 | if (!apiKey) { 107 | console.error('API key is missing'); 108 | return null; 109 | } 110 | 111 | const url = `https://api.theporndb.net/jizzarr/${endpoint}`; 112 | const headers = { 113 | 'Authorization': `Bearer ${apiKey}` 114 | }; 115 | 116 | try { 117 | const response = await fetch(url, {headers}); 118 | if (response.ok) { 119 | const data = await response.json(); 120 | console.log(`Data from ${endpoint}:`, data); // Print the API response for debugging 121 | return data; 122 | } else { 123 | console.error(`Failed to fetch data from ${endpoint}: ${response.status}`); 124 | return null; 125 | } 126 | } catch (error) { 127 | console.error('Error fetching data:', error); 128 | return null; 129 | } 130 | } 131 | 132 | // Search for site by name 133 | async function searchSiteByName(siteName) { 134 | const data = await fetchData(`site/search?q=${siteName}`); 135 | return data ? data : []; 136 | } 137 | 138 | function displayScenes() { 139 | scenesGrid.innerHTML = ''; 140 | if (Array.isArray(currentScenes)) { 141 | const scenesToDisplay = currentScenes.slice(currentPage * scenesPerPage, (currentPage + 1) * scenesPerPage); 142 | scenesToDisplay.forEach(scene => { 143 | const sceneElement = document.createElement('div'); 144 | sceneElement.classList.add('scene-card'); 145 | sceneElement.innerHTML = ` 146 | ${scene.Title} 147 |

${scene.Title}

148 |

Date: ${scene.ReleaseDate}

149 |

Overview: ${scene.Overview}

150 |

Duration: ${scene.Duration} minutes

151 |

Performers: ${scene.Credits.map(credit => credit.Name).sort().join(', ')}

152 | `; 153 | scenesGrid.appendChild(sceneElement); 154 | }); 155 | } else { 156 | scenesGrid.innerHTML = '

No scenes available

'; 157 | } 158 | } 159 | 160 | function setupPagination() { 161 | pagination.innerHTML = ''; 162 | if (Array.isArray(currentScenes) && currentScenes.length > 0) { 163 | const totalPages = Math.ceil(currentScenes.length / scenesPerPage); 164 | 165 | const firstButton = document.createElement('button'); 166 | firstButton.textContent = 'First'; 167 | firstButton.addEventListener('click', () => { 168 | currentPage = 0; 169 | displayScenes(); 170 | }); 171 | 172 | const prevButton = document.createElement('button'); 173 | prevButton.textContent = 'Previous'; 174 | prevButton.addEventListener('click', () => { 175 | if (currentPage > 0) { 176 | currentPage--; 177 | displayScenes(); 178 | } 179 | }); 180 | 181 | const nextButton = document.createElement('button'); 182 | nextButton.textContent = 'Next'; 183 | nextButton.addEventListener('click', () => { 184 | if (currentPage < totalPages - 1) { 185 | currentPage++; 186 | displayScenes(); 187 | } 188 | }); 189 | 190 | const lastButton = document.createElement('button'); 191 | lastButton.textContent = 'Last'; 192 | lastButton.addEventListener('click', () => { 193 | currentPage = totalPages - 1; 194 | displayScenes(); 195 | }); 196 | 197 | pagination.appendChild(firstButton); 198 | pagination.appendChild(prevButton); 199 | pagination.appendChild(nextButton); 200 | pagination.appendChild(lastButton); 201 | } 202 | } 203 | 204 | function downloadSiteData(title, scenes) { 205 | const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(scenes, null, 4)); 206 | const downloadAnchorNode = document.createElement('a'); 207 | downloadAnchorNode.setAttribute("href", dataStr); 208 | downloadAnchorNode.setAttribute("download", `${title}_data.json`); 209 | document.body.appendChild(downloadAnchorNode); 210 | downloadAnchorNode.click(); 211 | document.body.removeChild(downloadAnchorNode); 212 | } 213 | 214 | // Populate site details and scenes 215 | async function populateSiteDetails(site) { 216 | // Show loading indicator when fetching scenes 217 | loadingIndicator.classList.remove('hidden'); 218 | 219 | siteDetails.innerHTML = ''; 220 | scenesGrid.innerHTML = ''; 221 | pagination.innerHTML = ''; 222 | currentScenes = []; 223 | 224 | console.log('Fetching details for site with ForeignId:', site.ForeignId); 225 | 226 | try { 227 | const siteData = await fetchData(`site/${site.ForeignId}`); 228 | console.log('Site data received:', siteData); 229 | 230 | if (siteData) { 231 | const siteDetailsData = siteData; 232 | console.log('Processing site details data:', siteDetailsData); 233 | 234 | const scenes = siteDetailsData.Episodes || []; 235 | console.log('Scenes data:', scenes); 236 | 237 | const sceneCount = scenes.length; 238 | const siteInfo = ` 239 |
240 |

${siteDetailsData.Title}

241 |

URL: ${siteDetailsData.Homepage}

242 |

Description: ${siteDetailsData.Overview}

243 |

Network: ${siteDetailsData.Network}

244 |

Status: ${siteDetailsData.Status}

245 |

Total Scenes: ${sceneCount}

246 | ${siteDetailsData.Images && siteDetailsData.Images.length > 0 ? `${siteDetailsData.Title} Poster` : `${siteDetailsData.Title}`} 247 | 248 | 249 |
250 | `; 251 | siteDetails.innerHTML = siteInfo; 252 | 253 | if (scenes.length > 0) { 254 | currentScenes = scenes; 255 | displayScenes(); 256 | setupPagination(); 257 | } else { 258 | siteDetails.innerHTML += '

No scenes available

'; 259 | } 260 | 261 | // Add event listeners to buttons 262 | const downloadButton = document.getElementById('download-button'); 263 | const addSiteButton = document.getElementById('add-site-button'); 264 | downloadButton.addEventListener('click', () => downloadSiteData(siteDetailsData.Title, scenes)); 265 | addSiteButton.addEventListener('click', () => addSiteToCollection(siteDetailsData, scenes)); 266 | 267 | } else { 268 | console.log('No site data found.'); 269 | siteDetails.innerHTML = '

No site details available

'; 270 | } 271 | } catch (error) { 272 | console.error('Error fetching site details:', error); 273 | siteDetails.innerHTML = `

Error fetching site details: ${error.message}

`; 274 | } 275 | 276 | // Hide loading indicator once scenes are populated 277 | loadingIndicator.classList.add('hidden'); 278 | } 279 | 280 | // Display search results 281 | function displaySearchResults(sites) { 282 | searchResults.innerHTML = ''; 283 | sites.forEach(site => { 284 | const siteCard = document.createElement('div'); 285 | siteCard.classList.add('site-card'); 286 | siteCard.innerHTML = ` 287 |
288 | ${site.Title} 289 |
290 | `; 291 | siteCard.addEventListener('click', () => { 292 | // Show loading indicator when a site is selected 293 | loadingIndicator.classList.remove('hidden'); 294 | populateSiteDetails(site); 295 | }); 296 | searchResults.appendChild(siteCard); 297 | }); 298 | 299 | // Hide loading indicator once search results are populated 300 | loadingIndicator.classList.add('hidden'); 301 | } 302 | 303 | // Add site to collection 304 | function addSiteToCollection(site, scenes) { 305 | const siteData = { 306 | site: { 307 | uuid: site.ForeignGuid, 308 | name: site.Title, 309 | url: site.Homepage, 310 | description: site.Overview, 311 | rating: '', 312 | network: site.Network, 313 | parent: '', 314 | logo: site.Images && site.Images.length > 0 ? site.Images.find(img => img.CoverType === 'Logo')?.Url : '' 315 | }, 316 | scenes: scenes.map(scene => ({ 317 | title: scene.Title, 318 | date: scene.ReleaseDate, 319 | duration: scene.Duration, 320 | image: scene.Images.find(img => img.CoverType === 'Screenshot')?.Url, 321 | performers: scene.Credits, 322 | status: scene.status || null, 323 | local_path: scene.local_path || null, 324 | year: scene.Year, 325 | episode_number: scene.EpisodeNumber, 326 | slug: scene.Slug, 327 | overview: scene.Overview, 328 | credits: scene.Credits, 329 | release_date_utc: scene.ReleaseDateUtc, 330 | images: scene.Images, 331 | trailer: scene.Trailer, 332 | genres: scene.Genres, 333 | foreign_guid: scene.ForeignGuid, 334 | foreign_id: scene.ForeignId 335 | })) 336 | }; 337 | 338 | // Save to server-side database 339 | fetch('/add_site', { 340 | method: 'POST', 341 | headers: { 342 | 'Content-Type': 'application/json' 343 | }, 344 | body: JSON.stringify(siteData) 345 | }).then(response => { 346 | if (!response.ok) { 347 | throw new Error('Failed to save to server-side database.'); 348 | } 349 | return response.json(); 350 | }).then(data => { 351 | console.log('Saved to server-side database:', data); 352 | Toastify({ 353 | text: `${site.Title} has been added to your collection.`, 354 | duration: 3000, 355 | close: true, 356 | gravity: "top", 357 | position: "right", 358 | backgroundColor: "linear-gradient(to right, #00b09b, #96c93d)" 359 | }).showToast(); 360 | displayCollection(); // Refresh the collection display 361 | }).catch(error => { 362 | console.error('Error saving to server-side database:', error); 363 | Toastify({ 364 | text: 'Failed to add site to collection.', 365 | duration: 3000, 366 | close: true, 367 | gravity: "top", 368 | position: "right", 369 | backgroundColor: "linear-gradient(to right, #ff5f6d, #ffc371)" 370 | }).showToast(); 371 | }); 372 | } 373 | 374 | 375 | // Event listener for search button 376 | if (searchButton) { 377 | searchButton.addEventListener('click', async function () { 378 | const siteName = searchInput.value.trim(); 379 | if (siteName) { 380 | // Show loading indicator when search button is pressed 381 | loadingIndicator.classList.remove('hidden'); 382 | const sites = await searchSiteByName(siteName); 383 | if (sites && sites.length > 0) { 384 | displaySearchResults(sites); 385 | } else { 386 | searchResults.innerHTML = '

No sites found

'; 387 | // Hide loading indicator if no sites are found 388 | loadingIndicator.classList.add('hidden'); 389 | } 390 | } else { 391 | searchResults.innerHTML = '

Please enter a site name

'; 392 | // Hide loading indicator if input is empty 393 | loadingIndicator.classList.add('hidden'); 394 | } 395 | }); 396 | } 397 | 398 | // Function to display the collection 399 | async function displayCollection() { 400 | siteCollectionContainer.innerHTML = ''; 401 | 402 | // Fetch collection from server-side database 403 | const response = await fetch('/collection_data'); 404 | const siteCollection = await response.json(); 405 | 406 | // Display site collection 407 | siteCollection.forEach(item => { 408 | const siteCard = document.createElement('div'); 409 | siteCard.classList.add('site-card'); 410 | siteCard.innerHTML = ` 411 |

${item.site.name}

412 | ${item.site.poster ? `${item.site.name} Poster` : ''} 413 |

${item.site.description}

414 | 415 |
416 | ${item.scenes.map(scene => ` 417 |
418 |
419 |

${scene.title}

420 |

Release Date: ${new Date(scene.date).toLocaleDateString()}

421 |

Duration: ${scene.duration} minutes

422 |

Performers: ${scene.performers}

423 |

${scene.status}

424 | 425 |
426 | 429 |
430 | `).join('')} 431 |
432 | `; 433 | siteCard.querySelectorAll('.scene-row').forEach(row => { 434 | row.addEventListener('click', () => { 435 | const preview = row.querySelector('.scene-preview'); 436 | preview.classList.toggle('hidden'); 437 | }); 438 | }); 439 | siteCollectionContainer.appendChild(siteCard); 440 | }); 441 | 442 | // Add event listeners to remove buttons 443 | const removeSiteButtons = document.querySelectorAll('.remove-site-button'); 444 | removeSiteButtons.forEach(button => { 445 | button.addEventListener('click', (e) => { 446 | const siteUuid = e.target.getAttribute('data-site-uuid'); 447 | removeSiteFromCollection(siteUuid); 448 | }); 449 | }); 450 | 451 | // Add event listeners to match buttons 452 | const matchButtons = document.querySelectorAll('.match-button'); 453 | matchButtons.forEach(button => { 454 | button.addEventListener('click', (e) => { 455 | const sceneId = e.target.getAttribute('data-scene-id'); 456 | handleMatchButtonClick(sceneId); 457 | }); 458 | }); 459 | } 460 | 461 | // Remove site from collection 462 | function removeSiteFromCollection(siteUuid) { 463 | fetch(`/remove_site/${siteUuid}`, {method: 'DELETE'}) 464 | .then(response => response.json()) 465 | .then(data => { 466 | console.log('Site removed:', data); 467 | Toastify({ 468 | text: 'Site has been removed from your collection.', 469 | duration: 3000, 470 | close: true, 471 | gravity: "top", 472 | position: "right", 473 | backgroundColor: "linear-gradient(to right, #ff5f6d, #ffc371)" 474 | }).showToast(); 475 | displayCollection(); // Update the display 476 | }) 477 | .catch(error => { 478 | console.error('Error removing site from collection:', error); 479 | Toastify({ 480 | text: 'Failed to remove site from collection.', 481 | duration: 3000, 482 | close: true, 483 | gravity: "top", 484 | position: "right", 485 | backgroundColor: "linear-gradient(to right, #ff5f6d, #ffc371)" 486 | }).showToast(); 487 | }); 488 | } 489 | 490 | // Handle match button click 491 | function handleMatchButtonClick(sceneId) { 492 | const input = document.createElement('input'); 493 | input.type = 'file'; 494 | input.accept = '.mp4'; 495 | input.onchange = async (event) => { 496 | const file = event.target.files[0]; 497 | if (file) { 498 | try { 499 | const response = await fetch('/match_scene', { 500 | method: 'POST', 501 | headers: { 502 | 'Content-Type': 'application/json' 503 | }, 504 | body: JSON.stringify({scene_id: sceneId, file_path: file.path}) 505 | }); 506 | 507 | const result = await response.json(); 508 | if (response.ok) { 509 | Toastify({ 510 | text: result.message, 511 | duration: 3000, 512 | close: true, 513 | gravity: "top", 514 | position: "right", 515 | backgroundColor: "linear-gradient(to right, #00b09b, #96c93d)" 516 | }).showToast(); 517 | displayCollection(); 518 | } else { 519 | throw new Error(result.error); 520 | } 521 | } catch (error) { 522 | console.error('Error matching scene:', error); 523 | Toastify({ 524 | text: 'Error matching scene: ' + error.message, 525 | duration: 3000, 526 | close: true, 527 | gravity: "top", 528 | position: "right", 529 | backgroundColor: "linear-gradient(to right, #ff5f6d, #ffc371)" 530 | }).showToast(); 531 | } 532 | } 533 | }; 534 | input.click(); 535 | } 536 | 537 | // Compare collection with local directory 538 | function compareCollection(siteUuid, localDirectory) { 539 | fetch('/compare', { 540 | method: 'POST', 541 | headers: { 542 | 'Content-Type': 'application/json' 543 | }, 544 | body: JSON.stringify({siteUuid, localDirectory}) 545 | }).then(response => response.json()) 546 | .then(data => { 547 | console.log('Comparison results:', data); 548 | updateTableWithComparison(data.missing_files, data.matched_files); 549 | }).catch(error => { 550 | console.error('Error comparing collection:', error); 551 | }); 552 | } 553 | 554 | function updateTableWithComparison(missingFiles, matchedFiles) { 555 | // Fetch updated collection from server-side database 556 | fetch('/collection_data') 557 | .then(response => response.json()) 558 | .then(siteCollection => { 559 | siteCollection.forEach(siteData => { 560 | siteData.scenes.forEach(scene => { 561 | const sceneFileName = `${siteData.site.name} - ${scene.title} - ${scene.date}.mp4`; 562 | if (missingFiles.includes(sceneFileName)) { 563 | scene.status = 'Missing'; 564 | } else if (matchedFiles.includes(sceneFileName)) { 565 | scene.status = 'Found'; 566 | } else { 567 | scene.status = 'Unknown'; 568 | } 569 | }); 570 | }); 571 | displayCollection(); 572 | }) 573 | .catch(error => { 574 | console.error('Error fetching updated collection:', error); 575 | }); 576 | } 577 | 578 | // Event listener for the compare button 579 | if (compareButton) { 580 | compareButton.addEventListener('click', () => { 581 | const siteCard = document.querySelector('.site-card.active'); 582 | if (siteCard) { 583 | const siteUuid = siteCard.getAttribute('data-site-uuid'); 584 | const localDirectory = directoryInput.files[0].webkitRelativePath.split('/')[0]; 585 | compareCollection(siteUuid, localDirectory); 586 | } else { 587 | Toastify({ 588 | text: 'Please select a site first.', 589 | duration: 3000, 590 | close: true, 591 | gravity: "top", 592 | position: "right", 593 | backgroundColor: "linear-gradient(to right, #ff5f6d, #ffc371)" 594 | }).showToast(); 595 | } 596 | }); 597 | } 598 | 599 | // Add event listeners to directory input 600 | if (directoryInput) { 601 | directoryInput.addEventListener('change', () => { 602 | console.log('Directory selected:', directoryInput.files); 603 | }); 604 | } 605 | 606 | // Load collection when collection page is loaded 607 | if (window.location.pathname.endsWith('collection.html')) { 608 | displayCollection(); 609 | } 610 | }); 611 | -------------------------------------------------------------------------------- /jizzarr/web/static/stash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Serechops/Jizzarr/ab59265a44f80a192dfd243d2ab20be3cface2f6/jizzarr/web/static/stash.png -------------------------------------------------------------------------------- /jizzarr/web/static/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | background-color: #121212; 4 | color: #e0e0e0; 5 | margin: 0; 6 | padding: 0; 7 | display: flex; 8 | justify-content: center; 9 | align-items: flex-start; 10 | height: 100vh; 11 | overflow: auto; 12 | } 13 | 14 | .favicon { 15 | width: 25px; 16 | height: 25px; 17 | margin-right: 8px; 18 | vertical-align: middle; 19 | } 20 | 21 | .container { 22 | background-color: #1e1e1e; 23 | padding: 20px; 24 | border-radius: 8px; 25 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); 26 | width: 100%; 27 | max-width: 1920px; 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | margin-top: 20px; 32 | box-sizing: border-box; 33 | } 34 | 35 | h1 { 36 | text-align: center; 37 | color: #e0e0e0; 38 | } 39 | 40 | input[type="text"] { 41 | width: 100%; 42 | max-width: 600px; 43 | padding: 8px; 44 | margin-bottom: 10px; 45 | border: 1px solid #ccc; 46 | border-radius: 4px; 47 | background-color: #333; 48 | color: #e0e0e0; 49 | } 50 | 51 | button { 52 | max-width: 600px; 53 | width: 100%; 54 | padding: 10px; 55 | background-color: #007BFF; 56 | color: white; 57 | border: none; 58 | border-radius: 4px; 59 | cursor: pointer; 60 | margin-bottom: 20px; 61 | } 62 | 63 | button:hover { 64 | background-color: #0056b3; 65 | } 66 | 67 | #site-details { 68 | margin-top: 20px; 69 | text-align: center; 70 | width: 100%; 71 | max-width: 1920px; 72 | } 73 | 74 | .site-info img { 75 | display: none; 76 | } 77 | 78 | #site-collection .site-card img { 79 | display: none; 80 | } 81 | 82 | #search-results { 83 | display: flex; 84 | flex-wrap: wrap; 85 | justify-content: center; 86 | gap: 10px; 87 | margin-top: 20px; 88 | width: 100%; 89 | max-width: 1920px; 90 | } 91 | 92 | .site-card { 93 | background-color: #4a4a4a; 94 | display: flex; 95 | justify-content: center; 96 | align-items: center; 97 | margin: 10px; 98 | border-radius: 8px; 99 | overflow: hidden; 100 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); 101 | width: auto; 102 | max-width: 600px; 103 | padding: 10px; 104 | cursor: pointer; 105 | } 106 | 107 | .site-card img { 108 | max-height: 100px; 109 | object-fit: contain; 110 | display: block; 111 | margin: 0 auto; 112 | } 113 | 114 | .site-card:hover { 115 | cursor: pointer; 116 | } 117 | 118 | #scenes-grid { 119 | display: grid; 120 | grid-template-columns: repeat(4, 1fr); 121 | gap: 10px; 122 | margin-top: 20px; 123 | width: 100%; 124 | max-width: 1920px; 125 | } 126 | 127 | .scene-card { 128 | height: auto; 129 | margin: 10px; 130 | background-color: #2a2a2a; 131 | border-radius: 8px; 132 | overflow: hidden; 133 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); 134 | text-align: center; 135 | color: white; 136 | } 137 | 138 | .scene-card img { 139 | width: 100%; 140 | height: auto; 141 | max-height: 200px; 142 | object-fit: cover; 143 | } 144 | 145 | .scene-card:hover img { 146 | transform: scale(1.05); 147 | } 148 | 149 | .scene-card .scene-details { 150 | background-color: rgba(38, 38, 38, 0.8); 151 | color: #e0e0e0; 152 | position: absolute; 153 | bottom: 0; 154 | width: 100%; 155 | padding: 5px; 156 | display: flex; 157 | justify-content: space-between; 158 | } 159 | 160 | .scene-card .scene-info { 161 | padding: 10px; 162 | text-align: left; 163 | } 164 | 165 | .scene-card .scene-info h2 { 166 | font-size: 1.25rem; 167 | margin: 0; 168 | } 169 | 170 | .scene-card .scene-info p { 171 | margin: 5px 0; 172 | } 173 | 174 | .scene-card .scene-info a { 175 | color: #007BFF; 176 | } 177 | 178 | .scene-card p { 179 | display: -webkit-box; 180 | -webkit-box-orient: vertical; 181 | -webkit-line-clamp: 3; 182 | overflow: hidden; 183 | text-overflow: ellipsis; 184 | } 185 | 186 | #pagination { 187 | display: flex; 188 | justify-content: center; 189 | margin-top: 20px; 190 | flex-wrap: wrap; 191 | width: 100%; 192 | max-width: 1920px; 193 | } 194 | 195 | .pagination-button { 196 | margin: 0 5px; 197 | padding: 5px 10px; 198 | background-color: #007BFF; 199 | color: white; 200 | border: none; 201 | border-radius: 4px; 202 | cursor: pointer; 203 | } 204 | 205 | .pagination-button:hover { 206 | background-color: #0056b3; 207 | } 208 | 209 | .pagination-button.disabled { 210 | background-color: #cccccc; 211 | cursor: not-allowed; 212 | } 213 | 214 | #loading-indicator { 215 | display: none; 216 | justify-content: center; 217 | align-items: center; 218 | height: 100px; 219 | width: 100px; 220 | border: 8px solid #e0e0e0; 221 | border-top: 8px solid #007BFF; 222 | border-radius: 50%; 223 | animation: spin 1s linear infinite; 224 | margin-top: 20px; 225 | } 226 | 227 | .loading { 228 | display: flex !important; 229 | } 230 | 231 | .hidden { 232 | display: none !important; 233 | } 234 | 235 | @keyframes spin { 236 | 0% { 237 | transform: rotate(0deg); 238 | } 239 | 100% { 240 | transform: rotate(360deg); 241 | } 242 | } 243 | 244 | /* Tabulator dark mode styles */ 245 | .tabulator { 246 | background-color: #1e1e1e; 247 | color: #ffffff; 248 | } 249 | 250 | .tabulator .tabulator-row { 251 | background-color: #2c2c2c; 252 | } 253 | 254 | .tabulator .tabulator-row:nth-child(even) { 255 | background-color: #2e2e2e; 256 | } 257 | 258 | .tabulator .tabulator-row:hover { 259 | background-color: #3a3a3a; 260 | } 261 | 262 | .tabulator .tabulator-cell { 263 | border-right: 1px solid #444444; 264 | color: #ffffff; 265 | } 266 | 267 | .tabulator .tabulator-cell img { 268 | border-radius: 4px; 269 | } 270 | 271 | span.tabulator-paginator { 272 | display: none; 273 | } 274 | 275 | /* Toastify dark mode styles */ 276 | .toastify { 277 | background-color: #333333 !important; 278 | color: #ffffff !important; 279 | border-radius: 4px; 280 | } 281 | 282 | /* Center the back button */ 283 | #back-button-container { 284 | display: flex; 285 | justify-content: center; 286 | margin: 20px 0; 287 | } 288 | 289 | #back-button { 290 | background-color: #ff5f6d; 291 | color: #ffffff; 292 | border: none; 293 | padding: 10px 20px; 294 | border-radius: 4px; 295 | cursor: pointer; 296 | text-align: center; 297 | } 298 | 299 | #back-button:hover { 300 | background-color: #ff7f8d; 301 | } 302 | 303 | .pagination-controls { 304 | padding: 10px 20px; 305 | } 306 | 307 | /* Media Queries for responsive design */ 308 | @media (max-width: 1200px) { 309 | .container, #site-details, #search-results, #scenes-grid, #pagination { 310 | max-width: 100%; 311 | } 312 | } 313 | 314 | @media (max-width: 768px) { 315 | .container { 316 | padding: 10px; 317 | } 318 | 319 | input[type="text"], button { 320 | max-width: 100%; 321 | } 322 | 323 | .site-card, .scene-card { 324 | width: calc(50% - 10px); 325 | } 326 | 327 | #scenes-grid { 328 | grid-template-columns: repeat(2, 1fr); 329 | } 330 | } 331 | 332 | @media (max-width: 480px) { 333 | .site-card, .scene-card { 334 | width: calc(100% - 10px); 335 | } 336 | 337 | #scenes-grid { 338 | grid-template-columns: 1fr; 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /jizzarr/web/static/tpdblogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Serechops/Jizzarr/ab59265a44f80a192dfd243d2ab20be3cface2f6/jizzarr/web/static/tpdblogo.png -------------------------------------------------------------------------------- /jizzarr/web/templates/collection.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Collection 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 25 |
26 |
27 | 28 |
29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 |
Site Pages
37 |
38 |
39 | 40 | 41 | 42 | 43 |
44 |
45 |
46 |
47 | 48 |
49 | 50 |

51 |
52 |
53 |
54 | 55 | 64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
73 |
74 |

Current Home Directory:

75 |
76 |
77 |
78 | 79 |
80 |
Scene Pages:
81 |
82 |
83 | 84 | 85 | 86 | 87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
TitleRelease DateDurationPerformersStatusFile DirectoryTPDB Scene UUIDActionScene URL
104 |
105 |
106 | 107 | 108 | 109 | 1364 | 1365 | 1366 | -------------------------------------------------------------------------------- /jizzarr/web/templates/config.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Configuration 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 |
24 |

Configuration

25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 |
51 | 52 |
53 | 54 |
55 |
    56 | {% for library, sites in libraries_with_sites.items() %} 57 |
    58 |
    59 |

    {{ library.path }}

    60 | 61 |
      62 | {% for site in sites %} 63 |
    • {{ site.name }}: {{ site.home_directory }}
    • 64 | {% endfor %} 65 |
    66 |
    67 |
    68 | {% endfor %} 69 |
70 |
    71 | 72 |
73 |
74 |
75 |
76 |
77 | 78 | 343 | 344 | 345 | -------------------------------------------------------------------------------- /jizzarr/web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jizzarr Home 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 25 | 26 | 27 | 37 |
38 |

Search Sites via

39 |
40 | 41 | 42 | 45 | 48 |
49 |
50 |
51 | 52 |
53 | 54 |
55 | 56 | 57 |
58 | 59 |
60 |
0%
61 |
62 |
63 | 64 | 65 | 81 | 82 | 83 | 84 | 85 | 284 | 285 | 286 | -------------------------------------------------------------------------------- /jizzarr/web/templates/logs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Logs 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | 30 |
31 |

Logs

32 |
33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
TimestampLevelMessage
49 |
50 | 51 | 52 | 53 | 54 |
55 |
56 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /jizzarr/web/templates/stats.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jizzarr Collection Stats 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 79 | 80 | 81 | 91 |
92 |
93 |

Collection Stats

94 |
95 |
96 |
97 |
98 |
99 | Missing Scenes: 0 100 |
101 |
102 |
103 | Collected Scenes: 0 104 |
105 |
106 |
107 | Collected Duration: 0 108 |
109 |
110 |
111 | Missing Duration: 0 112 |
113 |
114 |
115 | Average Rating: 0 116 |
117 |
118 |
119 |
120 | 121 |
122 |
123 |

124 |
125 |
126 | 127 | 128 | 129 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "jizzarr" 3 | version = "0.17" 4 | description = "A collection tracker that integrates with TPDB and Stash." 5 | readme = "README.md" 6 | authors = ["Serechops"] 7 | include = ["**/static/**/*", "**/templates/**/*", "**/instance/**/*"] 8 | 9 | [tool.poetry.dependencies] 10 | python = ">=3.9,<3.13" 11 | Flask = "^3.0.0" 12 | Flask-Cors = "^3.0.10" 13 | Flask-SQLAlchemy = "^3.1.1" 14 | mutagen = "^1.45.1" 15 | requests = "^2.26.0" 16 | pystray = "^0.19" 17 | Pillow = "^10.4.0" 18 | moviepy = "^1.0.3" 19 | watchdog = "^5.0" 20 | fuzzywuzzy = { extras = ["speedup"], version = "^0.18.0" } 21 | aiohttp = "^3.10" 22 | asyncio = "^3.4.3" 23 | ffmpeg-python = "^0.2" 24 | 25 | [tool.poetry.group.dev.dependencies] 26 | ruff = "^0.5" 27 | 28 | [tool.ruff] 29 | exclude = [".git", "__pycache__", "build", "dist", "node_modules"] 30 | line-length = 320 31 | indent-width = 4 32 | target-version = "py39" 33 | 34 | [tool.ruff.format] 35 | quote-style = "single" 36 | indent-style = "space" 37 | line-ending = "auto" 38 | 39 | [tool.ruff.lint] 40 | select = ["E", "F"] 41 | ignore = ["E501", "E722"] 42 | 43 | [tool.poetry.build] 44 | generate-setup-file = true 45 | 46 | [tool.poetry.scripts] 47 | jizzarr = "jizzarr.app:main" 48 | 49 | [build-system] 50 | requires = ["poetry-core"] 51 | build-backend = "poetry.core.masonry.api" 52 | --------------------------------------------------------------------------------