Initial development by Claude
This commit is contained in:
155
README.md
155
README.md
@@ -1,2 +1,155 @@
|
|||||||
# path-juggler
|
# Path Juggler
|
||||||
|
|
||||||
|
Automates file organization by watching folders and routing files to structured destinations based on matching source material.
|
||||||
|
|
||||||
|
## Current Workflow: DIT Editorial Proxies
|
||||||
|
|
||||||
|
The default configuration watches for Resolve render outputs and organizes them into editorial proxy folder structures:
|
||||||
|
|
||||||
|
1. Watches `~/Movies/AM` and `~/Movies/PM` for MXF files from Resolve
|
||||||
|
2. Parses filenames like `A080C002_251209R2.mxf` to extract the reel prefix (`A080`)
|
||||||
|
3. Searches mounted `HONEY 1`, `HONEY 2`, etc. volumes for matching bin folders
|
||||||
|
4. Creates the editorial proxy folder structure in `~/Movies/Honey Dailies Transfer`
|
||||||
|
5. Moves the rendered files to the correct location
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
- Resolve outputs: `~/Movies/AM/A080C002_251209R2.mxf`
|
||||||
|
- Path Juggler finds on HONEY 1: `Honey/20251209_Day 22/02 FOOTAGE/CAM_A/A080CY75/`
|
||||||
|
- Path Juggler creates: `~/Movies/Honey Dailies Transfer/20251209_Day 22 AM/03 EDITORIAL PROXIES/CAM_A/A080CY75/`
|
||||||
|
- Path Juggler moves the MXF file there
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Standalone App (Recommended)
|
||||||
|
|
||||||
|
Build a self-contained macOS application - no Python dependencies needed after building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make the build script executable
|
||||||
|
chmod +x build_app.sh
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
./build_app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `dist/Path Juggler.app` which you can drag to your Applications folder.
|
||||||
|
|
||||||
|
**First launch:** Right-click the app and select "Open" to bypass Gatekeeper (since it's self-signed).
|
||||||
|
|
||||||
|
#### Optional: Add a custom icon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires Pillow
|
||||||
|
pip3 install Pillow
|
||||||
|
python3 create_icon.py
|
||||||
|
|
||||||
|
# Then edit build_app.sh and change:
|
||||||
|
# icon=None,
|
||||||
|
# to:
|
||||||
|
# icon='AppIcon.icns',
|
||||||
|
|
||||||
|
# Rebuild
|
||||||
|
./build_app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Run from Source
|
||||||
|
|
||||||
|
If you prefer to run the Python scripts directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install watchdog
|
||||||
|
python3 path_juggler_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### GUI Application
|
||||||
|
|
||||||
|
Launch the app (double-click or from terminal):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open "Path Juggler.app"
|
||||||
|
# or
|
||||||
|
python3 path_juggler_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The GUI provides:
|
||||||
|
- Start/Stop button to control watching
|
||||||
|
- Live status of mounted HONEY volumes
|
||||||
|
- Real-time activity log with color-coded messages
|
||||||
|
- Clean macOS-style interface
|
||||||
|
|
||||||
|
### Command Line Interface
|
||||||
|
|
||||||
|
**Watch mode (default)** - Continuously watches for new files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 path_juggler.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dry run** - See what would happen without moving anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 path_juggler.py --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Process once and exit** - Process existing files without watching:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 path_juggler.py --once
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verbose mode** - See detailed logging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 path_juggler.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `path_juggler_gui.py` - GUI application
|
||||||
|
- `path_juggler.py` - Command line interface
|
||||||
|
- `path_juggler_core.py` - Core logic (shared by both interfaces)
|
||||||
|
- `build_app.sh` - Build script for standalone .app
|
||||||
|
- `create_icon.py` - Optional icon generator
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
Edit the configuration section at the top of `path_juggler_core.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
WATCH_FOLDERS = {
|
||||||
|
"AM": Path.home() / "Movies" / "AM",
|
||||||
|
"PM": Path.home() / "Movies" / "PM",
|
||||||
|
}
|
||||||
|
DESTINATION_BASE = Path.home() / "Movies" / "Honey Dailies Transfer"
|
||||||
|
VOLUME_PATTERN = re.compile(r"^HONEY \d+$")
|
||||||
|
PROJECT_FOLDER = "Honey"
|
||||||
|
FOOTAGE_FOLDER = "02 FOOTAGE"
|
||||||
|
PROXIES_FOLDER = "03 EDITORIAL PROXIES"
|
||||||
|
```
|
||||||
|
|
||||||
|
After changing configuration, rebuild the app with `./build_app.sh`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No HONEY volumes found"
|
||||||
|
- Make sure your backup drives are mounted
|
||||||
|
- Check they're named exactly `HONEY 1`, `HONEY 2`, etc. (with space before number)
|
||||||
|
|
||||||
|
### "Could not find bin folder for A080"
|
||||||
|
- Verify the bin exists in SilverStack's folder structure
|
||||||
|
- Check the path follows: `HONEY X/Honey/YYYYMMDD_Day XX/02 FOOTAGE/CAM_X/A080XXXX/`
|
||||||
|
|
||||||
|
### Files not being detected
|
||||||
|
- Ensure Resolve is outputting to `~/Movies/AM` or `~/Movies/PM`
|
||||||
|
- Check file extension is `.mxf` (case insensitive)
|
||||||
|
|
||||||
|
### App won't open ("unidentified developer")
|
||||||
|
- Right-click the app and select "Open" instead of double-clicking
|
||||||
|
- Or: System Preferences → Security & Privacy → "Open Anyway"
|
||||||
|
|
||||||
|
### Build fails
|
||||||
|
- Ensure you have Xcode Command Line Tools: `xcode-select --install`
|
||||||
|
- Check Python 3.8+ is installed: `python3 --version`
|
||||||
|
|||||||
169
build_app.sh
Normal file
169
build_app.sh
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Build script for Path Juggler
|
||||||
|
# Creates a standalone macOS .app bundle with ad-hoc code signing
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
APP_NAME="Path Juggler"
|
||||||
|
BUNDLE_ID="com.pathjuggler.app"
|
||||||
|
VENV_DIR="$SCRIPT_DIR/.build_venv"
|
||||||
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
|
|
||||||
|
echo "=== Path Juggler - Build Script ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check for Python 3
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "Error: python3 is required but not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
echo "Using Python $PYTHON_VERSION"
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
echo ""
|
||||||
|
echo "=== Creating virtual environment ==="
|
||||||
|
if [ -d "$VENV_DIR" ]; then
|
||||||
|
echo "Removing existing build environment..."
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
# Upgrade pip
|
||||||
|
echo ""
|
||||||
|
echo "=== Installing build dependencies ==="
|
||||||
|
pip install --upgrade pip --quiet
|
||||||
|
pip install pyinstaller watchdog --quiet
|
||||||
|
|
||||||
|
echo "Installed:"
|
||||||
|
pip list | grep -E "(pyinstaller|watchdog)"
|
||||||
|
|
||||||
|
# Create PyInstaller spec file for more control
|
||||||
|
echo ""
|
||||||
|
echo "=== Creating build configuration ==="
|
||||||
|
|
||||||
|
cat > "$SCRIPT_DIR/path_juggler.spec" << 'SPEC'
|
||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['path_juggler_gui.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[
|
||||||
|
'watchdog.observers',
|
||||||
|
'watchdog.observers.fsevents',
|
||||||
|
'watchdog.events',
|
||||||
|
],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name='Path Juggler',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=False, # No terminal window
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=True, # Important for macOS
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='Path Juggler',
|
||||||
|
)
|
||||||
|
|
||||||
|
app = BUNDLE(
|
||||||
|
coll,
|
||||||
|
name='Path Juggler.app',
|
||||||
|
icon=None,
|
||||||
|
bundle_identifier='com.pathjuggler.app',
|
||||||
|
info_plist={
|
||||||
|
'CFBundleName': 'Path Juggler',
|
||||||
|
'CFBundleDisplayName': 'Path Juggler',
|
||||||
|
'CFBundleVersion': '1.0.0',
|
||||||
|
'CFBundleShortVersionString': '1.0.0',
|
||||||
|
'NSHighResolutionCapable': True,
|
||||||
|
'LSMinimumSystemVersion': '10.13.0',
|
||||||
|
'NSAppleEventsUsageDescription': 'Path Juggler needs to access files.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SPEC
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
echo ""
|
||||||
|
echo "=== Building application ==="
|
||||||
|
pyinstaller --clean --noconfirm path_juggler.spec
|
||||||
|
|
||||||
|
# Sign the app with ad-hoc signature
|
||||||
|
echo ""
|
||||||
|
echo "=== Signing application (ad-hoc) ==="
|
||||||
|
APP_PATH="$DIST_DIR/Path Juggler.app"
|
||||||
|
|
||||||
|
if [ -d "$APP_PATH" ]; then
|
||||||
|
# Remove any existing signatures
|
||||||
|
codesign --remove-signature "$APP_PATH" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Sign with ad-hoc signature (dash means ad-hoc)
|
||||||
|
codesign --force --deep --sign - "$APP_PATH"
|
||||||
|
|
||||||
|
echo "Signature applied"
|
||||||
|
codesign -dv "$APP_PATH" 2>&1 | head -5
|
||||||
|
else
|
||||||
|
echo "Error: App bundle not found at $APP_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
echo ""
|
||||||
|
echo "=== Cleaning up ==="
|
||||||
|
rm -rf "$SCRIPT_DIR/build"
|
||||||
|
rm -f "$SCRIPT_DIR/path_juggler.spec"
|
||||||
|
deactivate
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Application created at:"
|
||||||
|
echo " $APP_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "To install, drag the app to your Applications folder:"
|
||||||
|
echo " open \"$DIST_DIR\""
|
||||||
|
echo ""
|
||||||
|
echo "Note: On first launch, you may need to right-click and select 'Open'"
|
||||||
|
echo "to bypass Gatekeeper since this is a self-signed app."
|
||||||
134
create_icon.py
Normal file
134
create_icon.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a simple icon for Path Juggler.
|
||||||
|
Creates an .icns file for use with the macOS app bundle.
|
||||||
|
|
||||||
|
Requires: pip install Pillow
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
except ImportError:
|
||||||
|
print("Pillow not installed. Install with: pip3 install Pillow")
|
||||||
|
print("Or skip icon generation - the app will use a default icon.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def create_icon_image(size: int) -> Image.Image:
|
||||||
|
"""Create a single icon image at the given size."""
|
||||||
|
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Background - purple/blue gradient-like color (juggler theme)
|
||||||
|
margin = int(size * 0.08)
|
||||||
|
radius = int(size * 0.18)
|
||||||
|
|
||||||
|
# Draw rounded rectangle background
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[margin, margin, size - margin, size - margin],
|
||||||
|
radius=radius,
|
||||||
|
fill=(88, 86, 214, 255) # Purple-blue
|
||||||
|
)
|
||||||
|
|
||||||
|
center_x = size // 2
|
||||||
|
center_y = size // 2
|
||||||
|
|
||||||
|
# Draw three "juggling balls" in an arc
|
||||||
|
ball_radius = int(size * 0.09)
|
||||||
|
arc_radius = int(size * 0.22)
|
||||||
|
|
||||||
|
# Ball positions (arc above center)
|
||||||
|
balls = [
|
||||||
|
(center_x - arc_radius, center_y - int(size * 0.08)), # Left
|
||||||
|
(center_x, center_y - int(size * 0.18)), # Top center
|
||||||
|
(center_x + arc_radius, center_y - int(size * 0.08)), # Right
|
||||||
|
]
|
||||||
|
|
||||||
|
ball_colors = [
|
||||||
|
(255, 107, 107, 255), # Red/coral
|
||||||
|
(78, 205, 196, 255), # Teal
|
||||||
|
(255, 230, 109, 255), # Yellow
|
||||||
|
]
|
||||||
|
|
||||||
|
for (bx, by), color in zip(balls, ball_colors):
|
||||||
|
draw.ellipse(
|
||||||
|
[bx - ball_radius, by - ball_radius, bx + ball_radius, by + ball_radius],
|
||||||
|
fill=color
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw path arrows (two crossing paths)
|
||||||
|
arrow_color = (255, 255, 255, 200)
|
||||||
|
line_width = max(2, int(size * 0.025))
|
||||||
|
|
||||||
|
# Left-to-right arrow
|
||||||
|
arrow_y = center_y + int(size * 0.15)
|
||||||
|
arrow_start = margin + int(size * 0.1)
|
||||||
|
arrow_end = size - margin - int(size * 0.1)
|
||||||
|
|
||||||
|
draw.line(
|
||||||
|
[(arrow_start, arrow_y), (arrow_end - int(size * 0.08), arrow_y)],
|
||||||
|
fill=arrow_color,
|
||||||
|
width=line_width
|
||||||
|
)
|
||||||
|
# Arrow head
|
||||||
|
head_size = int(size * 0.06)
|
||||||
|
draw.polygon([
|
||||||
|
(arrow_end, arrow_y),
|
||||||
|
(arrow_end - head_size, arrow_y - head_size // 2),
|
||||||
|
(arrow_end - head_size, arrow_y + head_size // 2),
|
||||||
|
], fill=arrow_color)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def create_icns(output_path: Path):
|
||||||
|
"""Create an .icns file with all required sizes."""
|
||||||
|
sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
iconset_path = Path(tmpdir) / "icon.iconset"
|
||||||
|
iconset_path.mkdir()
|
||||||
|
|
||||||
|
for size in sizes:
|
||||||
|
img = create_icon_image(size)
|
||||||
|
img.save(iconset_path / f"icon_{size}x{size}.png")
|
||||||
|
|
||||||
|
if size <= 512:
|
||||||
|
img_2x = create_icon_image(size * 2)
|
||||||
|
img_2x.save(iconset_path / f"icon_{size}x{size}@2x.png")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["iconutil", "-c", "icns", str(iconset_path), "-o", str(output_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"iconutil failed: {result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
output_path = script_dir / "AppIcon.icns"
|
||||||
|
|
||||||
|
print("Generating Path Juggler icon...")
|
||||||
|
|
||||||
|
if create_icns(output_path):
|
||||||
|
print(f"Icon created: {output_path}")
|
||||||
|
print("\nTo use this icon in the build:")
|
||||||
|
print("1. Edit build_app.sh")
|
||||||
|
print("2. Find the line: icon=None,")
|
||||||
|
print("3. Change it to: icon='AppIcon.icns',")
|
||||||
|
else:
|
||||||
|
print("Failed to create icon")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
99
path_juggler.py
Normal file
99
path_juggler.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Path Juggler - Command Line Interface
|
||||||
|
|
||||||
|
Watches folders for rendered files and automatically organizes them
|
||||||
|
into structured folder hierarchies based on source material locations.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 path_juggler.py [--dry-run] [--once] [-v]
|
||||||
|
|
||||||
|
For the GUI version, run:
|
||||||
|
python3 path_juggler_gui.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from path_juggler_core import PathJuggler, get_honey_volumes
|
||||||
|
|
||||||
|
# Setup logging for CLI
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%H:%M:%S"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Watch for rendered files and organize into structured folders"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be done without actually moving files"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--once",
|
||||||
|
action="store_true",
|
||||||
|
help="Process existing files and exit (don't watch)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", "-v",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose logging"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gui",
|
||||||
|
action="store_true",
|
||||||
|
help="Launch the GUI version"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Launch GUI if requested
|
||||||
|
if args.gui:
|
||||||
|
from path_juggler_gui import main as gui_main
|
||||||
|
gui_main()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
logger.info("DRY RUN MODE - no files will be moved")
|
||||||
|
|
||||||
|
# Create juggler instance
|
||||||
|
juggler = PathJuggler(dry_run=args.dry_run)
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
# Just process existing files and exit
|
||||||
|
juggler.setup_folders()
|
||||||
|
|
||||||
|
volumes = get_honey_volumes()
|
||||||
|
if volumes:
|
||||||
|
logger.info(f"Found HONEY volumes: {[v.name for v in volumes]}")
|
||||||
|
else:
|
||||||
|
logger.warning("No HONEY volumes currently mounted")
|
||||||
|
|
||||||
|
logger.info("Processing existing files...")
|
||||||
|
juggler.process_existing()
|
||||||
|
logger.info("Done")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start watching
|
||||||
|
juggler.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while juggler.is_running():
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Interrupted")
|
||||||
|
finally:
|
||||||
|
juggler.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
398
path_juggler_core.py
Normal file
398
path_juggler_core.py
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
"""
|
||||||
|
Path Juggler - Core Logic
|
||||||
|
|
||||||
|
This module contains the file watching and processing logic,
|
||||||
|
separated from any UI concerns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread, Event
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
WATCH_FOLDERS = {
|
||||||
|
"AM": Path.home() / "Movies" / "AM",
|
||||||
|
"PM": Path.home() / "Movies" / "PM",
|
||||||
|
}
|
||||||
|
DESTINATION_BASE = Path.home() / "Movies" / "Honey Dailies Transfer"
|
||||||
|
VOLUMES_PATH = Path("/Volumes")
|
||||||
|
VOLUME_PATTERN = re.compile(r"^HONEY \d+$")
|
||||||
|
PROJECT_FOLDER = "Honey"
|
||||||
|
FOOTAGE_FOLDER = "02 FOOTAGE"
|
||||||
|
PROXIES_FOLDER = "03 EDITORIAL PROXIES"
|
||||||
|
|
||||||
|
# Regex to parse filenames like A080C002_251209R2.mxf
|
||||||
|
FILENAME_PATTERN = re.compile(
|
||||||
|
r"^([A-Z])(\d{3})C(\d{3})_(\d{6})([A-Z0-9]*)\.mxf$",
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_file_open(file_path: Path) -> bool:
|
||||||
|
"""Check if a file is currently open by any process using lsof."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["lsof", str(file_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning(f"lsof timed out for {file_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"lsof failed: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_owner_process(file_path: Path) -> str | None:
|
||||||
|
"""Get the name of the process that has the file open."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["lsof", "-t", str(file_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
pid = result.stdout.strip().split('\n')[0]
|
||||||
|
ps_result = subprocess.run(
|
||||||
|
["ps", "-p", pid, "-o", "comm="],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if ps_result.returncode == 0:
|
||||||
|
return ps_result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_file_ready(
|
||||||
|
file_path: Path,
|
||||||
|
stop_event: Event | None = None,
|
||||||
|
check_interval: float = 2.0,
|
||||||
|
stable_duration: float = 5.0,
|
||||||
|
timeout: float = 3600.0
|
||||||
|
) -> bool:
|
||||||
|
"""Wait until a file is no longer being written to."""
|
||||||
|
start_time = time.time()
|
||||||
|
last_size = -1
|
||||||
|
size_stable_since = None
|
||||||
|
logged_process = False
|
||||||
|
|
||||||
|
logger.info(f"Waiting for file to be ready: {file_path.name}")
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# Check if we should stop
|
||||||
|
if stop_event and stop_event.is_set():
|
||||||
|
logger.info(f"Stop requested, abandoning: {file_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.warning(f"File disappeared: {file_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not is_file_open(file_path):
|
||||||
|
logger.info(f"File closed by all processes: {file_path.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not logged_process:
|
||||||
|
owner = get_file_owner_process(file_path)
|
||||||
|
if owner:
|
||||||
|
logger.info(f"File held by: {owner}")
|
||||||
|
logged_process = True
|
||||||
|
|
||||||
|
current_size = file_path.stat().st_size
|
||||||
|
|
||||||
|
if current_size == last_size:
|
||||||
|
if size_stable_since is None:
|
||||||
|
size_stable_since = time.time()
|
||||||
|
elif time.time() - size_stable_since >= stable_duration:
|
||||||
|
time.sleep(1)
|
||||||
|
if not is_file_open(file_path):
|
||||||
|
logger.info(f"File ready (closed): {file_path.name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
size_stable_since = None
|
||||||
|
size_mb = current_size / (1024 * 1024)
|
||||||
|
logger.debug(f"File growing: {file_path.name} ({size_mb:.1f} MB)")
|
||||||
|
|
||||||
|
last_size = current_size
|
||||||
|
time.sleep(check_interval)
|
||||||
|
|
||||||
|
logger.error(f"Timeout waiting for file: {file_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_honey_volumes() -> list[Path]:
|
||||||
|
"""Find all mounted HONEY volumes."""
|
||||||
|
if not VOLUMES_PATH.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
volumes = []
|
||||||
|
for item in VOLUMES_PATH.iterdir():
|
||||||
|
if item.is_dir() and VOLUME_PATTERN.match(item.name):
|
||||||
|
volumes.append(item)
|
||||||
|
|
||||||
|
return sorted(volumes)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filename(filename: str) -> dict | None:
|
||||||
|
"""Parse an MXF filename to extract camera and reel info."""
|
||||||
|
match = FILENAME_PATTERN.match(filename)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
camera, reel, clip, date, suffix = match.groups()
|
||||||
|
return {
|
||||||
|
"camera": camera.upper(),
|
||||||
|
"reel": reel,
|
||||||
|
"clip": clip,
|
||||||
|
"date": date,
|
||||||
|
"suffix": suffix,
|
||||||
|
"reel_prefix": f"{camera.upper()}{reel}",
|
||||||
|
"original_filename": filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_bin_folder(reel_prefix: str, volumes: list[Path]) -> dict | None:
|
||||||
|
"""Search HONEY volumes for a bin folder matching the reel prefix."""
|
||||||
|
for volume in volumes:
|
||||||
|
project_path = volume / PROJECT_FOLDER
|
||||||
|
if not project_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for day_folder in project_path.iterdir():
|
||||||
|
if not day_folder.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
footage_path = day_folder / FOOTAGE_FOLDER
|
||||||
|
if not footage_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for cam_folder in footage_path.iterdir():
|
||||||
|
if not cam_folder.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for bin_folder in cam_folder.iterdir():
|
||||||
|
if not bin_folder.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if bin_folder.name.upper().startswith(reel_prefix.upper()):
|
||||||
|
return {
|
||||||
|
"volume": volume,
|
||||||
|
"day_folder": day_folder.name,
|
||||||
|
"camera_folder": cam_folder.name,
|
||||||
|
"bin_folder": bin_folder.name,
|
||||||
|
"full_path": bin_folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_destination_path(bin_info: dict, am_pm: str, destination_base: Path) -> Path:
|
||||||
|
"""Build the destination path for the proxy file."""
|
||||||
|
day_with_ampm = f"{bin_info['day_folder']} {am_pm}"
|
||||||
|
|
||||||
|
return (
|
||||||
|
destination_base
|
||||||
|
/ day_with_ampm
|
||||||
|
/ PROXIES_FOLDER
|
||||||
|
/ bin_info["camera_folder"]
|
||||||
|
/ bin_info["bin_folder"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(file_path: Path, am_pm: str, dry_run: bool = False) -> bool:
|
||||||
|
"""Process a single MXF file: find matching bin and move to destination."""
|
||||||
|
filename = file_path.name
|
||||||
|
|
||||||
|
file_info = parse_filename(filename)
|
||||||
|
if not file_info:
|
||||||
|
logger.warning(f"Could not parse filename: {filename}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Processing: {filename} (Reel: {file_info['reel_prefix']}, {am_pm})")
|
||||||
|
|
||||||
|
volumes = get_honey_volumes()
|
||||||
|
if not volumes:
|
||||||
|
logger.error("No HONEY volumes found!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
bin_info = find_bin_folder(file_info["reel_prefix"], volumes)
|
||||||
|
if not bin_info:
|
||||||
|
logger.error(f"Could not find bin folder for {file_info['reel_prefix']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Found bin: {bin_info['bin_folder']} in {bin_info['day_folder']}")
|
||||||
|
|
||||||
|
dest_folder = build_destination_path(bin_info, am_pm, DESTINATION_BASE)
|
||||||
|
dest_file = dest_folder / filename
|
||||||
|
|
||||||
|
logger.info(f"Destination: {dest_file}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info("[DRY RUN] Would create folder and move file")
|
||||||
|
return True
|
||||||
|
|
||||||
|
dest_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.move(str(file_path), str(dest_file))
|
||||||
|
logger.info(f"✓ Moved: {filename}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to move file: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class MXFHandler(FileSystemEventHandler):
|
||||||
|
"""Watchdog handler for new MXF files."""
|
||||||
|
|
||||||
|
def __init__(self, am_pm: str, stop_event: Event, dry_run: bool = False):
|
||||||
|
self.am_pm = am_pm
|
||||||
|
self.stop_event = stop_event
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self.processing = set()
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = Path(event.src_path)
|
||||||
|
|
||||||
|
if file_path.suffix.lower() != ".mxf":
|
||||||
|
return
|
||||||
|
|
||||||
|
if str(file_path) in self.processing:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.processing.add(str(file_path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if wait_for_file_ready(file_path, self.stop_event):
|
||||||
|
process_file(file_path, self.am_pm, self.dry_run)
|
||||||
|
finally:
|
||||||
|
self.processing.discard(str(file_path))
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
dest_path = Path(event.dest_path)
|
||||||
|
if dest_path.suffix.lower() == ".mxf":
|
||||||
|
if str(dest_path) not in self.processing:
|
||||||
|
self.processing.add(str(dest_path))
|
||||||
|
try:
|
||||||
|
if wait_for_file_ready(dest_path, self.stop_event):
|
||||||
|
process_file(dest_path, self.am_pm, self.dry_run)
|
||||||
|
finally:
|
||||||
|
self.processing.discard(str(dest_path))
|
||||||
|
|
||||||
|
|
||||||
|
class PathJuggler:
|
||||||
|
"""
|
||||||
|
Main controller class for Path Juggler.
|
||||||
|
Can be used by CLI or GUI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dry_run: bool = False):
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self.observer: Observer | None = None
|
||||||
|
self.stop_event = Event()
|
||||||
|
self.running = False
|
||||||
|
self._thread: Thread | None = None
|
||||||
|
|
||||||
|
def setup_folders(self):
|
||||||
|
"""Ensure watch and destination folders exist."""
|
||||||
|
for name, folder in WATCH_FOLDERS.items():
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info(f"Watch folder ({name}): {folder}")
|
||||||
|
|
||||||
|
DESTINATION_BASE.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info(f"Destination: {DESTINATION_BASE}")
|
||||||
|
|
||||||
|
def process_existing(self):
|
||||||
|
"""Process any MXF files already in watch folders."""
|
||||||
|
for am_pm, folder in WATCH_FOLDERS.items():
|
||||||
|
if not folder.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for file_path in folder.glob("*.mxf"):
|
||||||
|
if self.stop_event.is_set():
|
||||||
|
return
|
||||||
|
if is_file_open(file_path):
|
||||||
|
logger.info(f"Skipping (still being written): {file_path.name}")
|
||||||
|
continue
|
||||||
|
process_file(file_path, am_pm, self.dry_run)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start watching for files."""
|
||||||
|
if self.running:
|
||||||
|
logger.warning("Already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_event.clear()
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
self.setup_folders()
|
||||||
|
|
||||||
|
# Log volumes
|
||||||
|
volumes = get_honey_volumes()
|
||||||
|
if volumes:
|
||||||
|
logger.info(f"Found volumes: {[v.name for v in volumes]}")
|
||||||
|
else:
|
||||||
|
logger.warning("No HONEY volumes currently mounted")
|
||||||
|
|
||||||
|
# Process existing files
|
||||||
|
logger.info("Checking for existing files...")
|
||||||
|
self.process_existing()
|
||||||
|
|
||||||
|
# Start observer
|
||||||
|
self.observer = Observer()
|
||||||
|
|
||||||
|
for am_pm, folder in WATCH_FOLDERS.items():
|
||||||
|
handler = MXFHandler(am_pm, self.stop_event, self.dry_run)
|
||||||
|
self.observer.schedule(handler, str(folder), recursive=False)
|
||||||
|
logger.info(f"Watching: {folder}")
|
||||||
|
|
||||||
|
self.observer.start()
|
||||||
|
logger.info("Watcher started - waiting for new files...")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop watching for files."""
|
||||||
|
if not self.running:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Stopping watcher...")
|
||||||
|
self.stop_event.set()
|
||||||
|
|
||||||
|
if self.observer:
|
||||||
|
self.observer.stop()
|
||||||
|
self.observer.join(timeout=5)
|
||||||
|
self.observer = None
|
||||||
|
|
||||||
|
self.running = False
|
||||||
|
logger.info("Watcher stopped")
|
||||||
|
|
||||||
|
def start_async(self):
|
||||||
|
"""Start watching in a background thread."""
|
||||||
|
self._thread = Thread(target=self.start, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self.running
|
||||||
326
path_juggler_gui.py
Normal file
326
path_juggler_gui.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Path Juggler - GUI Application
|
||||||
|
|
||||||
|
A simple macOS GUI for watching Resolve render outputs and
|
||||||
|
organizing them into editorial proxy folder structures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, scrolledtext
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from path_juggler_core import (
|
||||||
|
PathJuggler,
|
||||||
|
get_honey_volumes,
|
||||||
|
WATCH_FOLDERS,
|
||||||
|
DESTINATION_BASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueHandler(logging.Handler):
|
||||||
|
"""Logging handler that puts log records into a queue for GUI consumption."""
|
||||||
|
|
||||||
|
def __init__(self, log_queue: queue.Queue):
|
||||||
|
super().__init__()
|
||||||
|
self.log_queue = log_queue
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
self.log_queue.put(record)
|
||||||
|
|
||||||
|
|
||||||
|
class PathJugglerApp:
|
||||||
|
"""Main GUI Application."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.root = tk.Tk()
|
||||||
|
self.root.title("Path Juggler")
|
||||||
|
self.root.geometry("700x500")
|
||||||
|
self.root.minsize(500, 300)
|
||||||
|
|
||||||
|
# Set macOS-like appearance
|
||||||
|
self.root.configure(bg="#f0f0f0")
|
||||||
|
|
||||||
|
# Core juggler instance
|
||||||
|
self.juggler = PathJuggler(dry_run=False)
|
||||||
|
|
||||||
|
# Logging queue for thread-safe log updates
|
||||||
|
self.log_queue: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
# Setup logging to our queue
|
||||||
|
self._setup_logging()
|
||||||
|
|
||||||
|
# Build UI
|
||||||
|
self._create_widgets()
|
||||||
|
|
||||||
|
# Start polling the log queue
|
||||||
|
self._poll_log_queue()
|
||||||
|
|
||||||
|
# Update status periodically
|
||||||
|
self._update_status()
|
||||||
|
|
||||||
|
# Handle window close
|
||||||
|
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||||
|
|
||||||
|
def _setup_logging(self):
|
||||||
|
"""Configure logging to use our queue handler."""
|
||||||
|
# Create queue handler
|
||||||
|
queue_handler = QueueHandler(self.log_queue)
|
||||||
|
queue_handler.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Format
|
||||||
|
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%H:%M:%S")
|
||||||
|
queue_handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Add to root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
root_logger.addHandler(queue_handler)
|
||||||
|
|
||||||
|
# Also add to our module logger
|
||||||
|
core_logger = logging.getLogger("path_juggler_core")
|
||||||
|
core_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
"""Create all UI widgets."""
|
||||||
|
# Main container with padding
|
||||||
|
main_frame = ttk.Frame(self.root, padding="10")
|
||||||
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
# === Header / Status Section ===
|
||||||
|
header_frame = ttk.Frame(main_frame)
|
||||||
|
header_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title_label = ttk.Label(
|
||||||
|
header_frame,
|
||||||
|
text="Path Juggler",
|
||||||
|
font=("SF Pro Display", 18, "bold")
|
||||||
|
)
|
||||||
|
title_label.pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
# Status indicator
|
||||||
|
self.status_frame = ttk.Frame(header_frame)
|
||||||
|
self.status_frame.pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
self.status_dot = tk.Canvas(
|
||||||
|
self.status_frame,
|
||||||
|
width=12,
|
||||||
|
height=12,
|
||||||
|
highlightthickness=0,
|
||||||
|
bg="#f0f0f0"
|
||||||
|
)
|
||||||
|
self.status_dot.pack(side=tk.LEFT, padx=(0, 5))
|
||||||
|
self._draw_status_dot("stopped")
|
||||||
|
|
||||||
|
self.status_label = ttk.Label(
|
||||||
|
self.status_frame,
|
||||||
|
text="Stopped",
|
||||||
|
font=("SF Pro Text", 12)
|
||||||
|
)
|
||||||
|
self.status_label.pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
# === Info Section ===
|
||||||
|
info_frame = ttk.LabelFrame(main_frame, text="Status", padding="10")
|
||||||
|
info_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
|
||||||
|
# Volumes status
|
||||||
|
volumes_frame = ttk.Frame(info_frame)
|
||||||
|
volumes_frame.pack(fill=tk.X, pady=(0, 5))
|
||||||
|
|
||||||
|
ttk.Label(volumes_frame, text="Volumes:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT)
|
||||||
|
self.volumes_label = ttk.Label(volumes_frame, text="Checking...", font=("SF Pro Text", 11))
|
||||||
|
self.volumes_label.pack(side=tk.LEFT, padx=(5, 0))
|
||||||
|
|
||||||
|
# Watch folders
|
||||||
|
watch_frame = ttk.Frame(info_frame)
|
||||||
|
watch_frame.pack(fill=tk.X, pady=(0, 5))
|
||||||
|
|
||||||
|
ttk.Label(watch_frame, text="Watching:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT)
|
||||||
|
watch_paths = ", ".join([f"~/{p.relative_to(Path.home())}" for p in WATCH_FOLDERS.values()])
|
||||||
|
ttk.Label(watch_frame, text=watch_paths, font=("SF Pro Text", 11)).pack(side=tk.LEFT, padx=(5, 0))
|
||||||
|
|
||||||
|
# Destination
|
||||||
|
dest_frame = ttk.Frame(info_frame)
|
||||||
|
dest_frame.pack(fill=tk.X)
|
||||||
|
|
||||||
|
ttk.Label(dest_frame, text="Output:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT)
|
||||||
|
dest_path = f"~/{DESTINATION_BASE.relative_to(Path.home())}"
|
||||||
|
ttk.Label(dest_frame, text=dest_path, font=("SF Pro Text", 11)).pack(side=tk.LEFT, padx=(5, 0))
|
||||||
|
|
||||||
|
# === Control Buttons ===
|
||||||
|
button_frame = ttk.Frame(main_frame)
|
||||||
|
button_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
|
||||||
|
# Style for buttons
|
||||||
|
style = ttk.Style()
|
||||||
|
style.configure("Start.TButton", font=("SF Pro Text", 12))
|
||||||
|
style.configure("Stop.TButton", font=("SF Pro Text", 12))
|
||||||
|
|
||||||
|
self.start_button = ttk.Button(
|
||||||
|
button_frame,
|
||||||
|
text="▶ Start Juggling",
|
||||||
|
command=self._start,
|
||||||
|
style="Start.TButton",
|
||||||
|
width=20
|
||||||
|
)
|
||||||
|
self.start_button.pack(side=tk.LEFT, padx=(0, 10))
|
||||||
|
|
||||||
|
self.stop_button = ttk.Button(
|
||||||
|
button_frame,
|
||||||
|
text="◼ Stop",
|
||||||
|
command=self._stop,
|
||||||
|
style="Stop.TButton",
|
||||||
|
state=tk.DISABLED,
|
||||||
|
width=15
|
||||||
|
)
|
||||||
|
self.stop_button.pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
# Clear log button
|
||||||
|
clear_button = ttk.Button(
|
||||||
|
button_frame,
|
||||||
|
text="Clear Log",
|
||||||
|
command=self._clear_log,
|
||||||
|
width=10
|
||||||
|
)
|
||||||
|
clear_button.pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
# === Log Section ===
|
||||||
|
log_frame = ttk.LabelFrame(main_frame, text="Activity Log", padding="5")
|
||||||
|
log_frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
# Scrolled text widget for logs
|
||||||
|
self.log_text = scrolledtext.ScrolledText(
|
||||||
|
log_frame,
|
||||||
|
wrap=tk.WORD,
|
||||||
|
font=("SF Mono", 11),
|
||||||
|
bg="#1e1e1e",
|
||||||
|
fg="#d4d4d4",
|
||||||
|
insertbackground="#d4d4d4",
|
||||||
|
selectbackground="#264f78",
|
||||||
|
state=tk.DISABLED,
|
||||||
|
height=15
|
||||||
|
)
|
||||||
|
self.log_text.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
# Configure log text tags for different log levels
|
||||||
|
self.log_text.tag_configure("INFO", foreground="#d4d4d4")
|
||||||
|
self.log_text.tag_configure("WARNING", foreground="#dcdcaa")
|
||||||
|
self.log_text.tag_configure("ERROR", foreground="#f14c4c")
|
||||||
|
self.log_text.tag_configure("DEBUG", foreground="#808080")
|
||||||
|
self.log_text.tag_configure("timestamp", foreground="#6a9955")
|
||||||
|
|
||||||
|
def _draw_status_dot(self, status: str):
|
||||||
|
"""Draw the status indicator dot."""
|
||||||
|
self.status_dot.delete("all")
|
||||||
|
|
||||||
|
colors = {
|
||||||
|
"running": "#34c759", # Green
|
||||||
|
"stopped": "#8e8e93", # Gray
|
||||||
|
"error": "#ff3b30", # Red
|
||||||
|
}
|
||||||
|
color = colors.get(status, colors["stopped"])
|
||||||
|
|
||||||
|
self.status_dot.create_oval(2, 2, 10, 10, fill=color, outline=color)
|
||||||
|
|
||||||
|
def _start(self):
|
||||||
|
"""Start the file watcher."""
|
||||||
|
self.start_button.config(state=tk.DISABLED)
|
||||||
|
self.stop_button.config(state=tk.NORMAL)
|
||||||
|
|
||||||
|
self._draw_status_dot("running")
|
||||||
|
self.status_label.config(text="Juggling")
|
||||||
|
|
||||||
|
# Start juggler in background thread
|
||||||
|
self.juggler.start_async()
|
||||||
|
|
||||||
|
def _stop(self):
|
||||||
|
"""Stop the file watcher."""
|
||||||
|
self.stop_button.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
self.juggler.stop()
|
||||||
|
|
||||||
|
self.start_button.config(state=tk.NORMAL)
|
||||||
|
self._draw_status_dot("stopped")
|
||||||
|
self.status_label.config(text="Stopped")
|
||||||
|
|
||||||
|
def _clear_log(self):
|
||||||
|
"""Clear the log text area."""
|
||||||
|
self.log_text.config(state=tk.NORMAL)
|
||||||
|
self.log_text.delete(1.0, tk.END)
|
||||||
|
self.log_text.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
def _poll_log_queue(self):
|
||||||
|
"""Poll the log queue and update the text widget."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
record = self.log_queue.get_nowait()
|
||||||
|
self._append_log(record)
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Schedule next poll
|
||||||
|
self.root.after(100, self._poll_log_queue)
|
||||||
|
|
||||||
|
def _append_log(self, record: logging.LogRecord):
|
||||||
|
"""Append a log record to the text widget."""
|
||||||
|
self.log_text.config(state=tk.NORMAL)
|
||||||
|
|
||||||
|
# Format: [HH:MM:SS] LEVEL - Message
|
||||||
|
timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
# Insert timestamp
|
||||||
|
self.log_text.insert(tk.END, f"[{timestamp}] ", "timestamp")
|
||||||
|
|
||||||
|
# Insert message with level-based coloring
|
||||||
|
level = record.levelname
|
||||||
|
message = f"{record.getMessage()}\n"
|
||||||
|
self.log_text.insert(tk.END, message, level)
|
||||||
|
|
||||||
|
# Auto-scroll to bottom
|
||||||
|
self.log_text.see(tk.END)
|
||||||
|
|
||||||
|
self.log_text.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
def _update_status(self):
|
||||||
|
"""Update the volumes status periodically."""
|
||||||
|
volumes = get_honey_volumes()
|
||||||
|
|
||||||
|
if volumes:
|
||||||
|
names = ", ".join([v.name for v in volumes])
|
||||||
|
self.volumes_label.config(text=names, foreground="")
|
||||||
|
else:
|
||||||
|
self.volumes_label.config(text="No HONEY volumes mounted", foreground="#ff3b30")
|
||||||
|
|
||||||
|
# Check if juggler stopped unexpectedly
|
||||||
|
if not self.juggler.is_running() and self.stop_button["state"] == tk.NORMAL:
|
||||||
|
self._stop()
|
||||||
|
|
||||||
|
# Schedule next update
|
||||||
|
self.root.after(5000, self._update_status)
|
||||||
|
|
||||||
|
def _on_close(self):
|
||||||
|
"""Handle window close."""
|
||||||
|
if self.juggler.is_running():
|
||||||
|
self.juggler.stop()
|
||||||
|
self.root.destroy()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Start the application main loop."""
|
||||||
|
# Initial log message
|
||||||
|
logging.info("Path Juggler ready")
|
||||||
|
logging.info("Click 'Start Juggling' to begin")
|
||||||
|
|
||||||
|
self.root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = PathJugglerApp()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user