Compare commits
3 Commits
4843ac1cb5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 539ce3a801 | |||
| 3abb66fbdf | |||
| 8fb68e76ab |
@@ -0,0 +1,126 @@
|
|||||||
|
name: "Bug Report"
|
||||||
|
description: "Report a bug or unexpected behavior to help us improve"
|
||||||
|
title: "[BUG]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## ⚠️ Before You Begin
|
||||||
|
Please ensure you have:
|
||||||
|
- [ ] Searched existing issues to avoid duplicates
|
||||||
|
- [ ] Confirmed this is a bug, not a question or configuration problem
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: "📋 Description"
|
||||||
|
description: "Provide a clear and concise description of the bug"
|
||||||
|
placeholder: "What happened? What did you expect to happen instead?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: "🔁 Steps to Reproduce"
|
||||||
|
description: "Step-by-step instructions to reproduce the issue"
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: "✅ Expected Behavior"
|
||||||
|
description: "What you expected to happen"
|
||||||
|
placeholder: "A clear description of what should happen..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: "❌ Actual Behavior"
|
||||||
|
description: "What actually happened"
|
||||||
|
placeholder: "Include error messages, stack traces, or unexpected outcomes..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: "📄 Logs / Screenshots"
|
||||||
|
description: |
|
||||||
|
Provide relevant logs, error messages, or screenshots.
|
||||||
|
For logs, please use a pastebin and share the URL.
|
||||||
|
**Remember to remove any sensitive information (API keys, passwords, etc.).**[reference:13]
|
||||||
|
placeholder: "Paste logs here or provide a Gist URL..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
---
|
||||||
|
## 🖥️ Environment Details
|
||||||
|
Please fill out the relevant information below.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: "📦 Audio Splitter Version"
|
||||||
|
description: "The version you are using (or commit reference)"
|
||||||
|
placeholder: "e.g., v1.21.7"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: "💻 Operating System"
|
||||||
|
description: "Your OS and version"
|
||||||
|
placeholder: "e.g., Ubuntu 22.04, macOS Sonoma 14.5, Windows"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: "🌍 Browser (if applicable)"
|
||||||
|
description: "Browser name and version"
|
||||||
|
placeholder: "e.g., Chrome 120, Firefox 121, Safari 17"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: "📎 Additional Context"
|
||||||
|
description: "Any other information that might be relevant"
|
||||||
|
placeholder: |
|
||||||
|
- Database type and version (e.g., PostgreSQL 15, SQLite)
|
||||||
|
- Reverse proxy/CDN in use (e.g., Nginx, Cloudflare)[reference:15]
|
||||||
|
- Any custom configuration
|
||||||
|
- Related issues or PRs
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: "✅ Submission Checklist"
|
||||||
|
description: "Please confirm the following before submitting"
|
||||||
|
options:
|
||||||
|
- label: "I have searched for existing issues (open and closed) that report the same problem"
|
||||||
|
required: true
|
||||||
|
- label: "I am using the latest stable release of Audio Splitter"
|
||||||
|
required: true
|
||||||
|
- label: "I have provided clear steps to reproduce the issue"
|
||||||
|
required: true
|
||||||
|
- label: "I have included relevant logs or error messages (with sensitive info removed)"
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
name: "Feature Request"
|
||||||
|
description: "Suggest a new feature or enhancement for this project"
|
||||||
|
title: "[FEATURE]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## ⚠️ Before You Begin
|
||||||
|
Please ensure you have:
|
||||||
|
- [ ] Searched existing issues (open and closed) to avoid duplicates
|
||||||
|
- [ ] Reviewed the [project documentation](https://git.vmn.su/max/audio_splitter/wiki) for existing functionality
|
||||||
|
- [ ] Confirmed this is a feature request, not a bug or configuration question
|
||||||
|
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: "🎯 Problem Statement"
|
||||||
|
description: "What problem does this feature solve? What can't you do today?"
|
||||||
|
placeholder: |
|
||||||
|
I'm always frustrated when...
|
||||||
|
Currently, it's hard/impossible to...
|
||||||
|
This feature would help me because...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: "💡 Proposed Solution"
|
||||||
|
description: "Describe the solution you'd like to see"
|
||||||
|
placeholder: |
|
||||||
|
A clear description of what you want to happen.
|
||||||
|
If you have a specific API, UI, or implementation in mind, describe it here.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: "🔄 Alternatives Considered"
|
||||||
|
description: "What alternative solutions or workarounds have you considered?"
|
||||||
|
placeholder: |
|
||||||
|
- Alternative A: ...
|
||||||
|
- Workaround B: ...
|
||||||
|
- Why these don't fully solve the problem
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: usecase
|
||||||
|
attributes:
|
||||||
|
label: "👤 Use Case / Persona"
|
||||||
|
description: "Who benefits from this feature and in what context?"
|
||||||
|
placeholder: |
|
||||||
|
As a [type of user], I want to [do something] so that [I achieve some benefit].
|
||||||
|
Example: "As a project maintainer, I want to bulk-close issues by label so that I can clean up stale tickets faster."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: "📸 Screenshots"
|
||||||
|
description: "If you can, provide screenshots or diagrams of the proposed feature"
|
||||||
|
placeholder: "Drag and drop images or paste links..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
---
|
||||||
|
## 🔧 Implementation Details (Optional)
|
||||||
|
The following sections are for contributors who want to implement this feature.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: technical
|
||||||
|
attributes:
|
||||||
|
label: "⚙️ Technical Approach"
|
||||||
|
description: "If you have ideas about how to implement this, share them here"
|
||||||
|
placeholder: |
|
||||||
|
- Which components/modules would be affected?
|
||||||
|
- Any database schema changes?
|
||||||
|
- API design considerations?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: acceptance
|
||||||
|
attributes:
|
||||||
|
label: "✅ Acceptance Criteria"
|
||||||
|
description: "What does 'done' look like for this feature?"
|
||||||
|
placeholder: |
|
||||||
|
- [ ] Feature works in the UI
|
||||||
|
- [ ] API endpoints are documented
|
||||||
|
- [ ] Tests are added
|
||||||
|
- [ ] Documentation is updated
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: "🤝 Are you willing to contribute this feature?"
|
||||||
|
description: "Knowing if you can help implement this helps us prioritize"
|
||||||
|
options:
|
||||||
|
- "Yes, I can contribute"
|
||||||
|
- "Yes, but I need guidance"
|
||||||
|
- "No, but I can test/review"
|
||||||
|
- "No, I'm just requesting"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: "✅ Submission Checklist"
|
||||||
|
description: "Please confirm the following before submitting"
|
||||||
|
options:
|
||||||
|
- label: "I have searched for existing issues (open and closed) that request the same feature"
|
||||||
|
required: true
|
||||||
|
- label: "I have explained the problem this feature solves"
|
||||||
|
required: true
|
||||||
|
- label: "I have described a concrete solution or direction"
|
||||||
|
required: true
|
||||||
|
- label: "I have considered alternatives and explained why they fall short"
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
name: "Refactor Request"
|
||||||
|
description: "Propose a code restructuring or cleanup with no behavior change"
|
||||||
|
title: "[REFACTOR]: "
|
||||||
|
labels: ["refactoring"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## ⚠️ Before You Begin
|
||||||
|
Please ensure you have:
|
||||||
|
- [ ] Searched existing issues (open and closed) to avoid duplicates
|
||||||
|
- [ ] Reviewed the [project documentation](https://git.vmn.su/max/audio_splitter/wiki) for architecture guidelines
|
||||||
|
- [ ] Confirmed this is a refactor (code restructuring with NO behavior change), not a bug fix or new feature
|
||||||
|
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: "🎯 Problem Statement"
|
||||||
|
description: "What pain points does the current code cause? Why is this refactor needed?"
|
||||||
|
placeholder: |
|
||||||
|
The current implementation is difficult to maintain because...
|
||||||
|
This code has accumulated technical debt due to...
|
||||||
|
I'm constantly frustrated when working with this module because...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: "📂 Scope"
|
||||||
|
description: "Which files, modules, or components need refactoring? Be specific."
|
||||||
|
placeholder: |
|
||||||
|
- File paths: `src/auth/`, `internal/handler/`
|
||||||
|
- Component: `UserService` class
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current-state
|
||||||
|
attributes:
|
||||||
|
label: "🔍 Current State"
|
||||||
|
description: "Describe the current code structure, its issues, and why it's problematic"
|
||||||
|
placeholder: |
|
||||||
|
- The function `processUser()` is 500+ lines long
|
||||||
|
- Duplicated logic across 3 different files
|
||||||
|
- Mixed concerns (business logic + HTTP handling)
|
||||||
|
- Poor test coverage makes changes risky
|
||||||
|
- Naming is unclear and inconsistent
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed-changes
|
||||||
|
attributes:
|
||||||
|
label: "💡 Proposed Solution"
|
||||||
|
description: "What should the code look like after the refactor?"
|
||||||
|
placeholder: |
|
||||||
|
- Extract `processUser()` into smaller, focused functions
|
||||||
|
- Consolidate duplicated logic into a shared utility
|
||||||
|
- Separate business logic from HTTP handlers
|
||||||
|
- Introduce interfaces for better testability
|
||||||
|
- Rename variables and functions for clarity
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: constraints
|
||||||
|
attributes:
|
||||||
|
label: "🚫 Constraints"
|
||||||
|
description: "What must NOT change? Public APIs, behavior, backward compatibility, performance characteristics?"
|
||||||
|
placeholder: |
|
||||||
|
- All public APIs must remain identical
|
||||||
|
- Database schema must not change
|
||||||
|
- External behavior must be identical
|
||||||
|
- Response formats must stay the same
|
||||||
|
- Performance must not degrade
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: benefits
|
||||||
|
attributes:
|
||||||
|
label: "📈 Expected Benefits"
|
||||||
|
description: "What improvements will this refactor bring?"
|
||||||
|
placeholder: |
|
||||||
|
- Improved maintainability
|
||||||
|
- Better testability
|
||||||
|
- Reduced code duplication
|
||||||
|
- Clearer separation of concerns
|
||||||
|
- Easier onboarding for new contributors
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: risks
|
||||||
|
attributes:
|
||||||
|
label: "⚠️ Risks & Mitigations"
|
||||||
|
description: "What could go wrong, and how will you mitigate it?"
|
||||||
|
placeholder: |
|
||||||
|
- Risk: Regression bugs
|
||||||
|
Mitigation: Comprehensive test coverage before and after
|
||||||
|
- Risk: Large diff making code review difficult
|
||||||
|
Mitigation: Break into smaller, incremental PRs
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: testing
|
||||||
|
attributes:
|
||||||
|
label: "🧪 Testing Strategy"
|
||||||
|
description: "How will you ensure the refactor doesn't break existing functionality?"
|
||||||
|
placeholder: |
|
||||||
|
- Existing test suite must pass
|
||||||
|
- Add regression tests before refactoring
|
||||||
|
- Run benchmarks to ensure no performance regression
|
||||||
|
- Manual testing of critical paths
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: "🔄 Alternatives Considered"
|
||||||
|
description: "What other approaches did you consider, and why did you reject them?"
|
||||||
|
placeholder: |
|
||||||
|
- Alternative A: Complete rewrite — rejected because too risky
|
||||||
|
- Alternative B: Gradual deprecation — rejected because too slow
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: "🤝 Are you willing to contribute this refactor?"
|
||||||
|
description: "Knowing if you can help implement this helps us prioritize"
|
||||||
|
options:
|
||||||
|
- "Yes, I can contribute"
|
||||||
|
- "Yes, but I need guidance"
|
||||||
|
- "No, but I can review"
|
||||||
|
- "No, I'm just suggesting"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: "✅ Submission Checklist"
|
||||||
|
description: "Please confirm the following before submitting"
|
||||||
|
options:
|
||||||
|
- label: "I have searched for existing issues (open and closed) that request the same refactor"
|
||||||
|
required: true
|
||||||
|
- label: "I have clearly explained the problem this refactor solves"
|
||||||
|
required: true
|
||||||
|
- label: "I have described the scope of the refactor (specific files/modules)"
|
||||||
|
required: true
|
||||||
|
- label: "I have identified what must NOT change (APIs, behavior, backward compatibility)"
|
||||||
|
required: true
|
||||||
|
- label: "I have described how the refactor will be tested"
|
||||||
|
required: true
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Install FFmpeg and dependencies required for gosu installation
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
ca-certificates \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
dirmngr \
|
||||||
|
gnupg-agent && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install gosu (lightweight tool for dropping privileges)
|
||||||
|
RUN set -eux; \
|
||||||
|
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||||
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch"; \
|
||||||
|
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch.asc"; \
|
||||||
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
|
gpgconf --kill all; \
|
||||||
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
chmod +x /usr/local/bin/gosu; \
|
||||||
|
gosu --version
|
||||||
|
|
||||||
|
# Set Python environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package metadata and source code
|
||||||
|
COPY setup.py pyproject.toml README.md ./
|
||||||
|
COPY audio_splitter/ ./audio_splitter/
|
||||||
|
|
||||||
|
# Install the package
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
# Create a non-root user with UID 1000
|
||||||
|
RUN addgroup --system --gid 1000 appgroup && \
|
||||||
|
adduser --system --uid 1000 --ingroup appgroup appuser
|
||||||
|
|
||||||
|
# Change ownership of /app to the container user (so it can write there if needed)
|
||||||
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|
||||||
|
# Copy the entrypoint script
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# Set the entrypoint
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
# Default command (shows help if no arguments provided)
|
||||||
|
CMD ["--help"]
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Default values for all configurable options shared between CLI, web backend, and frontend."""
|
|
||||||
|
|
||||||
# Output container format (used when user doesn't specify one; auto-detection overrides this)
|
|
||||||
DEFAULT_FORMAT = "mp3"
|
|
||||||
|
|
||||||
# Filename template
|
|
||||||
DEFAULT_OUTPUT_TEMPLATE = "%an-%tn.%ext"
|
|
||||||
|
|
||||||
# Character replacement
|
|
||||||
DEFAULT_REPLACEMENT_CHAR = "_"
|
|
||||||
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
|
|
||||||
|
|
||||||
# Tracklist parsing
|
|
||||||
DEFAULT_TRACKLIST_FORMAT = "%ts %tn - %an"
|
|
||||||
|
|
||||||
# Metadata defaults
|
|
||||||
DEFAULT_ALBUM = None
|
|
||||||
DEFAULT_COMMENT = None
|
|
||||||
DEFAULT_COMMENT_STREAM = None
|
|
||||||
DEFAULT_COMMENT_SEPARATOR = "; "
|
|
||||||
DEFAULT_NO_COMMENT = False
|
|
||||||
DEFAULT_MERGE_COMMENTS = False
|
|
||||||
|
|
||||||
# Stream handling
|
|
||||||
DEFAULT_DROP_VIDEO = False
|
|
||||||
DEFAULT_DROP_SUBS = False
|
|
||||||
|
|
||||||
# Filename/export options
|
|
||||||
DEFAULT_NUMBER_TRACKS = False
|
|
||||||
DEFAULT_REPLACE_BAD_CHARS = True
|
|
||||||
DEFAULT_SKIP_EXISTING = False
|
|
||||||
DEFAULT_DELETE_ORIGINAL = False
|
|
||||||
|
|
||||||
# Transcoding (None means copy codec)
|
|
||||||
DEFAULT_TRANSCODE_TO = None
|
|
||||||
+116
-50
@@ -1,67 +1,133 @@
|
|||||||
"""Command‑line interface and entry point."""
|
"""Command‑line interface and entry point."""
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import argparse
|
||||||
|
|
||||||
from .constants import DEFAULT_BAD_CHARS
|
from .constants import DEFAULT_BAD_CHARS
|
||||||
from .defaults import (
|
|
||||||
DEFAULT_FORMAT,
|
|
||||||
DEFAULT_OUTPUT_TEMPLATE,
|
|
||||||
DEFAULT_REPLACEMENT_CHAR,
|
|
||||||
DEFAULT_BAD_CHARS,
|
|
||||||
DEFAULT_TRACKLIST_FORMAT,
|
|
||||||
DEFAULT_ALBUM,
|
|
||||||
DEFAULT_COMMENT,
|
|
||||||
DEFAULT_COMMENT_STREAM,
|
|
||||||
DEFAULT_COMMENT_SEPARATOR,
|
|
||||||
DEFAULT_NO_COMMENT,
|
|
||||||
DEFAULT_MERGE_COMMENTS,
|
|
||||||
DEFAULT_DROP_VIDEO,
|
|
||||||
DEFAULT_DROP_SUBS,
|
|
||||||
DEFAULT_NUMBER_TRACKS,
|
|
||||||
DEFAULT_REPLACE_BAD_CHARS,
|
|
||||||
DEFAULT_SKIP_EXISTING,
|
|
||||||
DEFAULT_DELETE_ORIGINAL,
|
|
||||||
DEFAULT_TRANSCODE_TO,
|
|
||||||
)
|
|
||||||
from .core import split_audio
|
|
||||||
from .tracklist import read_tracklist, parse_format
|
from .tracklist import read_tracklist, parse_format
|
||||||
|
from .core import split_audio
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Split audio file using a tracklist.")
|
"""Parse arguments and start the splitting process."""
|
||||||
parser.add_argument('input_file', help='Input audio file')
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Split an audio file into tracks using a tracklist.",
|
||||||
|
epilog="Tracklist format: mm:ss track_name - author_name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Positional arguments.
|
||||||
|
parser.add_argument('input_file', help='Input audio/video file')
|
||||||
parser.add_argument('tracklist_file', help='Tracklist file')
|
parser.add_argument('tracklist_file', help='Tracklist file')
|
||||||
|
|
||||||
# Output options
|
# Optional arguments.
|
||||||
parser.add_argument('--format', default=DEFAULT_FORMAT, help=f"Output container format (default: {DEFAULT_FORMAT})")
|
parser.add_argument(
|
||||||
parser.add_argument('--transcode-to', default=DEFAULT_TRANSCODE_TO, help="Audio codec to transcode to (default: copy)")
|
'--output-dir', '-o',
|
||||||
parser.add_argument('--drop-video', action='store_true', default=DEFAULT_DROP_VIDEO, help="Drop video streams")
|
help='Output directory for split tracks (default: <input_basename>_splits)'
|
||||||
parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, help="Drop subtitle streams")
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--format',
|
||||||
|
help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--transcode-to',
|
||||||
|
help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--drop-video',
|
||||||
|
action='store_true',
|
||||||
|
help='Remove video streams from output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--drop-subs',
|
||||||
|
action='store_true',
|
||||||
|
help='Remove subtitle streams from output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--number-tracks',
|
||||||
|
action='store_true',
|
||||||
|
help='Prepend track number to output filenames (convenience; use %%num in template for full control)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--replace-bad-chars',
|
||||||
|
action='store_true',
|
||||||
|
help='Replace problematic characters in filenames (default: off)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--replacement-char',
|
||||||
|
default='_',
|
||||||
|
help='Character used as replacement (default: "_")'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--bad-chars',
|
||||||
|
default=DEFAULT_BAD_CHARS,
|
||||||
|
help='String of characters to replace (default includes space and single quote)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--skip-existing',
|
||||||
|
action='store_true',
|
||||||
|
help='Skip extraction if output file already exists (default: overwrite)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--tracklist-format',
|
||||||
|
default='%ts %tn - %an',
|
||||||
|
help='Format of each line in the tracklist using placeholders: '
|
||||||
|
'%%ts (timestamp), %%tn (track name), %%an (author), %%al (album), '
|
||||||
|
'%%date (date/year), %%ext (file extension). '
|
||||||
|
'Default: "%%ts %%tn - %%an"'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output-template',
|
||||||
|
default='%an-%tn.%ext',
|
||||||
|
help='Template for output filenames using placeholders: '
|
||||||
|
'%%tn (track name), %%an (author), %%al (album), '
|
||||||
|
'%%date (date/year), %%ext (file extension), %%num (track number). '
|
||||||
|
'Default: "%%an-%%tn.%%ext"'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Parse and display the tracklist without splitting any files'
|
||||||
|
)
|
||||||
|
|
||||||
# Filename options
|
# Metadata options.
|
||||||
parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, help="Prepend track numbers")
|
parser.add_argument(
|
||||||
parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, help=f"Output filename template (default: {DEFAULT_OUTPUT_TEMPLATE})")
|
'--album',
|
||||||
parser.add_argument('--replace-bad-chars', action='store_true', default=DEFAULT_REPLACE_BAD_CHARS, help="Replace bad characters")
|
help='Set album name in output metadata (overrides parsed %%al and original album)'
|
||||||
parser.add_argument('--replacement-char', default=DEFAULT_REPLACEMENT_CHAR, help=f"Replacement character (default: {DEFAULT_REPLACEMENT_CHAR})")
|
)
|
||||||
parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, help=f"Bad characters to replace (default: {DEFAULT_BAD_CHARS})")
|
parser.add_argument(
|
||||||
parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, help="Skip existing output files")
|
'--comment',
|
||||||
|
help='Explicit comment text (overrides all other comment settings)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-comment',
|
||||||
|
action='store_true',
|
||||||
|
help='Explicitly ignore any comment (no comment written)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--comment-stream',
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help='Select comment from a specific stream index (0‑based). Default: first stream with a comment.'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--merge-comments',
|
||||||
|
action='store_true',
|
||||||
|
help='Merge all comments from all streams into one (separated by --comment-separator)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--comment-separator',
|
||||||
|
default='; ',
|
||||||
|
help='Separator used when merging comments (default: "; ")'
|
||||||
|
)
|
||||||
|
|
||||||
# Metadata options
|
# NEW: Delete original after successful split.
|
||||||
parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
|
parser.add_argument(
|
||||||
parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
|
'--delete-original',
|
||||||
parser.add_argument('--no-comment', action='store_true', default=DEFAULT_NO_COMMENT, help="Ignore comment")
|
action='store_true',
|
||||||
parser.add_argument('--comment-stream', type=int, default=DEFAULT_COMMENT_STREAM, help="Comment stream index")
|
help='Delete the original input file after successful splitting (default: keep)'
|
||||||
parser.add_argument('--merge-comments', action='store_true', default=DEFAULT_MERGE_COMMENTS, help="Merge all comments")
|
)
|
||||||
parser.add_argument('--comment-separator', default=DEFAULT_COMMENT_SEPARATOR, help=f"Separator for merged comments (default: {DEFAULT_COMMENT_SEPARATOR})")
|
|
||||||
|
|
||||||
# Tracklist format
|
|
||||||
parser.add_argument('--tracklist-format', default=DEFAULT_TRACKLIST_FORMAT, help=f"Tracklist format (default: {DEFAULT_TRACKLIST_FORMAT})")
|
|
||||||
|
|
||||||
# Other
|
|
||||||
parser.add_argument('--delete-original', action='store_true', default=DEFAULT_DELETE_ORIGINAL, help="Delete original file after split")
|
|
||||||
parser.add_argument('--dry-run', action='store_true', help="Parse and display tracklist without splitting")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: web/backend/Dockerfile
|
||||||
|
volumes:
|
||||||
|
# Bind mount for source code (hot-reload for Python)
|
||||||
|
- ./web/backend:/app/backend
|
||||||
|
- ./audio_splitter:/app/audio_splitter
|
||||||
|
- ./setup.py:/app/setup.py
|
||||||
|
- ./pyproject.toml:/app/pyproject.toml
|
||||||
|
# Development data directory (overrides production)
|
||||||
|
- "./dev_data:/tmp/audio_splitter_web"
|
||||||
|
environment:
|
||||||
|
- DEBUG=1
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./web/frontend
|
||||||
|
# Use the same Dockerfile, but override CMD for development
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
volumes:
|
||||||
|
# Bind mount for source code (hot-reload for Vite)
|
||||||
|
- ./web/frontend:/app
|
||||||
|
- node_modules:/app/node_modules
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=http://backend:8000
|
||||||
|
- VITE_BACKEND_URL=http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
||||||
+5
-10
@@ -1,24 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
image: git.vmn.su/max/audio_splitter_backend:latest
|
||||||
context: .
|
|
||||||
dockerfile: web/backend/Dockerfile
|
|
||||||
#image: max/audio_splitter_backend:latest
|
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
#- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
|
# Bind mount for persistent data storage.
|
||||||
- "/tmp/test:/tmp/audio_splitter_web"
|
# Set BACKEND_DATA_DIR in .env to point to your data directory.
|
||||||
|
- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- DEBUG=${DEBUG:-0}
|
- DEBUG=${DEBUG:-0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: git.vmn.su/max/audio_splitter_frontend:latest
|
||||||
context: .
|
|
||||||
dockerfile: web/frontend/Dockerfile
|
|
||||||
#image: max/audio_splitter_frontend:latest
|
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-5173}:80"
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if we are running as root (default)
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
# If /data is a directory, change its ownership to the container user
|
||||||
|
if [ -d "/data" ]; then
|
||||||
|
echo "Setting ownership of /data to appuser:appgroup"
|
||||||
|
chown -R appuser:appgroup /data
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Drop privileges and run audio_splitter with the provided arguments
|
||||||
|
exec gosu appuser audio_splitter "$@"
|
||||||
|
else
|
||||||
|
# If not root, just run audio_splitter directly
|
||||||
|
# It's not supposed to be called
|
||||||
|
exec audio_splitter "$@"
|
||||||
|
fi
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Generate TypeScript constants from audio_splitter/defaults.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Get the directory where this script is located
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.absolute()
|
|
||||||
|
|
||||||
# The defaults.py is in the same directory as the script
|
|
||||||
DEFAULTS_PATH = SCRIPT_DIR / "defaults.py"
|
|
||||||
|
|
||||||
# The frontend source is at SCRIPT_DIR.parent (i.e., /app)
|
|
||||||
# The generated file should be at /app/src/constants/generated.ts
|
|
||||||
FRONTEND_ROOT = SCRIPT_DIR.parent
|
|
||||||
OUTPUT_PATH = FRONTEND_ROOT / "src" / "constants" / "generated.ts"
|
|
||||||
|
|
||||||
|
|
||||||
def load_defaults_module():
|
|
||||||
spec = importlib.util.spec_from_file_location("defaults", DEFAULTS_PATH)
|
|
||||||
if spec is None:
|
|
||||||
raise RuntimeError(f"Could not load spec for {DEFAULTS_PATH}")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def format_value(value):
|
|
||||||
"""Convert Python value to TypeScript literal."""
|
|
||||||
if value is None:
|
|
||||||
return "null"
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return str(value).lower()
|
|
||||||
if isinstance(value, str):
|
|
||||||
# Use json.dumps to produce a properly escaped string literal
|
|
||||||
return json.dumps(value)
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return str(value)
|
|
||||||
if isinstance(value, list):
|
|
||||||
return f"[{', '.join(format_value(v) for v in value)}]"
|
|
||||||
return repr(value)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print(f"Generating TypeScript defaults from {DEFAULTS_PATH}")
|
|
||||||
defaults = load_defaults_module()
|
|
||||||
|
|
||||||
constants = {name: value for name, value in vars(defaults).items() if name.startswith("DEFAULT_")}
|
|
||||||
|
|
||||||
if not constants:
|
|
||||||
print("No DEFAULT_* constants found.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"// ============================================================================",
|
|
||||||
"// GENERATED FILE – DO NOT EDIT MANUALLY.",
|
|
||||||
"// This file is generated from audio_splitter/defaults.py.",
|
|
||||||
"// Run `npm run generate` or `python scripts/generate_ts_defaults.py` to update.",
|
|
||||||
"// ============================================================================",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, value in sorted(constants.items()):
|
|
||||||
ts_value = format_value(value)
|
|
||||||
lines.append(f"export const {name} = {ts_value};")
|
|
||||||
|
|
||||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
OUTPUT_PATH.write_text("\n".join(lines) + "\n")
|
|
||||||
|
|
||||||
print(f"✅ Generated {OUTPUT_PATH}")
|
|
||||||
print(f" {len(constants)} constants exported.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
"""API route handlers."""
|
"""API route handlers."""
|
||||||
|
|
||||||
from . import upload, split, split_file, status, download, formats, info
|
from . import upload, split, status, download
|
||||||
|
# websocket is imported directly in main.py to avoid circular import
|
||||||
+2
-39
@@ -5,12 +5,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.services.file_manager import FileManager
|
from backend.services.file_manager import FileManager
|
||||||
from backend.services.task_manager import task_manager
|
from backend.services.task_manager import task_manager
|
||||||
|
from backend.ffmpeg import get_stream_info
|
||||||
# Import core functions
|
|
||||||
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
|
|
||||||
from audio_splitter.formats import determine_default_format
|
|
||||||
from audio_splitter.constants import FORMAT_INFO
|
|
||||||
from audio_splitter.defaults import DEFAULT_FORMAT
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["info"])
|
router = APIRouter(prefix="/api", tags=["info"])
|
||||||
|
|
||||||
@@ -18,8 +13,7 @@ router = APIRouter(prefix="/api", tags=["info"])
|
|||||||
@router.get("/info/{task_id}")
|
@router.get("/info/{task_id}")
|
||||||
async def get_task_info(task_id: str):
|
async def get_task_info(task_id: str):
|
||||||
"""
|
"""
|
||||||
Return stream information (has_audio, has_video, has_subtitle, audio_codec)
|
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
|
||||||
for the uploaded file.
|
|
||||||
"""
|
"""
|
||||||
if not task_manager.has_task(task_id):
|
if not task_manager.has_task(task_id):
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||||
@@ -39,34 +33,3 @@ async def get_task_info(task_id: str):
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/info/recommended-format/{task_id}")
|
|
||||||
async def get_recommended_format(task_id: str):
|
|
||||||
"""
|
|
||||||
Return the recommended output format (container name) for the uploaded file,
|
|
||||||
based on its container and audio codec.
|
|
||||||
"""
|
|
||||||
if not task_manager.has_task(task_id):
|
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
|
||||||
|
|
||||||
input_path = FileManager.get_input_path(task_id)
|
|
||||||
if not input_path or not input_path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Input file not found")
|
|
||||||
|
|
||||||
try:
|
|
||||||
container = get_container_format(str(input_path))
|
|
||||||
codec = get_audio_codec(str(input_path))
|
|
||||||
|
|
||||||
fmt = determine_default_format(container, codec)
|
|
||||||
|
|
||||||
# Fallback if detection fails or format is unsupported
|
|
||||||
if fmt is None:
|
|
||||||
fmt = DEFAULT_FORMAT
|
|
||||||
if fmt not in FORMAT_INFO:
|
|
||||||
fmt = "mp3" # ultimate fallback
|
|
||||||
|
|
||||||
return {"format": fmt}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to determine format: {str(e)}")
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"""Endpoint for splitting with a tracklist file upload."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks
|
|
||||||
|
|
||||||
from backend.config import settings
|
|
||||||
from backend.services.task_manager import task_manager
|
|
||||||
from backend.services.splitter import run_split_task
|
|
||||||
from backend.models.request import TracklistEntry
|
|
||||||
from backend.models.response import SplitResponse, TaskStatus
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["split"])
|
|
||||||
|
|
||||||
# Import CLI tracklist parsing functions
|
|
||||||
from audio_splitter.tracklist import parse_format, read_tracklist
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/split-file", response_model=SplitResponse)
|
|
||||||
async def split_from_file(
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
task_id: str = Form(...),
|
|
||||||
tracklist_file: UploadFile = File(...),
|
|
||||||
options: str = Form("{}"),
|
|
||||||
tracklist_format: str = Form("%ts %tn - %an"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Start a splitting task using a tracklist file.
|
|
||||||
|
|
||||||
This endpoint accepts a plain text tracklist file (like the CLI does)
|
|
||||||
and parses it using the same logic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: Task ID from upload.
|
|
||||||
tracklist_file: Tracklist file (text/plain).
|
|
||||||
options: JSON string of all CLI options.
|
|
||||||
tracklist_format: Format string for parsing (default: "%ts %tn - %an").
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SplitResponse with task_id and status.
|
|
||||||
"""
|
|
||||||
# Validate task exists
|
|
||||||
if not task_manager.has_task(task_id):
|
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
|
||||||
|
|
||||||
# Check if task is already processing
|
|
||||||
status = task_manager.get_status(task_id)
|
|
||||||
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=f"Task {task_id} is already {status['status']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate file type
|
|
||||||
if not tracklist_file.filename.endswith(('.txt', '.text')):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Tracklist file must be a text file (.txt or .text)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Read and parse the tracklist file
|
|
||||||
try:
|
|
||||||
content = await tracklist_file.read()
|
|
||||||
text = content.decode('utf-8')
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Tracklist file must be UTF-8 encoded"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not text.strip():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Tracklist file is empty"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse the tracklist using CLI logic
|
|
||||||
try:
|
|
||||||
# Parse the format string into tokens
|
|
||||||
tokens = parse_format(tracklist_format)
|
|
||||||
|
|
||||||
# Write the content to a temporary file (read_tracklist expects a file path)
|
|
||||||
temp_tracklist_path = settings.temp_dir / task_id / "tracklist.txt"
|
|
||||||
temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp_tracklist_path.write_text(text, encoding='utf-8')
|
|
||||||
|
|
||||||
# Parse the tracklist file using CLI logic
|
|
||||||
tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Failed to parse tracklist: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not tracklist_dicts:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="No valid tracks found in tracklist file"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert dicts to TracklistEntry objects (same as JSON endpoint)
|
|
||||||
try:
|
|
||||||
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid tracklist data: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse options JSON
|
|
||||||
try:
|
|
||||||
options_dict = json.loads(options)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Invalid JSON in options field"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update task status
|
|
||||||
task_manager.update_task(
|
|
||||||
task_id,
|
|
||||||
status=TaskStatus.PROCESSING,
|
|
||||||
progress=0,
|
|
||||||
message="Preparing to split..."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start background task
|
|
||||||
background_tasks.add_task(
|
|
||||||
run_split_task,
|
|
||||||
task_id,
|
|
||||||
tracklist_entries, # now TracklistEntry objects
|
|
||||||
options_dict
|
|
||||||
)
|
|
||||||
|
|
||||||
return SplitResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
status=TaskStatus.PROCESSING
|
|
||||||
)
|
|
||||||
+3
-4
@@ -5,7 +5,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.api import upload, split, split_file, status, download, websocket, formats, info
|
from backend.api import upload, split, status, download, websocket, formats, info
|
||||||
from backend.services import progress_publisher
|
from backend.services import progress_publisher
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -33,12 +33,11 @@ async def startup_event():
|
|||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(upload.router)
|
app.include_router(upload.router)
|
||||||
app.include_router(split.router)
|
app.include_router(split.router)
|
||||||
app.include_router(split_file.router) # NEW
|
|
||||||
app.include_router(status.router)
|
app.include_router(status.router)
|
||||||
app.include_router(download.router)
|
app.include_router(download.router)
|
||||||
app.include_router(websocket.router)
|
app.include_router(websocket.router)
|
||||||
app.include_router(formats.router)
|
app.include_router(formats.router) # new
|
||||||
app.include_router(info.router)
|
app.include_router(info.router) # new
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from typing import List, Dict, Any
|
||||||
from typing import Dict, List, Any
|
import time
|
||||||
|
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.services.task_manager import task_manager
|
from backend.services.task_manager import task_manager
|
||||||
@@ -13,31 +13,12 @@ from backend.services.file_manager import FileManager
|
|||||||
from backend.models.request import TracklistEntry
|
from backend.models.request import TracklistEntry
|
||||||
from backend.models.response import TaskStatus
|
from backend.models.response import TaskStatus
|
||||||
|
|
||||||
# Import central defaults
|
|
||||||
from audio_splitter.defaults import (
|
|
||||||
DEFAULT_FORMAT,
|
|
||||||
DEFAULT_OUTPUT_TEMPLATE,
|
|
||||||
DEFAULT_REPLACEMENT_CHAR,
|
|
||||||
DEFAULT_BAD_CHARS,
|
|
||||||
DEFAULT_ALBUM,
|
|
||||||
DEFAULT_COMMENT,
|
|
||||||
DEFAULT_NO_COMMENT,
|
|
||||||
DEFAULT_COMMENT_STREAM,
|
|
||||||
DEFAULT_MERGE_COMMENTS,
|
|
||||||
DEFAULT_COMMENT_SEPARATOR,
|
|
||||||
DEFAULT_DROP_VIDEO,
|
|
||||||
DEFAULT_DROP_SUBS,
|
|
||||||
DEFAULT_NUMBER_TRACKS,
|
|
||||||
DEFAULT_REPLACE_BAD_CHARS,
|
|
||||||
DEFAULT_SKIP_EXISTING,
|
|
||||||
DEFAULT_DELETE_ORIGINAL,
|
|
||||||
DEFAULT_TRANSCODE_TO,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
|
def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
|
||||||
try:
|
try:
|
||||||
task_manager.update_task_with_progress(task_id, progress=5, message="Initializing...")
|
task_manager.update_task_with_progress(
|
||||||
|
task_id, progress=5, message="Initializing..."
|
||||||
|
)
|
||||||
|
|
||||||
input_path = FileManager.get_input_path(task_id)
|
input_path = FileManager.get_input_path(task_id)
|
||||||
if not input_path or not input_path.exists():
|
if not input_path or not input_path.exists():
|
||||||
@@ -45,7 +26,6 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
|||||||
|
|
||||||
output_dir = FileManager.ensure_output_dir(task_id)
|
output_dir = FileManager.ensure_output_dir(task_id)
|
||||||
|
|
||||||
# Convert tracklist to dicts (CLI format)
|
|
||||||
tracks = []
|
tracks = []
|
||||||
for entry in tracklist:
|
for entry in tracklist:
|
||||||
track_dict = {
|
track_dict = {
|
||||||
@@ -58,25 +38,26 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
|||||||
}
|
}
|
||||||
tracks.append(track_dict)
|
tracks.append(track_dict)
|
||||||
|
|
||||||
# Build args namespace using defaults where options not provided
|
from types import SimpleNamespace
|
||||||
|
|
||||||
args = SimpleNamespace(
|
args = SimpleNamespace(
|
||||||
format=options.get("format", DEFAULT_FORMAT),
|
format=options.get("format", "mp3"),
|
||||||
transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO),
|
transcode_to=options.get("transcode_to", None),
|
||||||
drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO),
|
drop_video=options.get("drop_video", False),
|
||||||
drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS),
|
drop_subs=options.get("drop_subs", False),
|
||||||
number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS),
|
number_tracks=options.get("number_tracks", False),
|
||||||
replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS),
|
replace_bad_chars=options.get("replace_bad_chars", False),
|
||||||
replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR),
|
replacement_char=options.get("replacement_char", "_"),
|
||||||
bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS),
|
bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
|
||||||
skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING),
|
skip_existing=options.get("skip_existing", False),
|
||||||
output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE),
|
output_template=options.get("output_template", "%an-%tn.%ext"),
|
||||||
album=options.get("album", DEFAULT_ALBUM),
|
album=options.get("album", None),
|
||||||
comment=options.get("comment", DEFAULT_COMMENT),
|
comment=options.get("comment", None),
|
||||||
no_comment=options.get("no_comment", DEFAULT_NO_COMMENT),
|
no_comment=options.get("no_comment", False),
|
||||||
comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM),
|
comment_stream=options.get("comment_stream", None),
|
||||||
merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS),
|
merge_comments=options.get("merge_comments", False),
|
||||||
comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR),
|
comment_separator=options.get("comment_separator", "; "),
|
||||||
delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web
|
delete_original=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
@@ -85,12 +66,17 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
|||||||
|
|
||||||
from audio_splitter.core import split_audio
|
from audio_splitter.core import split_audio
|
||||||
|
|
||||||
task_manager.update_task_with_progress(task_id, progress=10, message="Starting split...")
|
task_manager.update_task_with_progress(
|
||||||
|
task_id, progress=10, message="Starting split..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the split
|
||||||
split_audio(str(input_path), str(output_dir), tracks, args)
|
split_audio(str(input_path), str(output_dir), tracks, args)
|
||||||
|
|
||||||
|
# Get output files
|
||||||
output_files = FileManager.get_output_files(task_id)
|
output_files = FileManager.get_output_files(task_id)
|
||||||
|
|
||||||
|
# Final status update
|
||||||
task_manager.update_task_with_progress(
|
task_manager.update_task_with_progress(
|
||||||
task_id,
|
task_id,
|
||||||
progress=100,
|
progress=100,
|
||||||
@@ -98,11 +84,13 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
|||||||
status=TaskStatus.DONE
|
status=TaskStatus.DONE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add tracks to the task state
|
||||||
task_manager.update_task(
|
task_manager.update_task(
|
||||||
task_id,
|
task_id,
|
||||||
tracks=output_files
|
tracks=output_files
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Give WebSocket time to send the final message
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+12
-27
@@ -1,42 +1,27 @@
|
|||||||
# web/frontend/Dockerfile
|
# Stage 1: Build
|
||||||
# Build context must be the project root.
|
|
||||||
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# Install Python for the generation script
|
|
||||||
RUN apk add --no-cache python3 py3-pip
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy frontend package files and install dependencies
|
# Install dependencies
|
||||||
COPY web/frontend/package.json web/frontend/package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Copy the frontend source code
|
# Copy the application code and build
|
||||||
COPY web/frontend/ .
|
COPY . .
|
||||||
|
|
||||||
# Copy the Python defaults and the generation script into the expected location
|
|
||||||
# The generate script in package.json expects ../../scripts/generate_ts_defaults.py
|
|
||||||
# So we must place it at /app/../../scripts/ which is /scripts/
|
|
||||||
# But we can't COPY to a parent directory. Instead, we'll copy to /app/scripts/
|
|
||||||
# and adjust the package.json script to use ./scripts/generate_ts_defaults.py
|
|
||||||
# Actually, the simplest fix is to copy to /app/scripts/ and then adjust package.json.
|
|
||||||
#
|
|
||||||
# Let's use a different approach: copy to /app/scripts/ and update the generate script.
|
|
||||||
COPY audio_splitter/defaults.py /app/scripts/defaults.py
|
|
||||||
COPY scripts/generate_ts_defaults.py /app/scripts/generate_ts_defaults.py
|
|
||||||
|
|
||||||
# Run the generation script
|
|
||||||
RUN python /app/scripts/generate_ts_defaults.py
|
|
||||||
|
|
||||||
# Build the frontend
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Production (nginx)
|
# Stage 2: Production (nginx)
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built assets from builder
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY web/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose the port
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "python scripts/generate_ts_defaults.py",
|
|
||||||
"predev": "npm run generate",
|
|
||||||
"prebuild": "npm run generate",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
|||||||
@@ -63,8 +63,3 @@ export const getTaskInfo = async (task_id: string): Promise<{
|
|||||||
const response = await api.get(`/info/${task_id}`)
|
const response = await api.get(`/info/${task_id}`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRecommendedFormat = async (task_id: string): Promise<{ format: string }> => {
|
|
||||||
const response = await api.get(`/info/recommended-format/${task_id}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -115,37 +115,32 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
<Section title="Output Settings" defaultExpanded>
|
<Section title="Output Settings" defaultExpanded>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Container"
|
label="Format"
|
||||||
select
|
select
|
||||||
value={options.format}
|
value={options.format}
|
||||||
onChange={handleFormatChange}
|
onChange={handleFormatChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
error={!!formatError}
|
error={!!formatError}
|
||||||
helperText={
|
|
||||||
formatError || "Determines the output file extension and container structure."
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="mp3">MP3 (.mp3)</MenuItem>
|
<MenuItem value="mp3">MP3</MenuItem>
|
||||||
<MenuItem value="m4a">M4A (.m4a)</MenuItem>
|
<MenuItem value="m4a">M4A</MenuItem>
|
||||||
<MenuItem value="mkv">MKV (.mkv)</MenuItem>
|
<MenuItem value="mkv">MKV</MenuItem>
|
||||||
<MenuItem value="mp4">MP4 (.mp4)</MenuItem>
|
<MenuItem value="mp4">MP4</MenuItem>
|
||||||
<MenuItem value="ogg">OGG (.ogg)</MenuItem>
|
<MenuItem value="ogg">OGG</MenuItem>
|
||||||
<MenuItem value="opus">OPUS (.opus)</MenuItem>
|
<MenuItem value="opus">OPUS</MenuItem>
|
||||||
<MenuItem value="flac">FLAC (.flac)</MenuItem>
|
<MenuItem value="flac">FLAC</MenuItem>
|
||||||
<MenuItem value="wav">WAV (.wav)</MenuItem>
|
<MenuItem value="wav">WAV</MenuItem>
|
||||||
<MenuItem value="aac">AAC (.aac)</MenuItem>
|
<MenuItem value="aac">AAC</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
{/* Transcode option – remains unchanged but clearly labeled */}
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Audio Codec (Transcode)"
|
label="Transcode to"
|
||||||
select
|
select
|
||||||
value={options.transcode_to || ''}
|
value={options.transcode_to || ''}
|
||||||
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original."
|
|
||||||
>
|
>
|
||||||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||||
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
|
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { useTracklistStore } from '../stores/tracklistStore'
|
import { useTracklistStore } from '../stores/tracklistStore'
|
||||||
@@ -10,9 +10,6 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
const { options } = useOptionsStore()
|
const { options } = useOptionsStore()
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
const lineNumbersRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const validate = (text: string) => {
|
const validate = (text: string) => {
|
||||||
const result = parseAndValidateTracklist(text, options.tracklist_format)
|
const result = parseAndValidateTracklist(text, options.tracklist_format)
|
||||||
setEntries(result.entries)
|
setEntries(result.entries)
|
||||||
@@ -26,29 +23,10 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
validate(text)
|
validate(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (lineNumbersRef.current && textAreaRef.current) {
|
|
||||||
lineNumbersRef.current.scrollTop = textAreaRef.current.scrollTop
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const textArea = textAreaRef.current
|
|
||||||
if (textArea) {
|
|
||||||
textArea.addEventListener('scroll', handleScroll)
|
|
||||||
return () => textArea.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
}, [handleScroll])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawText) {
|
|
||||||
validate(rawText)
|
|
||||||
}
|
|
||||||
}, [options.tracklist_format])
|
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
if (acceptedFiles.length === 0) return
|
if (acceptedFiles.length === 0) return
|
||||||
|
|
||||||
const file = acceptedFiles[0]
|
const file = acceptedFiles[0]
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
@@ -70,6 +48,13 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Re-validate when tracklist format changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawText) {
|
||||||
|
validate(rawText)
|
||||||
|
}
|
||||||
|
}, [options.tracklist_format])
|
||||||
|
|
||||||
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
|
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,13 +82,12 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, position: 'relative' }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
{/* Line numbers column */}
|
{/* Line numbers column */}
|
||||||
<Box
|
<Box
|
||||||
ref={lineNumbersRef}
|
|
||||||
sx={{
|
sx={{
|
||||||
minWidth: 40,
|
minWidth: 40,
|
||||||
maxWidth: 60, // Allow more space for 3-digit numbers
|
maxWidth: 40,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
@@ -111,11 +95,6 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
paddingTop: '8.5px',
|
|
||||||
paddingBottom: '8.5px',
|
|
||||||
scrollbarWidth: 'none',
|
|
||||||
'&::-webkit-scrollbar': { display: 'none' },
|
|
||||||
whiteSpace: 'nowrap', // Prevent wrapping of line numbers
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rawText.split('\n').map((_, i) => (
|
{rawText.split('\n').map((_, i) => (
|
||||||
@@ -123,7 +102,7 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Editor text area – now with horizontal scroll and no wrap */}
|
{/* Editor text area */}
|
||||||
<TextField
|
<TextField
|
||||||
multiline
|
multiline
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -133,19 +112,11 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
|
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
inputRef={textAreaRef}
|
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiInputBase-root': {
|
'& .MuiInputBase-root': {
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
overflowX: 'auto', // Enable horizontal scroll
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': {
|
|
||||||
paddingTop: '8.5px',
|
|
||||||
paddingBottom: '8.5px',
|
|
||||||
whiteSpace: 'nowrap', // Prevent wrapping
|
|
||||||
overflowX: 'auto',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
error={!isValid && errors.length > 0}
|
error={!isValid && errors.length > 0}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { useDropzone } from 'react-dropzone'
|
|||||||
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
|
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
|
||||||
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
|
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
|
||||||
import { useUploadStore } from '../stores/uploadStore'
|
import { useUploadStore } from '../stores/uploadStore'
|
||||||
import { useOptionsStore } from '../stores/optionsStore' // NEW
|
import { uploadFile, getTaskInfo } from '../api/client'
|
||||||
import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW
|
|
||||||
import { useTaskStore } from '../stores/taskStore'
|
import { useTaskStore } from '../stores/taskStore'
|
||||||
|
|
||||||
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
|
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
|
||||||
@@ -31,7 +30,6 @@ export const UploadZone: React.FC = () => {
|
|||||||
} = useUploadStore()
|
} = useUploadStore()
|
||||||
|
|
||||||
const { setTaskId: setTaskIdStore } = useTaskStore()
|
const { setTaskId: setTaskIdStore } = useTaskStore()
|
||||||
const { setOptions } = useOptionsStore() // NEW
|
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
async (acceptedFiles: File[]) => {
|
async (acceptedFiles: File[]) => {
|
||||||
@@ -67,18 +65,9 @@ export const UploadZone: React.FC = () => {
|
|||||||
setHasAudio(info.has_audio)
|
setHasAudio(info.has_audio)
|
||||||
setHasSubtitle(info.has_subtitle)
|
setHasSubtitle(info.has_subtitle)
|
||||||
setAudioCodec(info.audio_codec)
|
setAudioCodec(info.audio_codec)
|
||||||
|
|
||||||
// Fetch recommended format and update options
|
|
||||||
try {
|
|
||||||
const rec = await getRecommendedFormat(taskId)
|
|
||||||
// Update only the format; keep other options (e.g., transcode_to) as defaults
|
|
||||||
setOptions({ format: rec.format })
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to fetch recommended format, using default', err)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch stream info:', err)
|
console.error('Failed to fetch stream info:', err)
|
||||||
// Don't block upload flow; user can manually change options
|
// Don't block the upload flow if this fails; we'll just assume no video
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
||||||
@@ -89,7 +78,7 @@ export const UploadZone: React.FC = () => {
|
|||||||
setFileSize(0)
|
setFileSize(0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore, setHasVideo, setHasAudio, setHasSubtitle, setAudioCodec, setOptions]
|
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
|||||||
@@ -1,44 +1,24 @@
|
|||||||
// web/frontend/src/stores/optionsStore.ts
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { SplitOptions } from '../types'
|
import { SplitOptions } from '../types'
|
||||||
import {
|
|
||||||
DEFAULT_FORMAT,
|
|
||||||
DEFAULT_OUTPUT_TEMPLATE,
|
|
||||||
DEFAULT_REPLACEMENT_CHAR,
|
|
||||||
DEFAULT_BAD_CHARS,
|
|
||||||
DEFAULT_ALBUM,
|
|
||||||
DEFAULT_COMMENT,
|
|
||||||
DEFAULT_NO_COMMENT,
|
|
||||||
DEFAULT_COMMENT_STREAM,
|
|
||||||
DEFAULT_MERGE_COMMENTS,
|
|
||||||
DEFAULT_COMMENT_SEPARATOR,
|
|
||||||
DEFAULT_DROP_VIDEO,
|
|
||||||
DEFAULT_DROP_SUBS,
|
|
||||||
DEFAULT_NUMBER_TRACKS,
|
|
||||||
DEFAULT_REPLACE_BAD_CHARS,
|
|
||||||
DEFAULT_SKIP_EXISTING,
|
|
||||||
DEFAULT_TRANSCODE_TO,
|
|
||||||
DEFAULT_TRACKLIST_FORMAT,
|
|
||||||
} from '../constants/generated'
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: SplitOptions = {
|
const DEFAULT_OPTIONS: SplitOptions = {
|
||||||
format: DEFAULT_FORMAT,
|
format: 'mp3',
|
||||||
transcode_to: DEFAULT_TRANSCODE_TO ?? '',
|
transcode_to: '',
|
||||||
drop_video: DEFAULT_DROP_VIDEO,
|
drop_video: false,
|
||||||
drop_subs: DEFAULT_DROP_SUBS,
|
drop_subs: false,
|
||||||
number_tracks: DEFAULT_NUMBER_TRACKS,
|
number_tracks: false,
|
||||||
replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS,
|
replace_bad_chars: false,
|
||||||
replacement_char: DEFAULT_REPLACEMENT_CHAR,
|
replacement_char: '_',
|
||||||
bad_chars: DEFAULT_BAD_CHARS,
|
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ',
|
||||||
skip_existing: DEFAULT_SKIP_EXISTING,
|
skip_existing: false,
|
||||||
output_template: DEFAULT_OUTPUT_TEMPLATE,
|
output_template: '%an-%tn.%ext',
|
||||||
album: DEFAULT_ALBUM ?? '',
|
album: '',
|
||||||
comment: DEFAULT_COMMENT ?? '',
|
comment: '',
|
||||||
no_comment: DEFAULT_NO_COMMENT,
|
no_comment: false,
|
||||||
comment_stream: DEFAULT_COMMENT_STREAM,
|
comment_stream: null,
|
||||||
merge_comments: DEFAULT_MERGE_COMMENTS,
|
merge_comments: false,
|
||||||
comment_separator: DEFAULT_COMMENT_SEPARATOR,
|
comment_separator: '; ',
|
||||||
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
|
tracklist_format: '%ts %tn - %an', // NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OptionsState {
|
interface OptionsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user