Compare commits

..

No commits in common. "2c3c86e07d4c5da4e7f7dd43d5121dfccc255ed5" and "e778e0ec8fe9629a83169659fe5e9122280a26b3" have entirely different histories.

23 changed files with 51 additions and 2052 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View file

@ -1,2 +1 @@
/target /target
*.mp4

104
DEPLOY.md
View file

@ -1,104 +0,0 @@
# Deployment Instructions
## Prerequisites
Before deploying, ensure:
1. **SSH Key Setup**: You have passwordless SSH access to the server:
```bash
ssh -p 8443 rockvilleav@remote.rockvilletollandsda.church
```
2. **Mac SSH Setup**: The server can SSH to your Mac on port 8443:
```bash
# Test from the server:
ssh -p 8443 benjaminslingo@macbook-pro.slingoapps.dev
```
3. **Directory on Mac**: Create the destination directory:
```bash
mkdir -p ~/rtsda/livestreams
```
## Deployment
Simply run the deployment script:
```bash
./deploy.sh
```
This will:
- Build the release binary
- Package all necessary files
- Copy to the server
- Install as a systemd service
- Start the service
- Show service status
## Manual Deployment (if script fails)
1. **Build locally**:
```bash
cargo build --release
```
2. **Copy files to server**:
```bash
scp -P 8443 target/release/livestream_archiver rockvilleav@remote.rockvilletollandsda.church:/tmp/
scp -P 8443 livestream-archiver.service rockvilleav@remote.rockvilletollandsda.church:/tmp/
```
3. **Install on server**:
```bash
ssh -p 8443 rockvilleav@remote.rockvilletollandsda.church
# Create directories
sudo mkdir -p /home/rockvilleav/livestream-archiver
sudo mkdir -p /home/rockvilleav/Sync/Livestreams
sudo mkdir -p /media/archive/jellyfin/livestreams
# Move binary
sudo mv /tmp/livestream_archiver /home/rockvilleav/livestream-archiver/
sudo chmod +x /home/rockvilleav/livestream-archiver/livestream_archiver
# Install service
sudo mv /tmp/livestream-archiver.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable livestream-archiver
sudo systemctl start livestream-archiver
# Set permissions
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/livestream-archiver
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/Sync/Livestreams
```
## Management Commands
```bash
# Check status
sudo systemctl status livestream-archiver
# View logs
sudo journalctl -u livestream-archiver -f
# Restart service
sudo systemctl restart livestream-archiver
# Stop service
sudo systemctl stop livestream-archiver
```
## Testing
1. **Place a test file** in `/home/rockvilleav/Sync/Livestreams/` (name it like `2024-07-21_10-30-00.mp4`)
2. **Check logs** to see if it detects and processes the file
3. **Verify sync** to your Mac at `~/rtsda/livestreams/`
4. **Check Jellyfin** output at `/media/archive/jellyfin/livestreams/`
## Troubleshooting
- **SSH connection issues**: Verify port 8443 is open and SSH keys are set up
- **Permission errors**: Ensure directories have correct ownership (`rockvilleav:rockvilleav`)
- **rsync failures**: Check network connectivity and SSH key authentication
- **Service won't start**: Check logs with `journalctl -u livestream-archiver`

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Rockville Tolland Seventh-day Adventist Church
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
README.md
View file

@ -1,130 +0,0 @@
# Livestream Archiver
A Rust application that monitors a directory for livestream recordings, processes them, and syncs them to a remote server. Designed for automated livestream archiving with hardware-accelerated transcoding.
## Features
- **File Detection**: Monitors a configured directory for new MP4 files
- **Readiness Check**: Waits for files to be completely written before processing
- **Remote Sync**: Uses rsync to send files to a remote server immediately after detection
- **Caching & Retry**: Caches failed syncs and retries them on subsequent runs
- **Processing**: Converts files to AV1 using QSV hardware acceleration
- **Organization**: Creates date-based directory structure in Jellyfin format
- **Cleanup**: Deletes original files after successful processing and sync
- **Systemd Integration**: Runs as a system service with automatic restart
## Installation
### Prerequisites
- Rust toolchain (1.70 or later)
- `rsync` for file synchronization
- `ffmpeg` compiled with Intel QSV support
- SSH key authentication configured for passwordless rsync
### Building from Source
```bash
# Clone the repository
git clone <repository-url>
cd livestream-archiver
# Build release binary
cargo build --release
# Binary will be at target/release/livestream-archiver
```
### Quick Deploy
```bash
# Use the included deploy script
./deploy.sh
```
## Configuration
Set the `PC_SYNC_TARGET` environment variable to configure where files are synced:
```bash
export PC_SYNC_TARGET="user@192.168.1.100:/path/to/destination/"
```
If not set, defaults to: `user@192.168.1.100:/path/to/destination/`
## Usage
### Running Manually
```bash
# Run directly
cargo run
# Or run the compiled binary
./target/release/livestream-archiver
```
### Running as a Service
```bash
# Copy the service file
sudo cp livestream-archiver.service /etc/systemd/system/
# Reload systemd and enable the service
sudo systemctl daemon-reload
sudo systemctl enable livestream-archiver
sudo systemctl start livestream-archiver
# Check service status
sudo systemctl status livestream-archiver
```
The application will:
1. Check for existing unprocessed files
2. Start monitoring for new files
3. For each detected file:
- Wait for it to be ready (stable size/modification time)
- Sync to PC using rsync
- Convert to AV1 format
- Create NFO metadata file
- Delete original file
## Directory Structure
Output files are organized as:
```
<output-directory>/
├── 2024/
│ ├── 01-January/
│ │ ├── Livestream - January 01 2024.mp4
│ │ ├── Livestream - January 01 2024.nfo
│ │ └── ...
│ └── ...
└── ...
```
## Environment Variables
- `PC_SYNC_TARGET`: Remote sync destination (e.g., `user@server:/path/to/destination/`)
- `INPUT_DIR`: Directory to monitor for new files (default: `/home/user/Sync/Livestreams`)
- `OUTPUT_DIR`: Local output directory for processed files (default: `/media/archive/jellyfin/livestreams`)
## Troubleshooting
### Service not starting
- Check logs: `journalctl -u livestream-archiver -f`
- Verify paths exist and have proper permissions
- Ensure ffmpeg has QSV support: `ffmpeg -hwaccels | grep qsv`
### Sync failures
- Verify SSH key authentication to remote server
- Check network connectivity
- Review cached sync files in `~/.cache/livestream-archiver/`
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

View file

@ -1,87 +0,0 @@
#!/bin/bash
# Deployment script for Livestream Archiver
# Usage: ./deploy.sh
SERVER="rockvilleav@remote.rockvilletollandsda.church"
PORT="8443"
REMOTE_PATH="/home/rockvilleav/livestream-archiver"
SERVICE_NAME="livestream-archiver"
echo "🚀 Starting deployment to $SERVER:$PORT"
# Build release binary
echo "📦 Building release binary..."
cargo build --release
if [ $? -ne 0 ]; then
echo "❌ Build failed!"
exit 1
fi
echo "✅ Build successful!"
# Create deployment directory
echo "📁 Creating deployment directory..."
mkdir -p deploy
# Copy necessary files to deploy directory
echo "📋 Copying files..."
cp target/release/livestream_archiver deploy/
cp livestream-archiver.service deploy/
cp README.md deploy/
# Create deployment package
echo "📦 Creating deployment package..."
tar -czf livestream-archiver-deploy.tar.gz -C deploy .
# Copy to server
echo "🔄 Copying to server..."
scp -P $PORT livestream-archiver-deploy.tar.gz $SERVER:/tmp/
# Deploy on server
echo "🚀 Deploying on server..."
ssh -p $PORT $SERVER << 'EOF'
# Stop existing service if running
sudo systemctl stop livestream-archiver 2>/dev/null || true
# Create application directory
sudo mkdir -p /home/rockvilleav/livestream-archiver
# Extract deployment package
cd /tmp
tar -xzf livestream-archiver-deploy.tar.gz -C /home/rockvilleav/livestream-archiver/
# Set permissions
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/livestream-archiver
sudo chmod +x /home/rockvilleav/livestream-archiver/livestream_archiver
# Install systemd service
sudo cp /home/rockvilleav/livestream-archiver/livestream-archiver.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable livestream-archiver
# Create required directories
sudo mkdir -p /home/rockvilleav/Sync/Livestreams
sudo mkdir -p /media/archive/jellyfin/livestreams
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/Sync/Livestreams
# Start service
sudo systemctl start livestream-archiver
# Check status
echo "📊 Service status:"
sudo systemctl status livestream-archiver --no-pager
# Clean up
rm -f /tmp/livestream-archiver-deploy.tar.gz
EOF
echo "✅ Deployment complete!"
echo "📊 To check logs: ssh -p $PORT $SERVER 'sudo journalctl -u livestream-archiver -f'"
echo "🔄 To restart: ssh -p $PORT $SERVER 'sudo systemctl restart livestream-archiver'"
# Clean up local files
rm -rf deploy livestream-archiver-deploy.tar.gz
echo "🎉 All done!"

Binary file not shown.

View file

@ -1,16 +0,0 @@
[Unit]
Description=Livestream Archiver Service
After=network.target
[Service]
Type=simple
User=rockvilleav
Group=rockvilleav
WorkingDirectory=/home/rockvilleav/livestream-archiver
ExecStart=/home/rockvilleav/livestream-archiver/target/release/livestream_archiver
Environment=PC_SYNC_TARGET=benjaminslingo@macbook-pro.slingoapps.dev:~/rtsda/livestreams/
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

BIN
output.mp4 Normal file

Binary file not shown.

View file

@ -1,94 +0,0 @@
#!/bin/bash
# Package source code for deployment to ARM server
echo "📦 Creating source package for ARM deployment..."
# Create package directory
mkdir -p package
# Copy source files
echo "📋 Copying source files..."
cp -r src/ package/
cp Cargo.toml package/
cp Cargo.lock package/
cp livestream-archiver.service package/
cp README.md package/
cp DEPLOY.md package/
# Create deployment script for the server
cat > package/build-and-deploy.sh << 'EOF'
#!/bin/bash
echo "🔧 Building on ARM server..."
# Install Rust if not present
if ! command -v cargo &> /dev/null; then
echo "📥 Installing Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
fi
# Build release
echo "📦 Building release binary..."
cargo build --release
if [ $? -ne 0 ]; then
echo "❌ Build failed!"
exit 1
fi
# Stop existing service if running
echo "🛑 Stopping existing service..."
sudo systemctl stop livestream-archiver 2>/dev/null || true
# Create directories
echo "📁 Creating directories..."
sudo mkdir -p /home/rockvilleav/livestream-archiver
sudo mkdir -p /home/rockvilleav/Sync/Livestreams
sudo mkdir -p /media/archive/jellyfin/livestreams
# Install binary
echo "📦 Installing binary..."
sudo cp target/release/livestream_archiver /home/rockvilleav/livestream-archiver/
sudo chmod +x /home/rockvilleav/livestream-archiver/livestream_archiver
# Install service
echo "⚙️ Installing systemd service..."
sudo cp livestream-archiver.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable livestream-archiver
# Set permissions
echo "🔐 Setting permissions..."
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/livestream-archiver
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/Sync/Livestreams
# Start service
echo "🚀 Starting service..."
sudo systemctl start livestream-archiver
# Show status
echo "📊 Service status:"
sudo systemctl status livestream-archiver --no-pager
echo "✅ Deployment complete!"
echo "📊 To check logs: sudo journalctl -u livestream-archiver -f"
echo "🔄 To restart: sudo systemctl restart livestream-archiver"
EOF
chmod +x package/build-and-deploy.sh
# Create the tar.gz package (without macOS extended attributes)
echo "📦 Creating tar.gz package..."
COPYFILE_DISABLE=1 tar --no-xattrs -czf livestream-archiver-source.tar.gz -C package .
# Clean up
rm -rf package
echo "✅ Package created: livestream-archiver-source.tar.gz"
echo ""
echo "📤 To deploy:"
echo "1. scp -P 8443 livestream-archiver-source.tar.gz rockvilleav@remote.rockvilletollandsda.church:/tmp/"
echo "2. ssh -p 8443 rockvilleav@remote.rockvilletollandsda.church"
echo "3. cd /tmp && tar -xzf livestream-archiver-source.tar.gz"
echo "4. ./build-and-deploy.sh"

748
package/Cargo.lock generated
View file

@ -1,748 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "backtrace"
version = "0.3.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets 0.52.6",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "bytes"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
[[package]]
name = "cc"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-targets 0.52.6",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossbeam-channel"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "filetime"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "gimli"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "iana-time-zone"
version = "0.1.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "kqueue"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "libc"
version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.6.0",
"libc",
"redox_syscall",
]
[[package]]
name = "livestream_archiver"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"notify",
"tokio",
]
[[package]]
name = "lock_api"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "miniz_oxide"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi",
"windows-sys 0.52.0",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.6.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "parking_lot"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets 0.52.6",
]
[[package]]
name = "pin-project-lite"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff"
[[package]]
name = "proc-macro2"
version = "1.0.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
dependencies = [
"bitflags 2.6.0",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
dependencies = [
"libc",
]
[[package]]
name = "smallvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "socket2"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "syn"
version = "2.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio 1.0.3",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",
]
[[package]]
name = "tokio-macros"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396"
dependencies = [
"cfg-if",
"once_cell",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

View file

@ -1,14 +0,0 @@
[package]
name = "livestream_archiver"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.36", features = ["full"] }
anyhow = "1.0"
notify = "6.1"
chrono = "0.4"
# We don't need regex or other conversion-related deps
# since we're just copying and renaming files

View file

@ -1,104 +0,0 @@
# Deployment Instructions
## Prerequisites
Before deploying, ensure:
1. **SSH Key Setup**: You have passwordless SSH access to the server:
```bash
ssh -p 8443 rockvilleav@remote.rockvilletollandsda.church
```
2. **Mac SSH Setup**: The server can SSH to your Mac on port 8443:
```bash
# Test from the server:
ssh -p 8443 benjaminslingo@macbook-pro.slingoapps.dev
```
3. **Directory on Mac**: Create the destination directory:
```bash
mkdir -p ~/rtsda/livestreams
```
## Deployment
Simply run the deployment script:
```bash
./deploy.sh
```
This will:
- Build the release binary
- Package all necessary files
- Copy to the server
- Install as a systemd service
- Start the service
- Show service status
## Manual Deployment (if script fails)
1. **Build locally**:
```bash
cargo build --release
```
2. **Copy files to server**:
```bash
scp -P 8443 target/release/livestream_archiver rockvilleav@remote.rockvilletollandsda.church:/tmp/
scp -P 8443 livestream-archiver.service rockvilleav@remote.rockvilletollandsda.church:/tmp/
```
3. **Install on server**:
```bash
ssh -p 8443 rockvilleav@remote.rockvilletollandsda.church
# Create directories
sudo mkdir -p /home/rockvilleav/livestream-archiver
sudo mkdir -p /home/rockvilleav/Sync/Livestreams
sudo mkdir -p /media/archive/jellyfin/livestreams
# Move binary
sudo mv /tmp/livestream_archiver /home/rockvilleav/livestream-archiver/
sudo chmod +x /home/rockvilleav/livestream-archiver/livestream_archiver
# Install service
sudo mv /tmp/livestream-archiver.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable livestream-archiver
sudo systemctl start livestream-archiver
# Set permissions
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/livestream-archiver
sudo chown -R rockvilleav:rockvilleav /home/rockvilleav/Sync/Livestreams
```
## Management Commands
```bash
# Check status
sudo systemctl status livestream-archiver
# View logs
sudo journalctl -u livestream-archiver -f
# Restart service
sudo systemctl restart livestream-archiver
# Stop service
sudo systemctl stop livestream-archiver
```
## Testing
1. **Place a test file** in `/home/rockvilleav/Sync/Livestreams/` (name it like `2024-07-21_10-30-00.mp4`)
2. **Check logs** to see if it detects and processes the file
3. **Verify sync** to your Mac at `~/rtsda/livestreams/`
4. **Check Jellyfin** output at `/media/archive/jellyfin/livestreams/`
## Troubleshooting
- **SSH connection issues**: Verify port 8443 is open and SSH keys are set up
- **Permission errors**: Ensure directories have correct ownership (`rockvilleav:rockvilleav`)
- **rsync failures**: Check network connectivity and SSH key authentication
- **Service won't start**: Check logs with `journalctl -u livestream-archiver`

View file

@ -1,59 +0,0 @@
# Livestream Archiver
A Rust application that monitors a directory for livestream recordings, processes them, and syncs them to a PC.
## Features
- **File Detection**: Monitors `/home/rockvilleav/Sync/Livestreams` for new MP4 files
- **Readiness Check**: Waits for files to be completely written before processing
- **PC Sync**: Uses rsync to send files to your PC immediately after detection
- **Caching & Retry**: Caches failed syncs and retries them on subsequent runs
- **Processing**: Converts files to AV1 using QSV hardware acceleration
- **Organization**: Creates date-based directory structure in Jellyfin format
- **Cleanup**: Deletes original files after successful processing and sync
## Configuration
Set the `PC_SYNC_TARGET` environment variable to configure where files are synced:
```bash
export PC_SYNC_TARGET="user@192.168.1.100:/path/to/destination/"
```
If not set, defaults to: `user@192.168.1.100:/path/to/destination/`
## Usage
```bash
cargo run
```
The application will:
1. Check for existing unprocessed files
2. Start monitoring for new files
3. For each detected file:
- Wait for it to be ready (stable size/modification time)
- Sync to PC using rsync
- Convert to AV1 format
- Create NFO metadata file
- Delete original file
## Dependencies
- `rsync` must be installed and accessible in PATH
- `ffmpeg` with QSV support for hardware acceleration
- SSH key authentication should be set up for passwordless rsync
## Directory Structure
Output files are organized as:
```
/media/archive/jellyfin/livestreams/
├── 2024/
│ ├── 01-January/
│ │ ├── Divine Worship Service - RTSDA | January 01 2024.mp4
│ │ ├── Divine Worship Service - RTSDA | January 01 2024.nfo
│ │ └── ...
│ └── ...
└── ...
```

View file

@ -1,16 +0,0 @@
[Unit]
Description=Livestream Archiver Service
After=network.target
[Service]
Type=simple
User=rockvilleav
Group=rockvilleav
WorkingDirectory=/home/rockvilleav/livestream-archiver
ExecStart=/home/rockvilleav/livestream-archiver/target/release/livestream_archiver
Environment=PC_SYNC_TARGET=benjaminslingo@macbook-pro.slingoapps.dev:~/rtsda/livestreams/
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View file

@ -1,134 +0,0 @@
use std::path::PathBuf;
use anyhow::Result;
use notify::{Watcher, RecursiveMode, Event, EventKind};
use tokio::sync::mpsc;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
mod services;
use services::livestream_archiver::LivestreamArchiver;
#[tokio::main]
async fn main() -> Result<()> {
let watch_path = PathBuf::from("/home/rockvilleav/Sync/Livestreams");
let output_path = PathBuf::from("/media/archive/jellyfin/livestreams");
// Ensure directories exist
if !watch_path.exists() {
std::fs::create_dir_all(&watch_path)?;
}
if !output_path.exists() {
std::fs::create_dir_all(&output_path)?;
}
println!("Starting livestream archiver service...");
println!("Watching directory: {}", watch_path.display());
println!("Output directory: {}", output_path.display());
// Configure PC sync target (replace with your actual PC address and path)
let pc_sync_target = std::env::var("PC_SYNC_TARGET")
.unwrap_or_else(|_| "benjaminslingo@macbook-pro.slingoapps.dev:~/rtsda/livestreams/".to_string());
let archiver = Arc::new(Mutex::new(
LivestreamArchiver::with_pc_sync(output_path.clone(), pc_sync_target)
));
let processed_files = Arc::new(Mutex::new(HashSet::new()));
// Process existing files first
println!("Checking for existing files...");
if let Ok(entries) = std::fs::read_dir(&watch_path) {
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
// Only process .mp4 files
if path.extension().and_then(|ext| ext.to_str()) == Some("mp4") {
// Extract date from filename to check if output exists
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
let archiver_guard = archiver.lock().unwrap();
if let Ok(date) = archiver_guard.extract_date_from_filename(filename).await {
// Check if either Divine Worship or Afternoon Program exists for this date
let year_dir = archiver_guard.get_output_path().join(date.format("%Y").to_string());
let month_dir = year_dir.join(format!("{}-{}",
date.format("%m"),
date.format("%B")
));
let divine_worship_file = month_dir.join(format!(
"Divine Worship Service - RTSDA | {}.mp4",
date.format("%B %d %Y")
));
let afternoon_program_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {}.mp4",
date.format("%B %d %Y")
));
if !divine_worship_file.exists() && !afternoon_program_file.exists() {
println!("Found unprocessed file: {}", path.display());
drop(archiver_guard); // Release lock before async operation
let mut archiver_mut = archiver.lock().unwrap();
if let Err(e) = archiver_mut.process_file(path).await {
eprintln!("Error processing existing file: {}", e);
}
} else {
println!("Skipping already processed file: {}", path.display());
}
}
}
}
}
}
}
// Set up file watcher for new files
let (tx, mut rx) = mpsc::channel(100);
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
let tx = tx.clone();
match res {
Ok(event) => {
println!("Received event: {:?}", event);
if let Err(e) = tx.blocking_send(event) {
eprintln!("Error sending event: {}", e);
}
}
Err(e) => eprintln!("Watch error: {}", e),
}
})?;
watcher.watch(&watch_path, RecursiveMode::NonRecursive)?;
while let Some(event) = rx.recv().await {
println!("Processing event: {:?}", event);
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {
for path in event.paths {
if let Ok(canonical_path) = std::fs::canonicalize(&path) {
let path_str = canonical_path.to_string_lossy().to_string();
let processed = processed_files.lock().unwrap();
if !processed.contains(&path_str) {
println!("Processing file: {}", path_str);
drop(processed); // Release processed files lock
let mut archiver_mut = archiver.lock().unwrap();
if let Err(e) = archiver_mut.process_file(path).await {
eprintln!("Error processing file: {}", e);
} else {
let mut processed = processed_files.lock().unwrap();
processed.insert(path_str);
if processed.len() > 1000 {
processed.clear();
}
}
} else {
println!("Skipping already processed file: {}", path_str);
}
}
}
},
_ => println!("Ignoring event: {:?}", event),
}
}
Ok(())
}

View file

@ -1,313 +0,0 @@
use std::path::PathBuf;
use anyhow::{Result, anyhow};
use chrono::NaiveDateTime;
use tokio::process::Command;
use tokio::time::Duration;
use std::collections::VecDeque;
pub struct LivestreamArchiver {
output_path: PathBuf,
pc_sync_target: Option<String>,
sync_cache: VecDeque<PathBuf>,
}
impl LivestreamArchiver {
pub fn new(output_path: PathBuf) -> Self {
LivestreamArchiver {
output_path,
pc_sync_target: None,
sync_cache: VecDeque::new(),
}
}
pub fn with_pc_sync(output_path: PathBuf, pc_sync_target: String) -> Self {
LivestreamArchiver {
output_path,
pc_sync_target: Some(pc_sync_target),
sync_cache: VecDeque::new(),
}
}
pub fn get_output_path(&self) -> &PathBuf {
&self.output_path
}
async fn sync_to_pc(&mut self, file_path: &PathBuf) -> Result<()> {
if let Some(target) = &self.pc_sync_target {
println!("Syncing {} to PC at {}", file_path.display(), target);
let status = Command::new("rsync")
.arg("-avz")
.arg("--progress")
.arg("-e")
.arg("ssh -p 8443")
.arg(file_path)
.arg(target)
.status()
.await?;
if status.success() {
println!("Successfully synced {} to PC", file_path.display());
Ok(())
} else {
println!("Failed to sync {} to PC, adding to cache", file_path.display());
self.sync_cache.push_back(file_path.clone());
Err(anyhow!("Rsync failed"))
}
} else {
println!("No PC sync target configured, skipping sync");
Ok(())
}
}
async fn retry_cached_syncs(&mut self) -> Result<()> {
if let Some(target) = &self.pc_sync_target {
let mut successful_syncs = Vec::new();
for (index, file_path) in self.sync_cache.iter().enumerate() {
println!("Retrying sync for cached file: {}", file_path.display());
let status = Command::new("rsync")
.arg("-avz")
.arg("--progress")
.arg("-e")
.arg("ssh -p 8443")
.arg(file_path)
.arg(target)
.status()
.await?;
if status.success() {
println!("Successfully synced cached file: {}", file_path.display());
successful_syncs.push(index);
} else {
println!("Still failed to sync: {}", file_path.display());
}
}
// Remove successfully synced files from cache (in reverse order to maintain indices)
for &index in successful_syncs.iter().rev() {
self.sync_cache.remove(index);
}
}
Ok(())
}
async fn wait_for_file_ready(&self, path: &PathBuf) -> Result<()> {
println!("Waiting for file to be ready: {}", path.display());
// Initial delay - let OBS get started
tokio::time::sleep(Duration::from_secs(10)).await;
let mut last_size = 0;
let mut stable_count = 0;
let mut last_modified = std::time::SystemTime::now();
let required_stable_checks = 15; // Must be stable for 30 seconds
// Check for up to 4 hours (14400 seconds / 2 second interval = 7200 iterations)
for i in 0..7200 {
match tokio::fs::metadata(path).await {
Ok(metadata) => {
let current_size = metadata.len();
let current_modified = metadata.modified()?;
println!("Check {}: Size = {} bytes, Last Modified: {:?}", i, current_size, current_modified);
if current_size > 0 {
if current_size == last_size {
// Also check if file hasn't been modified recently
if current_modified == last_modified {
stable_count += 1;
println!("Size and modification time stable for {} checks", stable_count);
if stable_count >= required_stable_checks {
println!("File appears complete - size and modification time stable for 30 seconds");
// Extra 30 second buffer after stability to be sure
tokio::time::sleep(Duration::from_secs(30)).await;
return Ok(());
}
} else {
println!("File still being modified");
stable_count = 0;
}
} else {
println!("Size changed: {} -> {}", last_size, current_size);
stable_count = 0;
}
last_size = current_size;
last_modified = current_modified;
}
},
Err(e) => {
println!("Error checking file: {}", e);
return Err(anyhow!("Failed to check file metadata: {}", e));
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
// If we reach here, it timed out after 4 hours - something is wrong
println!("Timeout after 4 hours - file is still being written?");
Err(anyhow!("Timeout after 4 hours waiting for file to stabilize"))
}
pub async fn extract_date_from_filename(&self, filename: &str) -> Result<NaiveDateTime> {
// Example filename: "2024-12-27_18-42-36.mp4"
let date_time_str = filename
.strip_suffix(".mp4")
.ok_or_else(|| anyhow!("Invalid filename format"))?;
// Parse the full date and time
let date = NaiveDateTime::parse_from_str(date_time_str, "%Y-%m-%d_%H-%M-%S")?;
Ok(date)
}
pub async fn process_file(&mut self, path: PathBuf) -> Result<()> {
// Only process .mp4 files
if path.extension().and_then(|ext| ext.to_str()) != Some("mp4") {
return Err(anyhow!("Ignoring non-MP4 file"));
}
println!("Processing livestream recording: {}", path.display());
// Wait for file to be fully copied
self.wait_for_file_ready(&path).await?;
// Try to retry any cached syncs first
if let Err(e) = self.retry_cached_syncs().await {
println!("Warning: Failed to retry cached syncs: {}", e);
}
// Sync the file to PC immediately after detection and readiness check
if let Err(e) = self.sync_to_pc(&path).await {
println!("Warning: Failed to sync file to PC: {}", e);
}
// Get the filename
let filename = path.file_name()
.ok_or_else(|| anyhow!("Invalid filename"))?
.to_str()
.ok_or_else(|| anyhow!("Invalid UTF-8 in filename"))?;
// Extract date from filename
let date = self.extract_date_from_filename(filename).await?;
// Create date-based directory structure
let year_dir = self.output_path.join(date.format("%Y").to_string());
let month_dir = year_dir.join(format!("{}-{}",
date.format("%m"), // numeric month (12)
date.format("%B") // full month name (December)
));
// Create directories if they don't exist
tokio::fs::create_dir_all(&month_dir).await?;
// Check for existing files
let divine_worship_file = month_dir.join(format!(
"Divine Worship Service - RTSDA | {}.mp4",
date.format("%B %d %Y")
));
let afternoon_program_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {}.mp4",
date.format("%B %d %Y")
));
// Determine which filename to use
let (base_filename, nfo_title, nfo_tag) = if !divine_worship_file.exists() {
(
format!("Divine Worship Service - RTSDA | {}", date.format("%B %d %Y")),
format!("Divine Worship Service - RTSDA | {}", date.format("%B %-d %Y")),
"Divine Worship Service"
)
} else if !afternoon_program_file.exists() {
(
format!("Afternoon Program - RTSDA | {}", date.format("%B %d %Y")),
format!("Afternoon Program - RTSDA | {}", date.format("%B %-d %Y")),
"Afternoon Program"
)
} else {
// Both exist, add suffix to Afternoon Program
let mut suffix = 1;
let mut test_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {} ({}).mp4",
date.format("%B %d %Y"),
suffix
));
while test_file.exists() {
suffix += 1;
test_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {} ({}).mp4",
date.format("%B %d %Y"),
suffix
));
}
(
format!("Afternoon Program - RTSDA | {} ({})", date.format("%B %d %Y"), suffix),
format!("Afternoon Program - RTSDA | {} ({})", date.format("%B %-d %Y"), suffix),
"Afternoon Program"
)
};
let output_file = month_dir.join(format!("{}.mp4", base_filename));
println!("Converting to AV1 and saving to: {}", output_file.display());
// Build ffmpeg command for AV1 conversion using QSV
let status = Command::new("ffmpeg")
.arg("-init_hw_device").arg("qsv=hw")
.arg("-filter_hw_device").arg("hw")
.arg("-hwaccel").arg("qsv")
.arg("-hwaccel_output_format").arg("qsv")
.arg("-i").arg(&path)
.arg("-c:v").arg("av1_qsv")
.arg("-preset").arg("4")
.arg("-b:v").arg("6M")
.arg("-maxrate").arg("12M")
.arg("-bufsize").arg("24M")
.arg("-c:a").arg("copy")
.arg("-n") // Never overwrite existing files
.arg(&output_file)
.status()
.await?;
if !status.success() {
return Err(anyhow!("FFmpeg conversion failed"));
}
// Create NFO file
println!("Creating NFO file...");
let nfo_content = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<episodedetails>
<title>{}</title>
<showtitle>LiveStreams</showtitle>
<season>{}</season>
<episode>{}</episode>
<aired>{}</aired>
<displayseason>{}</displayseason>
<displayepisode>{}</displayepisode>
<tag>{}</tag>
</episodedetails>"#,
nfo_title,
date.format("%Y").to_string(),
date.format("%m%d").to_string(),
date.format("%Y-%m-%d"),
date.format("%Y"),
date.format("%m%d"),
nfo_tag
);
let nfo_path = output_file.with_extension("nfo");
tokio::fs::write(nfo_path, nfo_content).await?;
println!("Successfully converted {} to AV1 and created NFO", path.display());
// Delete original file after successful processing and sync
match tokio::fs::remove_file(&path).await {
Ok(_) => println!("Successfully deleted original file: {}", path.display()),
Err(e) => println!("Warning: Failed to delete original file {}: {}", path.display(), e),
}
Ok(())
}
}

View file

@ -1 +0,0 @@
pub mod livestream_archiver;

BIN
src/.DS_Store vendored

Binary file not shown.

View file

@ -10,7 +10,7 @@ use services::livestream_archiver::LivestreamArchiver;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let watch_path = PathBuf::from("/mnt/sync/Livestreams"); let watch_path = PathBuf::from("/home/rockvilleav/Sync/Livestreams");
let output_path = PathBuf::from("/media/archive/jellyfin/livestreams"); let output_path = PathBuf::from("/media/archive/jellyfin/livestreams");
// Ensure directories exist // Ensure directories exist
@ -25,13 +25,7 @@ async fn main() -> Result<()> {
println!("Watching directory: {}", watch_path.display()); println!("Watching directory: {}", watch_path.display());
println!("Output directory: {}", output_path.display()); println!("Output directory: {}", output_path.display());
// Configure PC sync target (replace with your actual PC address and path) let archiver = LivestreamArchiver::new(output_path);
let pc_sync_target = std::env::var("PC_SYNC_TARGET")
.unwrap_or_else(|_| "benjaminslingo@macbook-pro.slingoapps.dev:~/rtsda/livestreams/".to_string());
let archiver = Arc::new(Mutex::new(
LivestreamArchiver::with_pc_sync(output_path.clone(), pc_sync_target)
));
let processed_files = Arc::new(Mutex::new(HashSet::new())); let processed_files = Arc::new(Mutex::new(HashSet::new()));
// Process existing files first // Process existing files first
@ -40,41 +34,10 @@ async fn main() -> Result<()> {
for entry in entries { for entry in entries {
if let Ok(entry) = entry { if let Ok(entry) = entry {
let path = entry.path(); let path = entry.path();
// Only process .mp4 files println!("Found existing file: {}", path.display());
if path.extension().and_then(|ext| ext.to_str()) == Some("mp4") { if let Err(e) = archiver.process_file(path).await {
// Extract date from filename to check if output exists
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
let archiver_guard = archiver.lock().unwrap();
if let Ok(date) = archiver_guard.extract_date_from_filename(filename).await {
// Check if either Divine Worship or Afternoon Program exists for this date
let year_dir = archiver_guard.get_output_path().join(date.format("%Y").to_string());
let month_dir = year_dir.join(format!("{}-{}",
date.format("%m"),
date.format("%B")
));
let divine_worship_file = month_dir.join(format!(
"Divine Worship Service - RTSDA | {}.mp4",
date.format("%B %d %Y")
));
let afternoon_program_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {}.mp4",
date.format("%B %d %Y")
));
if !divine_worship_file.exists() && !afternoon_program_file.exists() {
println!("Found unprocessed file: {}", path.display());
drop(archiver_guard); // Release lock before async operation
let mut archiver_mut = archiver.lock().unwrap();
if let Err(e) = archiver_mut.process_file(path).await {
eprintln!("Error processing existing file: {}", e); eprintln!("Error processing existing file: {}", e);
} }
} else {
println!("Skipping already processed file: {}", path.display());
}
}
}
}
} }
} }
} }
@ -105,16 +68,13 @@ async fn main() -> Result<()> {
for path in event.paths { for path in event.paths {
if let Ok(canonical_path) = std::fs::canonicalize(&path) { if let Ok(canonical_path) = std::fs::canonicalize(&path) {
let path_str = canonical_path.to_string_lossy().to_string(); let path_str = canonical_path.to_string_lossy().to_string();
let processed = processed_files.lock().unwrap(); let mut processed = processed_files.lock().unwrap();
if !processed.contains(&path_str) { if !processed.contains(&path_str) {
println!("Processing file: {}", path_str); println!("Processing file: {}", path_str);
drop(processed); // Release processed files lock if let Err(e) = archiver.process_file(path).await {
let mut archiver_mut = archiver.lock().unwrap();
if let Err(e) = archiver_mut.process_file(path).await {
eprintln!("Error processing file: {}", e); eprintln!("Error processing file: {}", e);
} else { } else {
let mut processed = processed_files.lock().unwrap();
processed.insert(path_str); processed.insert(path_str);
if processed.len() > 1000 { if processed.len() > 1000 {
processed.clear(); processed.clear();

View file

@ -3,96 +3,18 @@ use anyhow::{Result, anyhow};
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use tokio::process::Command; use tokio::process::Command;
use tokio::time::Duration; use tokio::time::Duration;
use std::collections::VecDeque;
pub struct LivestreamArchiver { pub struct LivestreamArchiver {
output_path: PathBuf, output_path: PathBuf,
pc_sync_target: Option<String>,
sync_cache: VecDeque<PathBuf>,
} }
impl LivestreamArchiver { impl LivestreamArchiver {
pub fn new(output_path: PathBuf) -> Self { pub fn new(output_path: PathBuf) -> Self {
LivestreamArchiver { LivestreamArchiver {
output_path, output_path,
pc_sync_target: None,
sync_cache: VecDeque::new(),
} }
} }
pub fn with_pc_sync(output_path: PathBuf, pc_sync_target: String) -> Self {
LivestreamArchiver {
output_path,
pc_sync_target: Some(pc_sync_target),
sync_cache: VecDeque::new(),
}
}
pub fn get_output_path(&self) -> &PathBuf {
&self.output_path
}
async fn sync_to_pc(&mut self, file_path: &PathBuf) -> Result<()> {
if let Some(target) = &self.pc_sync_target {
println!("Syncing {} to PC at {}", file_path.display(), target);
let status = Command::new("rsync")
.arg("-avz")
.arg("--progress")
.arg("-e")
.arg("ssh -p 8443")
.arg(file_path)
.arg(target)
.status()
.await?;
if status.success() {
println!("Successfully synced {} to PC", file_path.display());
Ok(())
} else {
println!("Failed to sync {} to PC, adding to cache", file_path.display());
self.sync_cache.push_back(file_path.clone());
Err(anyhow!("Rsync failed"))
}
} else {
println!("No PC sync target configured, skipping sync");
Ok(())
}
}
async fn retry_cached_syncs(&mut self) -> Result<()> {
if let Some(target) = &self.pc_sync_target {
let mut successful_syncs = Vec::new();
for (index, file_path) in self.sync_cache.iter().enumerate() {
println!("Retrying sync for cached file: {}", file_path.display());
let status = Command::new("rsync")
.arg("-avz")
.arg("--progress")
.arg("-e")
.arg("ssh -p 8443")
.arg(file_path)
.arg(target)
.status()
.await?;
if status.success() {
println!("Successfully synced cached file: {}", file_path.display());
successful_syncs.push(index);
} else {
println!("Still failed to sync: {}", file_path.display());
}
}
// Remove successfully synced files from cache (in reverse order to maintain indices)
for &index in successful_syncs.iter().rev() {
self.sync_cache.remove(index);
}
}
Ok(())
}
async fn wait_for_file_ready(&self, path: &PathBuf) -> Result<()> { async fn wait_for_file_ready(&self, path: &PathBuf) -> Result<()> {
println!("Waiting for file to be ready: {}", path.display()); println!("Waiting for file to be ready: {}", path.display());
@ -152,7 +74,7 @@ impl LivestreamArchiver {
Err(anyhow!("Timeout after 4 hours waiting for file to stabilize")) Err(anyhow!("Timeout after 4 hours waiting for file to stabilize"))
} }
pub async fn extract_date_from_filename(&self, filename: &str) -> Result<NaiveDateTime> { async fn extract_date_from_filename(&self, filename: &str) -> Result<NaiveDateTime> {
// Example filename: "2024-12-27_18-42-36.mp4" // Example filename: "2024-12-27_18-42-36.mp4"
let date_time_str = filename let date_time_str = filename
.strip_suffix(".mp4") .strip_suffix(".mp4")
@ -163,7 +85,38 @@ impl LivestreamArchiver {
Ok(date) Ok(date)
} }
pub async fn process_file(&mut self, path: PathBuf) -> Result<()> { async fn create_nfo_file(&self, video_path: &PathBuf, date: &NaiveDateTime) -> Result<()> {
let nfo_path = video_path.with_extension("nfo");
// Format the full title with date including year
let full_title = format!("Divine Worship Service - RTSDA | {}",
date.format("%B %-d %Y") // Format like "December 28 2024"
);
let nfo_content = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<episodedetails>
<title>{}</title>
<showtitle>LiveStreams</showtitle>
<season>{}</season>
<episode>{}</episode>
<aired>{}</aired>
<displayseason>{}</displayseason>
<displayepisode>{}</displayepisode>
<tag>Divine Worship Service</tag>
</episodedetails>"#,
full_title,
date.format("%Y").to_string(),
date.format("%m%d").to_string(),
date.format("%Y-%m-%d"),
date.format("%Y"),
date.format("%m%d")
);
tokio::fs::write(nfo_path, nfo_content).await?;
Ok(())
}
pub async fn process_file(&self, path: PathBuf) -> Result<()> {
// Only process .mp4 files // Only process .mp4 files
if path.extension().and_then(|ext| ext.to_str()) != Some("mp4") { if path.extension().and_then(|ext| ext.to_str()) != Some("mp4") {
return Err(anyhow!("Ignoring non-MP4 file")); return Err(anyhow!("Ignoring non-MP4 file"));
@ -171,15 +124,8 @@ impl LivestreamArchiver {
println!("Processing livestream recording: {}", path.display()); println!("Processing livestream recording: {}", path.display());
// Try to retry any cached syncs first // Wait for file to be fully copied
if let Err(e) = self.retry_cached_syncs().await { self.wait_for_file_ready(&path).await?;
println!("Warning: Failed to retry cached syncs: {}", e);
}
// Sync the file to PC immediately after detection and readiness check
if let Err(e) = self.sync_to_pc(&path).await {
println!("Warning: Failed to sync file to PC: {}", e);
}
// Get the filename // Get the filename
let filename = path.file_name() let filename = path.file_name()
@ -200,53 +146,12 @@ impl LivestreamArchiver {
// Create directories if they don't exist // Create directories if they don't exist
tokio::fs::create_dir_all(&month_dir).await?; tokio::fs::create_dir_all(&month_dir).await?;
// Check for existing files // Create output path with .mp4 extension
let divine_worship_file = month_dir.join(format!( let output_file = month_dir.join(format!(
"Divine Worship Service - RTSDA | {}.mp4", "Divine Worship Service - RTSDA | {}{}",
date.format("%B %d %Y")
));
let afternoon_program_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {}.mp4",
date.format("%B %d %Y")
));
// Determine which filename to use
let (base_filename, nfo_title, nfo_tag) = if !divine_worship_file.exists() {
(
format!("Divine Worship Service - RTSDA | {}", date.format("%B %d %Y")),
format!("Divine Worship Service - RTSDA | {}", date.format("%B %-d %Y")),
"Divine Worship Service"
)
} else if !afternoon_program_file.exists() {
(
format!("Afternoon Program - RTSDA | {}", date.format("%B %d %Y")),
format!("Afternoon Program - RTSDA | {}", date.format("%B %-d %Y")),
"Afternoon Program"
)
} else {
// Both exist, add suffix to Afternoon Program
let mut suffix = 1;
let mut test_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {} ({}).mp4",
date.format("%B %d %Y"), date.format("%B %d %Y"),
suffix ".mp4"
)); ));
while test_file.exists() {
suffix += 1;
test_file = month_dir.join(format!(
"Afternoon Program - RTSDA | {} ({}).mp4",
date.format("%B %d %Y"),
suffix
));
}
(
format!("Afternoon Program - RTSDA | {} ({})", date.format("%B %d %Y"), suffix),
format!("Afternoon Program - RTSDA | {} ({})", date.format("%B %-d %Y"), suffix),
"Afternoon Program"
)
};
let output_file = month_dir.join(format!("{}.mp4", base_filename));
println!("Converting to AV1 and saving to: {}", output_file.display()); println!("Converting to AV1 and saving to: {}", output_file.display());
@ -263,7 +168,7 @@ impl LivestreamArchiver {
.arg("-maxrate").arg("12M") .arg("-maxrate").arg("12M")
.arg("-bufsize").arg("24M") .arg("-bufsize").arg("24M")
.arg("-c:a").arg("copy") .arg("-c:a").arg("copy")
.arg("-n") // Never overwrite existing files .arg("-y")
.arg(&output_file) .arg(&output_file)
.status() .status()
.await?; .await?;
@ -274,36 +179,12 @@ impl LivestreamArchiver {
// Create NFO file // Create NFO file
println!("Creating NFO file..."); println!("Creating NFO file...");
let nfo_content = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?> self.create_nfo_file(&output_file, &date).await?;
<episodedetails>
<title>{}</title>
<showtitle>LiveStreams</showtitle>
<season>{}</season>
<episode>{}</episode>
<aired>{}</aired>
<displayseason>{}</displayseason>
<displayepisode>{}</displayepisode>
<tag>{}</tag>
</episodedetails>"#,
nfo_title,
date.format("%Y").to_string(),
date.format("%m%d").to_string(),
date.format("%Y-%m-%d"),
date.format("%Y"),
date.format("%m%d"),
nfo_tag
);
let nfo_path = output_file.with_extension("nfo");
tokio::fs::write(nfo_path, nfo_content).await?;
println!("Successfully converted {} to AV1 and created NFO", path.display()); println!("Successfully converted {} to AV1 and created NFO", path.display());
// Delete original file after successful processing and sync // Don't delete original file
match tokio::fs::remove_file(&path).await { println!("Original file preserved at: {}", path.display());
Ok(_) => println!("Successfully deleted original file: {}", path.display()),
Err(e) => println!("Warning: Failed to delete original file {}: {}", path.display(), e),
}
Ok(()) Ok(())
} }

BIN
test.mp4 Normal file

Binary file not shown.