Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@
app = Flask(__name__)

# Configuration
app.config['SECRET_KEY'] = 'your-secret-key' # Change in production
app.config["SECRET_KEY"] = "your-secret-key" # Change in production
basedir = os.path.abspath(os.path.dirname(__file__))
instance_dir = os.path.join(basedir, 'instance')
instance_dir = os.path.join(basedir, "instance")
os.makedirs(instance_dir, exist_ok=True)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(instance_dir, "radio_sources.db")}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config["SQLALCHEMY_DATABASE_URI"] = (
f'sqlite:///{os.path.join(instance_dir, "radio_sources.db")}'
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

# Import db from separate module
from database import db

db.init_app(app)

# Enable CSRF protection for forms
try:
from flask_wtf import CSRFProtect

csrf = CSRFProtect(app)
except Exception:
# If flask-wtf is not installed in the environment, app will still run
Expand Down Expand Up @@ -46,5 +50,5 @@

# Db is created only by pyway migrations

if __name__ == '__main__':
app.run(debug=True)
if __name__ == "__main__":
app.run(debug=True)
2 changes: 1 addition & 1 deletion database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()
db = SQLAlchemy()
1 change: 1 addition & 0 deletions migrate_db/init_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

DBPATH = Path(__file__).parent.parent / "instance" / "radio_sources.db"


def init_database():
"""Initialize a fresh database with all migrations."""
print("🗄️ Initializing RadioChWeb database...")
Expand Down
30 changes: 15 additions & 15 deletions migrate_db/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@

PYWAY_PATH = Path(__file__).parent.parent / ".venv" / "bin" / "pyway"


def run_command(cmd: list) -> bool:
"""Run a command and return success status."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, cwd=Path(__file__).parent)
result = subprocess.run(
cmd, capture_output=True, text=True, cwd=Path(__file__).parent
)
if result.returncode != 0:
print(f"❌ Command failed: {' '.join(cmd)}")
print(f"Error: {result.stderr}")
Expand Down Expand Up @@ -48,21 +51,26 @@ def run_migrations():
else:
print("⚠️ pyway run failed, falling back to direct SQL application.")
except Exception:
print("⚠️ pyway CLI not available or failed to run; falling back to direct SQL application.")
print(
"⚠️ pyway CLI not available or failed to run; falling back to direct SQL application."
)

# Fallback: apply SQL files directly
import sqlite3

db_path = Path("../instance/radio_sources.db")
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()

# Get list of migration files
migration_dir = Path("migrations")
migration_files = sorted([f for f in migration_dir.iterdir() if f.is_file() and f.name.endswith('.sql')])
migration_files = sorted(
[f for f in migration_dir.iterdir() if f.is_file() and f.name.endswith(".sql")]
)

for migration_file in migration_files:
print(f"Applying migration: {migration_file.name}")
with open(migration_file, 'r') as f:
with open(migration_file, "r") as f:
sql = f.read()
cursor.executescript(sql)

Expand All @@ -76,11 +84,7 @@ def show_migration_status():
"""Show current migration status."""
print("\n📊 Migration Status:")

cmd = [
PYWAY_PATH.as_posix(),
"--config", "pyway.yaml",
"info"
]
cmd = [PYWAY_PATH.as_posix(), "--config", "pyway.yaml", "info"]

run_command(cmd)

Expand All @@ -89,11 +93,7 @@ def validate_migrations():
"""Validate migration checksums."""
print("🔍 Validating migrations...")

cmd = [
PYWAY_PATH.as_posix(),
"--config", "pyway.yaml",
"validate"
]
cmd = [PYWAY_PATH.as_posix(), "--config", "pyway.yaml", "validate"]

if run_command(cmd):
print("✅ All migrations are valid!")
Expand All @@ -113,4 +113,4 @@ def validate_migrations():
print("Usage: python migrate.py [status|validate]")
sys.exit(1)
else:
run_migrations()
run_migrations()
70 changes: 70 additions & 0 deletions migrate_db/migrations/V6_0__add_created_by_fields.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- V6_0__add_created_by_fields.sql
-- Rebuild `proposals` and `stream_analysis` to add `created_by` columns
-- with foreign key constraints referencing `users(id)`.

PRAGMA foreign_keys = OFF;
BEGIN TRANSACTION;

-- Rebuild proposals table with created_by FK
CREATE TABLE proposals_new (
id INTEGER NOT NULL,
stream_url VARCHAR(200) NOT NULL,
name VARCHAR(200) NOT NULL,
website_url VARCHAR(200),
image_url VARCHAR(200),
stream_type_id INTEGER NOT NULL,
is_secure BOOLEAN NOT NULL DEFAULT 1,
country VARCHAR(50),
description VARCHAR(200),
created_at DATETIME,
created_by INTEGER,
PRIMARY KEY (id),
FOREIGN KEY (stream_type_id) REFERENCES stream_types(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);

-- copy existing data (created_by will be NULL for existing rows)
INSERT INTO proposals_new (id, stream_url, name, website_url, image_url, stream_type_id, is_secure, country, description, created_at)
SELECT id, stream_url, name, website_url, image_url, stream_type_id, is_secure, country, description, created_at
FROM proposals;

DROP TABLE proposals;
ALTER TABLE proposals_new RENAME TO proposals;

CREATE INDEX IF NOT EXISTS idx_proposals_url ON proposals(stream_url);
CREATE INDEX IF NOT EXISTS idx_proposals_stream_type_id ON proposals(stream_type_id);
CREATE INDEX IF NOT EXISTS idx_proposals_is_secure ON proposals(is_secure);
CREATE INDEX IF NOT EXISTS idx_proposals_created_by ON proposals(created_by);

-- Rebuild stream_analysis table with created_by FK
CREATE TABLE stream_analysis_new (
id INTEGER NOT NULL,
stream_url VARCHAR(200) NOT NULL,
stream_type_id INTEGER,
is_valid BOOLEAN NOT NULL,
is_secure BOOLEAN NOT NULL,
error_code VARCHAR(200),
detection_method VARCHAR(200),
raw_content_type TEXT NULL,
raw_ffmpeg_output TEXT NULL,
extracted_metadata TEXT NULL,
created_by INTEGER,
PRIMARY KEY (id),
FOREIGN KEY (stream_type_id) REFERENCES stream_types(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);

INSERT INTO stream_analysis_new (id, stream_url, stream_type_id, is_valid, is_secure, error_code, detection_method, raw_content_type, raw_ffmpeg_output, extracted_metadata)
SELECT id, stream_url, stream_type_id, is_valid, is_secure, error_code, detection_method, raw_content_type, raw_ffmpeg_output, extracted_metadata
FROM stream_analysis;

DROP TABLE stream_analysis;
ALTER TABLE stream_analysis_new RENAME TO stream_analysis;

CREATE INDEX IF NOT EXISTS idx_stream_analysis_stream_url ON stream_analysis(stream_url);
CREATE INDEX IF NOT EXISTS idx_stream_analysis_created_by ON stream_analysis(created_by);

COMMIT;
PRAGMA foreign_keys = ON;

-- End of migration V6_0
28 changes: 15 additions & 13 deletions model/dto/stream_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,45 +25,47 @@ class ErrorCode(str, Enum):

class StreamAnalysisRequest(BaseModel):
"""Request DTO for stream analysis (spec 003)."""

url: HttpUrl
timeout_seconds: int = 30

model_config = ConfigDict(
json_encoders={
HttpUrl: str
}
)
model_config = ConfigDict(json_encoders={HttpUrl: str})


class StreamAnalysisResult(BaseModel):
"""
Data structure returned by analysis process (persisted for page proposal.html).
This is the main return type from spec 003 analyze-and-classify process.
"""

is_valid: bool
is_secure: bool # False for HTTP, true for HTTPS
stream_url: Optional[str] = None # if loaded is the url of proposal stream
stream_type_id: Optional[int] = None # Foreign key to StreamType, null if invalid
stream_type_display_name: Optional[str] = None # Human-readable name of the stream type
stream_type_display_name: Optional[str] = (
None # Human-readable name of the stream type
)
error_code: Optional[ErrorCode] = None # Null if valid
detection_method: Optional[DetectionMethod] = None # How the stream was detected
raw_content_type: Optional[str] = None # String from curl headers
raw_ffmpeg_output: Optional[str] = None # String from ffmpeg detection
extracted_metadata: Optional[str] = None # Normalized metadata extracted from ffmpeg stderr

@field_validator('extracted_metadata')
extracted_metadata: Optional[str] = (
None # Normalized metadata extracted from ffmpeg stderr
)

@field_validator("extracted_metadata")
def _clean_extracted_metadata(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
# remove control chars except newline and tab, trim, and enforce max length
cleaned = ''.join(ch for ch in v if (ch >= ' ' or ch in '\n\t'))
cleaned = "".join(ch for ch in v if (ch >= " " or ch in "\n\t"))
cleaned = cleaned.strip()
if len(cleaned) > 4096:
cleaned = cleaned[:4096]
return cleaned

def is_success(self) -> bool:
"""Returns True if analysis was successful and stream is valid."""
return self.is_valid and self.error_code is None
model_config = ConfigDict(from_attributes=True)

model_config = ConfigDict(from_attributes=True)
9 changes: 5 additions & 4 deletions model/dto/stream_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@

class StreamTypeDTO(BaseModel):
"""DTO for StreamType entity."""

id: int
protocol: str # HTTP, HTTPS, HLS
format: str # MP3, AAC, OGG
format: str # MP3, AAC, OGG
metadata: str # Icecast, Shoutcast, None (mapped from metadata_type)
display_name: str

@property
def type_key(self) -> str:
"""Returns the type key in format: PROTOCOL-FORMAT-METADATA"""
return f"{self.protocol}-{self.format}-{self.metadata}"
model_config = ConfigDict(from_attributes=True)

model_config = ConfigDict(from_attributes=True)
26 changes: 16 additions & 10 deletions model/dto/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class SecurityStatus(str, Enum):

class ValidationResult(BaseModel):
"""Result of proposal validation."""

is_valid: bool
message: str = ""
security_status: Optional[SecurityStatus] = None
Expand All @@ -35,6 +36,7 @@ def add_warning(self, warning: str):

class ProposalUpdateRequest(BaseModel):
"""Request DTO for updating proposal details."""

name: Optional[str] = None
website_url: Optional[str] = None
country: Optional[str] = None
Expand All @@ -45,17 +47,20 @@ class ProposalUpdateRequest(BaseModel):

def has_updates(self) -> bool:
"""Check if any updates are provided."""
return any([
self.name is not None,
self.website_url is not None,
self.country is not None,
self.description is not None,
self.image is not None
])
return any(
[
self.name is not None,
self.website_url is not None,
self.country is not None,
self.description is not None,
self.image is not None,
]
)


class ProposalRequest(BaseModel):
"""Data model for a proposal."""
"""Data model for a proposal."""

id: int
stream_url: str
name: str
Expand All @@ -65,7 +70,8 @@ class ProposalRequest(BaseModel):
image_url: Optional[str] = None
stream_type_id: int
is_secure: bool

model_config = ConfigDict(from_attributes=True)

def __repr__(self):
return f"<Proposal(id={self.id}, name='{self.name}', stream_url='{self.stream_url}', is_secure={self.is_secure})>"
return f"<Proposal(id={self.id}, name='{self.name}', stream_url='{self.stream_url}', is_secure={self.is_secure})>"
20 changes: 12 additions & 8 deletions model/entity/proposal.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
from sqlalchemy import func
from database import db


class Proposal(db.Model):
__tablename__ = 'proposals'
__tablename__ = "proposals"

id = db.Column(db.Integer, primary_key=True, autoincrement=True)
stream_url = db.Column(db.String(500), nullable=False, unique=True, index=True)
name = db.Column(db.String(100), nullable=False)
website_url = db.Column(db.String(500))

# Classification data from analysis
stream_type_id = db.Column(db.Integer, db.ForeignKey("stream_types.id"), nullable=False)
is_secure = db.Column(db.Boolean, nullable=False, default=False)

# User-editable fields
country = db.Column(db.String(50))
description = db.Column(db.Text)
image_url = db.Column(db.String(500))

created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
# Relationship

# Relationship with StreamType
stream_type = db.relationship("StreamType", back_populates="proposals")


# Relationship with User
proposal_user = db.relationship("User", back_populates="proposals")

def __repr__(self):
return f"<Proposal(id={self.id}, name='{self.name}', stream_url='{self.stream_url}', is_secure={self.is_secure})>"
return f"<Proposal(id={self.id}, name='{self.name}', stream_url='{self.stream_url}', is_secure={self.is_secure})>"
Loading