├── go.mod ├── go.sum ├── .gitignore ├── .dockerignore ├── .github └── workflows │ └── docker-image.yml ├── LICENSE ├── Dockerfile ├── templates ├── index.html ├── style.css └── app.js ├── README.md └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Nirmata-1/Audiforge 2 | 3 | go 1.24.1 4 | 5 | require github.com/google/uuid v1.6.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 2 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool 12 | *.out 13 | 14 | # Dependency directories 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | go.work.sum 20 | 21 | # env file 22 | .env 23 | 24 | # audiveris directory 25 | audiveris/ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore Git 2 | .git/ 3 | .github/ 4 | 5 | # Ignore the Dockerfile itself 6 | Dockerfile 7 | .dockerignore 8 | .gitignore 9 | 10 | # Ignore Go build stuff 11 | bin/ 12 | *.exe 13 | *.exe~ 14 | *.dll 15 | *.so 16 | *.dylib 17 | 18 | # Ignore any temporary files 19 | .DS_Store 20 | Thumbs.db 21 | 22 | # Ignore any logs or backup files 23 | *.log 24 | *.bak 25 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Trigger workflow on pushes to the main branch 7 | workflow_dispatch: # Allow manual triggering 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # Step 1: Check out the repository 15 | - name: Check out code 16 | uses: actions/checkout@v3 17 | 18 | # Step 2: Log in to Docker Hub 19 | - name: Log in to Docker Hub 20 | uses: docker/login-action@v2 21 | with: 22 | username: ${{ secrets.DOCKER_USERNAME }} 23 | password: ${{ secrets.DOCKER_PASSWORD }} 24 | 25 | # Step 3: Build the Docker image 26 | - name: Build Docker image 27 | run: | 28 | docker build -t ${{ secrets.DOCKER_USERNAME }}/audiforge:latest . 29 | 30 | # Step 4: Push the Docker image to Docker Hub 31 | - name: Push Docker image 32 | run: | 33 | docker push ${{ secrets.DOCKER_USERNAME }}/audiforge:latest 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jermiah Jeffries 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build Go application 2 | FROM golang:1.24 AS go-builder 3 | 4 | WORKDIR /app 5 | COPY . . 6 | COPY templates/ ./templates/ 7 | RUN go build -o audiforge . 8 | 9 | # Stage 2: Build Audiveris and final image 10 | FROM debian:bookworm-slim 11 | 12 | # Install system dependencies 13 | RUN apt-get update && \ 14 | apt-get install -y \ 15 | git \ 16 | wget \ 17 | unzip \ 18 | zip \ 19 | ca-certificates \ 20 | fontconfig \ 21 | fonts-dejavu \ 22 | libfreetype6 \ 23 | && apt-get clean \ 24 | && rm -rf /var/lib/apt/lists/* 25 | 26 | # Install Java 21 JDK 27 | RUN mkdir -p /etc/apt/keyrings && \ 28 | wget -O /etc/apt/keyrings/adoptium.asc https://packages.adoptium.net/artifactory/api/gpg/key/public && \ 29 | echo "deb [signed-by=/etc/apt/keyrings/adoptium.asc] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | \ 30 | tee /etc/apt/sources.list.d/adoptium.list && \ 31 | apt-get update && \ 32 | apt-get install -y temurin-21-jdk 33 | 34 | # Install Gradle 8.7 35 | RUN wget https://services.gradle.org/distributions/gradle-8.7-bin.zip -O /tmp/gradle.zip \ 36 | && unzip -d /opt /tmp/gradle.zip \ 37 | && rm /tmp/gradle.zip 38 | ENV PATH="/opt/gradle-8.7/bin:${PATH}" 39 | 40 | # Build Audiveris 41 | WORKDIR /app 42 | RUN git clone https://github.com/Nirmata-1/audiveris.git 43 | WORKDIR /app/audiveris 44 | RUN ./gradlew build 45 | 46 | # Copy Go artifacts from first stage 47 | COPY --from=go-builder /app/audiforge /app/ 48 | COPY --from=go-builder /app/templates /app/templates 49 | 50 | # Setup environment 51 | RUN mkdir -p /tmp/uploads /tmp/downloads && \ 52 | chmod -R 755 /tmp/uploads /tmp/downloads /app/templates 53 | 54 | ENV AUDIVERIS_HOME=/app/audiveris \ 55 | UPLOAD_DIR=/tmp/uploads \ 56 | DOWNLOAD_DIR=/tmp/downloads \ 57 | LOG="" 58 | 59 | EXPOSE 8080 60 | ENTRYPOINT ["/app/audiforge"] 61 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Audiforge - PDF to MusicXML Converter 7 | 8 | 9 | 10 |
11 |

Audiforge

12 |

PDF to MusicXML Conversion Platform

13 |
14 |
15 |
16 | 17 | 18 | 19 |

Click or drag PDF to convert

20 |
21 |
22 | 23 | 27 | 39 |
40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /templates/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --brand-purple: rgb(216, 138, 246); 3 | --brand-mid: rgb(179, 173, 248); 4 | --brand-blue: rgb(150, 212, 251); 5 | } 6 | 7 | body { 8 | font-family: 'Segoe UI', system-ui, sans-serif; 9 | margin: 0; 10 | min-height: 100vh; 11 | background: linear-gradient( 12 | to right, 13 | var(--brand-purple), 14 | var(--brand-mid), 15 | var(--brand-blue) 16 | ); 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | } 21 | 22 | .container { 23 | width: 90%; 24 | max-width: 600px; 25 | text-align: center; 26 | } 27 | 28 | .brand { 29 | color: white; 30 | font-size: 2.5rem; 31 | margin-bottom: 0.5rem; 32 | text-shadow: 0 2px 4px rgba(0,0,0,0.2); 33 | } 34 | 35 | .subtitle { 36 | color: rgba(255, 255, 255, 0.9); 37 | margin-bottom: 2rem; 38 | font-size: 1.1rem; 39 | } 40 | 41 | .upload-container { 42 | position: relative; 43 | background: rgba(255, 255, 255, 0.95); 44 | border-radius: 16px; 45 | padding: 2rem; 46 | box-shadow: 0 8px 32px rgba(0,0,0,0.1); 47 | backdrop-filter: blur(4px); 48 | } 49 | 50 | .upload-box { 51 | border: 2px dashed var(--brand-mid); 52 | border-radius: 12px; 53 | padding: 3rem 2rem; 54 | cursor: pointer; 55 | transition: all 0.3s ease; 56 | } 57 | 58 | .hidden-input { 59 | position: absolute; 60 | left: -9999px; 61 | width: 1px; 62 | height: 1px; 63 | overflow: hidden; 64 | opacity: 0; 65 | } 66 | 67 | .upload-content { 68 | display: flex; 69 | flex-direction: column; 70 | align-items: center; 71 | gap: 1rem; 72 | } 73 | 74 | .upload-icon { 75 | width: 48px; 76 | height: 48px; 77 | fill: var(--brand-purple); 78 | transition: transform 0.3s ease; 79 | } 80 | 81 | .upload-text { 82 | color: var(--brand-purple); 83 | font-weight: 500; 84 | margin: 0; 85 | font-size: 1.1rem; 86 | } 87 | 88 | #processing { 89 | margin: 2rem 0; 90 | } 91 | 92 | .spinner { 93 | width: 40px; 94 | height: 40px; 95 | margin: 0 auto; 96 | border: 4px solid rgba(179, 173, 248, 0.2); 97 | border-top: 4px solid var(--brand-purple); 98 | border-radius: 50%; 99 | animation: spin 1s linear infinite; 100 | } 101 | 102 | .status-text { 103 | color: var(--brand-purple); 104 | margin-top: 1rem; 105 | } 106 | 107 | #complete { 108 | animation: fadeIn 0.5s ease; 109 | } 110 | 111 | .thank-you { 112 | color: var(--brand-purple); 113 | margin-bottom: 1rem; 114 | } 115 | 116 | .button-group { 117 | display: flex; 118 | gap: 1rem; 119 | flex-wrap: wrap; 120 | justify-content: center; 121 | margin-top: 2rem; 122 | } 123 | 124 | .btn { 125 | padding: 0.8rem 1.5rem; 126 | border: none; 127 | border-radius: 8px; 128 | font-weight: 500; 129 | cursor: pointer; 130 | transition: transform 0.2s ease, opacity 0.2s ease; 131 | } 132 | 133 | .btn:hover { 134 | transform: translateY(-2px); 135 | opacity: 0.9; 136 | } 137 | 138 | .download-btn { 139 | background: var(--brand-purple); 140 | color: white; 141 | } 142 | 143 | .reset-btn { 144 | background: var(--brand-blue); 145 | color: white; 146 | } 147 | 148 | @keyframes spin { 149 | 0% { transform: rotate(0deg); } 150 | 100% { transform: rotate(360deg); } 151 | } 152 | 153 | @keyframes fadeIn { 154 | from { opacity: 0; transform: translateY(10px); } 155 | to { opacity: 1; transform: translateY(0); } 156 | } 157 | 158 | .hidden { 159 | display: none; 160 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audiforge - PDF to MusicXML Conversion 2 | 3 | A **Go-based web service** that leverages **Audiveris** for converting PDF sheet music to MusicXML format. 4 | 5 | --- 6 | 7 | ## Table of Contents 8 | 9 | - [Docker Installation](#docker-installation) 10 | - [Local Development Setup](#local-development-setup) 11 | - [Environment Variables](#environment-variables) 12 | - [Configuration](#configuration) 13 | - [API Endpoints](#api-endpoints) 14 | - [Project Structure](#project-structure) 15 | - [Troubleshooting](#troubleshooting) 16 | - [FAQ](#faq) 17 | - [Contributing](#contributing) 18 | - [License](#license) 19 | 20 | --- 21 | 22 | ## Docker Installation 23 | 24 | ### Prerequisites 25 | 26 | - Docker installed ([installation guide](https://docs.docker.com/get-docker/)) 27 | 28 | ### Steps 29 | 30 | 1. **Pull the Docker image:** 31 | 32 | ```bash 33 | docker pull nirmata1/audiforge:latest 34 | ``` 35 | 36 | 2. **Run the container:** 37 | 38 | ```bash 39 | docker run -d -p 8080:8080 \ 40 | -e LOG=debug #optional 41 | -v /path/to/uploads:/tmp/uploads \ 42 | -v /path/to/downloads:/tmp/downloads \ 43 | nirmata1/audiforge:latest 44 | ``` 45 | --- 46 | 47 | ## Local Development Setup 48 | 49 | ### Requirements 50 | 51 | - Go 1.20+ 52 | - Java 17+ (for Audiveris) 53 | - Gradle 7+ 54 | 55 | ### Installation Guide 56 | 57 | #### Clone Repositories 58 | 59 | ```bash 60 | # Create project directory 61 | mkdir audiforge && cd audiforge 62 | 63 | # Clone Audiveris 64 | git clone https://github.com/Audiveris/audiveris.git 65 | 66 | # Build Audiveris 67 | cd audiveris 68 | ./gradlew build 69 | ``` 70 | 71 | #### Set Up Go Application 72 | 73 | ```bash 74 | cd .. 75 | git clone https://github.com/Nirmata-1/Audiforge.git 76 | cd audiforge-go 77 | go build 78 | ``` 79 | 80 | #### Run the Service 81 | 82 | ```bash 83 | LOG=debug ./audiforge 84 | ``` 85 | 86 | --- 87 | 88 | ## Environment Variables 89 | 90 | | Variable | Default | Description | 91 | |----------|---------|-------------| 92 | | `LOG` | info | Set to `debug` to show Audiveris logs in console | 93 | 94 | --- 95 | 96 | ## Configuration 97 | 98 | ### Key Directories 99 | 100 | | Directory | Path | Purpose | 101 | |----------------|------------------|------------------------------| 102 | | Uploads | `/tmp/uploads` | Temporary PDF storage | 103 | | Downloads | `/tmp/downloads` | Converted MusicXML files | 104 | | Audiveris Home | `./audiveris` | Audiveris engine installation | 105 | 106 | ### Cleanup Process 107 | 108 | - Automatic cleanup runs every hour 109 | - Files older than 1 hour are deleted 110 | - Executed via background goroutine 111 | 112 | --- 113 | 114 | ## API Endpoints 115 | 116 | | Method | Endpoint | Description | 117 | |--------|------------------|--------------------------------| 118 | | POST | `/upload` | Upload PDF file | 119 | | GET | `/status/{id}` | Check conversion status | 120 | | GET | `/download/{id}` | Download ZIP of MusicXML files | 121 | 122 | --- 123 | 124 | ## Project Structure 125 | 126 | ``` 127 | audiforge/ 128 | ├── audiveris/ # Audiveris engine 129 | │ ├── build/ 130 | │ ├── app/ 131 | │ └── gradlew 132 | ├── main.go # Go application 133 | ├── go.mod 134 | ├── go.sum 135 | ├── templates/ # Web UI 136 | └── Dockerfile 137 | ``` 138 | 139 | --- 140 | 141 | ## Troubleshooting 142 | 143 | ### Common Issues 144 | 145 | **Missing Dependencies** 146 | 147 | ```bash 148 | # Install Java & Gradle 149 | sudo apt install openjdk-17-jdk gradle 150 | ``` 151 | 152 | **Permission Denied** 153 | 154 | ```bash 155 | chmod +x audiveris/gradlew 156 | ``` 157 | 158 | **Gradle Build Failure** 159 | 160 | ```bash 161 | cd audiveris && ./gradlew clean build 162 | ``` 163 | 164 | **Missing Files After Conversion** 165 | 166 | - Check `/tmp` permissions 167 | - Verify available disk space 168 | 169 | --- 170 | 171 | ## FAQ 172 | 173 | **Q: Can it handle multi-movement scores?** 174 | A: The service automatically detects movements and packages them in a ZIP file. 175 | 176 | **Q: Can I change the cleanup interval?** 177 | A: Modify `CleanupInterval` in `main.go` and rebuild. 178 | 179 | **Q: How to enable debug logging?** 180 | A: Run with `LOG=debug ./audiforge` 181 | 182 | --- 183 | 184 | ## Contributing 185 | 186 | 1. Fork the repository 187 | 2. Create a feature branch 188 | 3. Submit a PR with tests 189 | 4. Follow better coding standards than me 190 | 191 | --- 192 | 193 | ## License 194 | 195 | **Apache 2.0 © 2025 Jermiah Jeffries** 196 | -------------------------------------------------------------------------------- /templates/app.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | const dropZone = document.getElementById('drop-zone'); 3 | const fileInput = document.getElementById('file-input'); 4 | const processingDiv = document.getElementById('processing'); 5 | const completeDiv = document.getElementById('complete'); 6 | const subtitle = completeDiv.querySelector('.subtitle'); 7 | let currentConversionId = null; 8 | 9 | // Drag & drop handlers 10 | const handleDrag = (e) => { 11 | e.preventDefault(); 12 | dropZone.style.borderColor = 'var(--brand-purple)'; 13 | dropZone.style.background = 'rgba(255, 255, 255, 0.8)'; 14 | }; 15 | 16 | dropZone.addEventListener('dragover', handleDrag); 17 | dropZone.addEventListener('dragleave', () => { 18 | dropZone.style.borderColor = 'var(--brand-mid)'; 19 | dropZone.style.background = 'rgba(255, 255, 255, 0.95)'; 20 | }); 21 | 22 | dropZone.addEventListener('drop', (e) => { 23 | e.preventDefault(); 24 | handleFile(e.dataTransfer.files[0]); 25 | resetDropZone(); 26 | }); 27 | 28 | // Single click handler 29 | dropZone.addEventListener('click', () => fileInput.click()); 30 | 31 | // File input handler 32 | fileInput.addEventListener('change', (e) => { 33 | if (e.target.files.length > 0) { 34 | handleFile(e.target.files[0]); 35 | e.target.value = ''; 36 | } 37 | }); 38 | 39 | // Download handler 40 | document.getElementById('download-again').addEventListener('click', () => { 41 | if (currentConversionId) { 42 | window.location.href = `/download/${currentConversionId}`; 43 | } 44 | }); 45 | 46 | // New conversion handler 47 | document.getElementById('new-conversion').addEventListener('click', resetUI); 48 | 49 | async function handleFile(file) { 50 | if (!file?.name.endsWith('.pdf')) return; 51 | 52 | const formData = new FormData(); 53 | formData.append('file', file); 54 | 55 | try { 56 | showProcessing(); 57 | const response = await fetch('/upload', { 58 | method: 'POST', 59 | body: formData 60 | }); 61 | 62 | if (!response.ok) throw new Error(await response.text()); 63 | 64 | const { id } = await response.json(); 65 | currentConversionId = id; 66 | pollStatus(id); 67 | } catch (error) { 68 | showError(error.message); 69 | } 70 | } 71 | 72 | async function pollStatus(id) { 73 | try { 74 | const response = await fetch(`/status/${id}`); 75 | if (!response.ok) throw new Error('Status check failed'); 76 | 77 | const status = await response.json(); 78 | 79 | switch (status.status) { 80 | case 'completed': 81 | showConversionComplete(status.movementCount); 82 | break; 83 | case 'error': 84 | throw new Error(status.message); 85 | default: 86 | setTimeout(() => pollStatus(id), 1000); 87 | } 88 | } catch (error) { 89 | showError(error.message); 90 | } 91 | } 92 | 93 | function showProcessing() { 94 | dropZone.classList.add('hidden'); 95 | processingDiv.classList.remove('hidden'); 96 | completeDiv.classList.add('hidden'); 97 | } 98 | 99 | function showConversionComplete(movementCount) { 100 | subtitle.textContent = `Successfully converted ${movementCount} ${movementCount === 1 ? 'movement' : 'movements'}`; 101 | processingDiv.classList.add('hidden'); 102 | completeDiv.classList.remove('hidden'); 103 | } 104 | 105 | function showError(message) { 106 | processingDiv.classList.add('hidden'); 107 | completeDiv.innerHTML = ` 108 |

⚠️ Conversion Error

109 |

${message}

110 |
111 | 114 |
115 | `; 116 | completeDiv.classList.remove('hidden'); 117 | } 118 | 119 | function resetDropZone() { 120 | dropZone.style.borderColor = 'var(--brand-mid)'; 121 | dropZone.style.background = 'rgba(255, 255, 255, 0.95)'; 122 | } 123 | 124 | function resetUI() { 125 | currentConversionId = null; 126 | completeDiv.classList.add('hidden'); 127 | dropZone.classList.remove('hidden'); 128 | processingDiv.classList.add('hidden'); 129 | } 130 | }); -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html/template" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/google/uuid" 18 | ) 19 | 20 | const ( 21 | UploadDir = "/tmp/uploads" 22 | DownloadDir = "/tmp/downloads" 23 | CleanupInterval = 1 * time.Hour 24 | FileTTL = 1 * time.Hour 25 | ) 26 | 27 | type ProcessingStatus struct { 28 | Status string `json:"status"` 29 | Message string `json:"message"` 30 | Timestamp int64 `json:"timestamp"` 31 | MovementCount int `json:"movementCount,omitempty"` 32 | } 33 | 34 | var ( 35 | processing sync.Map 36 | templates *template.Template 37 | ) 38 | 39 | func init() { 40 | os.MkdirAll(UploadDir, 0755) 41 | os.MkdirAll(DownloadDir, 0755) 42 | templates = template.Must(template.ParseGlob("/app/templates/*.html")) 43 | go startCleanupRoutine() 44 | } 45 | 46 | // I can't believe this Janky code works. Good luck to whoever reads this. 47 | func main() { 48 | http.HandleFunc("/", indexHandler) 49 | http.HandleFunc("/upload", uploadHandler) 50 | http.HandleFunc("/status/", statusHandler) 51 | http.HandleFunc("/download/", downloadHandler) 52 | http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("/app/templates")))) 53 | 54 | log.Println("Server started on :8080") 55 | log.Fatal(http.ListenAndServe(":8080", nil)) 56 | } 57 | 58 | // Add the cleanup routine 59 | func startCleanupRoutine() { 60 | for { 61 | time.Sleep(CleanupInterval) 62 | cleanupFiles() 63 | } 64 | } 65 | 66 | func cleanupFiles() { 67 | if os.Getenv("LOG") != "debug" { 68 | return 69 | } 70 | 71 | log.Println("Starting cleanup routine...") 72 | cleanupDirectory(UploadDir, cleanUploads) 73 | cleanupDirectory(DownloadDir, cleanDownloads) 74 | } 75 | 76 | func cleanupDirectory(path string, cleanFunc func(string) error) { 77 | err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { 78 | if err != nil { 79 | return nil // Skip unreadable files 80 | } 81 | 82 | // Check if the file is older than TTL 83 | if time.Since(info.ModTime()) > FileTTL { 84 | // Call the cleanup function for old files 85 | if err := cleanFunc(filePath); err != nil { 86 | log.Printf("Cleanup failed for %s: %v", filePath, err) 87 | } 88 | } 89 | return nil 90 | }) 91 | 92 | if err != nil { 93 | log.Printf("Cleanup error: %v", err) 94 | } 95 | } 96 | 97 | func cleanUploads(path string) error { 98 | if strings.HasSuffix(path, ".pdf") { 99 | if err := os.Remove(path); err == nil { 100 | log.Printf("Cleaned up upload file: %s", path) 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | func cleanDownloads(path string) error { 107 | // Resolve absolute paths to handle symlinks/relative paths 108 | downloadDirAbs, err := filepath.Abs(DownloadDir) 109 | if err != nil { 110 | return fmt.Errorf("failed to resolve DownloadDir: %v", err) 111 | } 112 | 113 | fileDir := filepath.Dir(path) 114 | fileDirAbs, err := filepath.Abs(fileDir) 115 | if err != nil { 116 | return nil // Skip if path resolution fails 117 | } 118 | 119 | // Check if the directory is a subdirectory of DownloadDir 120 | if strings.HasPrefix(fileDirAbs, downloadDirAbs + string(filepath.Separator)) && 121 | fileDirAbs != downloadDirAbs { 122 | 123 | // Delete the entire subdirectory 124 | if err := os.RemoveAll(fileDir); err != nil { 125 | return fmt.Errorf("failed to delete %s: %v", fileDir, err) 126 | } 127 | log.Printf("Cleaned up download directory: %s", fileDir) 128 | return filepath.SkipDir // Skip further processing of this directory 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func indexHandler(w http.ResponseWriter, r *http.Request) { 135 | templates.ExecuteTemplate(w, "index.html", nil) 136 | } 137 | 138 | func uploadHandler(w http.ResponseWriter, r *http.Request) { 139 | if r.Method != "POST" { 140 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 141 | return 142 | } 143 | 144 | file, header, err := r.FormFile("file") 145 | if err != nil { 146 | http.Error(w, "Invalid file", http.StatusBadRequest) 147 | return 148 | } 149 | defer file.Close() 150 | 151 | if ext := filepath.Ext(header.Filename); ext != ".pdf" { 152 | http.Error(w, "Only PDF files allowed", http.StatusBadRequest) 153 | return 154 | } 155 | 156 | id := uuid.New().String() 157 | uploadPath := filepath.Join(UploadDir, id+".pdf") 158 | 159 | out, err := os.Create(uploadPath) 160 | if err != nil { 161 | http.Error(w, "Failed to save file", http.StatusInternalServerError) 162 | return 163 | } 164 | defer out.Close() 165 | 166 | if _, err := io.Copy(out, file); err != nil { 167 | http.Error(w, "Failed to save file", http.StatusInternalServerError) 168 | return 169 | } 170 | 171 | processing.Store(id, ProcessingStatus{ 172 | Status: "processing", 173 | Message: "File uploaded, starting conversion", 174 | Timestamp: time.Now().Unix(), 175 | }) 176 | 177 | go processFile(id, uploadPath) 178 | 179 | w.Header().Set("Content-Type", "application/json") 180 | json.NewEncoder(w).Encode(map[string]string{"id": id}) 181 | } 182 | 183 | func processFile(id, inputPath string) { 184 | outputDir := filepath.Join(DownloadDir, id) 185 | if err := os.MkdirAll(outputDir, 0755); err != nil { 186 | log.Printf("Error creating output dir: %v", err) 187 | processing.Store(id, ProcessingStatus{ 188 | Status: "error", 189 | Message: fmt.Sprintf("Failed to create output directory: %v", err), 190 | Timestamp: time.Now().Unix(), 191 | }) 192 | return 193 | } 194 | 195 | cmdArgs := []string{ 196 | "-batch", 197 | "-export", 198 | "-output", outputDir, 199 | "--", inputPath, 200 | } 201 | 202 | cmd := exec.Command( 203 | "gradle", 204 | "run", 205 | "-PjvmLineArgs=-Xmx3g", 206 | fmt.Sprintf("-PcmdLineArgs=%s", escapeArgs(cmdArgs)), 207 | ) 208 | cmd.Dir = "/app/audiveris" 209 | 210 | logPath := filepath.Join(outputDir, "conversion.log") 211 | outputFile, err := os.Create(logPath) 212 | if err != nil { 213 | log.Printf("Error creating log file: %v", err) 214 | processing.Store(id, ProcessingStatus{ 215 | Status: "error", 216 | Message: fmt.Sprintf("Failed to create log file: %v", err), 217 | Timestamp: time.Now().Unix(), 218 | }) 219 | return 220 | } 221 | defer outputFile.Close() 222 | 223 | var logWriter io.Writer = outputFile 224 | if os.Getenv("LOG") == "debug" { 225 | logWriter = io.MultiWriter(outputFile, os.Stdout) 226 | } 227 | 228 | cmd.Stdout = logWriter 229 | cmd.Stderr = logWriter 230 | 231 | log.Printf("\n=== START Processing %s ===", id) 232 | defer log.Printf("=== END Processing %s ===\n", id) 233 | 234 | processing.Store(id, ProcessingStatus{ 235 | Status: "processing", 236 | Message: "Converting PDF to MusicXML", 237 | Timestamp: time.Now().Unix(), 238 | }) 239 | 240 | runErr := cmd.Run() 241 | 242 | // Check for generated movements 243 | files, _ := filepath.Glob(filepath.Join(outputDir, "*.mxl")) 244 | movementCount := len(files) 245 | 246 | // Update processFile to check for both errors and files 247 | if movementCount > 0 { 248 | msg := "Conversion completed with potential warnings" 249 | status := "completed" 250 | if runErr != nil { 251 | msg = fmt.Sprintf("Conversion completed with errors (%v)", runErr) 252 | } 253 | processing.Store(id, ProcessingStatus{ 254 | Status: status, 255 | Message: msg, 256 | Timestamp: time.Now().Unix(), 257 | MovementCount: movementCount, 258 | }) 259 | } else { 260 | errorMsg := "Conversion failed - no movements generated" 261 | if runErr != nil { 262 | errorMsg += fmt.Sprintf(" (exec error: %v)", runErr) 263 | } 264 | processing.Store(id, ProcessingStatus{ 265 | Status: "error", 266 | Message: errorMsg, 267 | Timestamp: time.Now().Unix(), 268 | }) 269 | } 270 | } 271 | 272 | func escapeArgs(args []string) string { 273 | var escaped []string 274 | for _, arg := range args { 275 | if strings.ContainsAny(arg, " ,") { 276 | escaped = append(escaped, fmt.Sprintf(`"%s"`, arg)) 277 | } else { 278 | escaped = append(escaped, arg) 279 | } 280 | } 281 | return strings.Join(escaped, ",") 282 | } 283 | 284 | func statusHandler(w http.ResponseWriter, r *http.Request) { 285 | id := strings.TrimPrefix(r.URL.Path, "/status/") 286 | status, ok := processing.Load(id) 287 | if !ok { 288 | http.Error(w, "Invalid ID", http.StatusNotFound) 289 | return 290 | } 291 | 292 | w.Header().Set("Content-Type", "application/json") 293 | json.NewEncoder(w).Encode(status) 294 | } 295 | 296 | func downloadHandler(w http.ResponseWriter, r *http.Request) { 297 | id := strings.TrimPrefix(r.URL.Path, "/download/") 298 | outputDir := filepath.Join(DownloadDir, id) 299 | 300 | // Find all MXL files 301 | files, err := filepath.Glob(filepath.Join(outputDir, "*.mxl")) 302 | if err != nil || len(files) == 0 { 303 | http.Error(w, "No movements found", http.StatusNotFound) 304 | return 305 | } 306 | 307 | // Create ZIP archive 308 | zipPath := filepath.Join(outputDir, "converted.zip") 309 | args := append([]string{"-j", zipPath}, files...) 310 | cmd := exec.Command("zip", args...) 311 | if err := cmd.Run(); err != nil { 312 | http.Error(w, "Failed to create ZIP archive: " + err.Error(), http.StatusInternalServerError) 313 | return 314 | } 315 | 316 | // Serve ZIP file 317 | w.Header().Set("Content-Type", "application/zip") 318 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", id)) 319 | http.ServeFile(w, r, zipPath) 320 | } 321 | --------------------------------------------------------------------------------