From b6787872363e8301a308bb79009df6c776c0bf80 Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Sat, 11 Apr 2026 00:38:04 +0200 Subject: [PATCH] Start --- linedance-api/.env.example | 3 + linedance-api/README.md | 87 ++ linedance-api/app/__init__.py | 0 linedance-api/app/core/config.py | 11 + linedance-api/app/core/database.py | 21 + linedance-api/app/core/security.py | 53 ++ linedance-api/app/main.py | 52 ++ linedance-api/app/models/__init__.py | 180 +++++ linedance-api/app/routers/alternatives.py | 235 ++++++ linedance-api/app/routers/auth.py | 39 + linedance-api/app/routers/dances.py | 108 +++ linedance-api/app/routers/projects.py | 190 +++++ linedance-api/app/routers/songs.py | 109 +++ linedance-api/app/schemas/__init__.py | 115 +++ linedance-api/app/websocket/manager.py | 78 ++ linedance-api/local/__init__.py | 29 + linedance-api/local/file_watcher.py | 258 ++++++ linedance-api/local/local_db.py | 330 ++++++++ linedance-api/local/tag_reader.py | 280 +++++++ linedance-api/requirements.txt | 15 + linedance-app/BUILD_VEJLEDNING.md | 47 ++ linedance-app/LineDancePlayer.spec | 161 ++++ linedance-app/README.md | 57 ++ linedance-app/app_logger.py | 33 + linedance-app/build.bat | 35 + linedance-app/build_linux.sh | 30 + linedance-app/build_windows.spec | 84 ++ linedance-app/local/__init__.py | 29 + linedance-app/local/file_watcher.py | 274 +++++++ linedance-app/local/local_db.py | 688 ++++++++++++++++ linedance-app/local/tag_reader.py | 391 +++++++++ linedance-app/main.py | 33 + linedance-app/player/__init__.py | 0 linedance-app/player/player.py | 200 +++++ linedance-app/requirements.txt | 7 + linedance-app/ui/__init__.py | 0 linedance-app/ui/library_manager.py | 135 ++++ linedance-app/ui/library_panel.py | 364 +++++++++ linedance-app/ui/login_dialog.py | 139 ++++ linedance-app/ui/main_window.py | 943 ++++++++++++++++++++++ linedance-app/ui/next_up_bar.py | 59 ++ linedance-app/ui/playlist_manager.py | 324 ++++++++ linedance-app/ui/playlist_panel.py | 538 ++++++++++++ linedance-app/ui/scan_worker.py | 64 ++ linedance-app/ui/settings_dialog.py | 281 +++++++ linedance-app/ui/tag_editor.py | 345 ++++++++ linedance-app/ui/themes.py | 334 ++++++++ linedance-app/ui/vu_meter.py | 96 +++ 48 files changed, 7884 insertions(+) create mode 100644 linedance-api/.env.example create mode 100644 linedance-api/README.md create mode 100644 linedance-api/app/__init__.py create mode 100644 linedance-api/app/core/config.py create mode 100644 linedance-api/app/core/database.py create mode 100644 linedance-api/app/core/security.py create mode 100644 linedance-api/app/main.py create mode 100644 linedance-api/app/models/__init__.py create mode 100644 linedance-api/app/routers/alternatives.py create mode 100644 linedance-api/app/routers/auth.py create mode 100644 linedance-api/app/routers/dances.py create mode 100644 linedance-api/app/routers/projects.py create mode 100644 linedance-api/app/routers/songs.py create mode 100644 linedance-api/app/schemas/__init__.py create mode 100644 linedance-api/app/websocket/manager.py create mode 100644 linedance-api/local/__init__.py create mode 100644 linedance-api/local/file_watcher.py create mode 100644 linedance-api/local/local_db.py create mode 100644 linedance-api/local/tag_reader.py create mode 100644 linedance-api/requirements.txt create mode 100644 linedance-app/BUILD_VEJLEDNING.md create mode 100644 linedance-app/LineDancePlayer.spec create mode 100644 linedance-app/README.md create mode 100644 linedance-app/app_logger.py create mode 100644 linedance-app/build.bat create mode 100755 linedance-app/build_linux.sh create mode 100644 linedance-app/build_windows.spec create mode 100644 linedance-app/local/__init__.py create mode 100644 linedance-app/local/file_watcher.py create mode 100644 linedance-app/local/local_db.py create mode 100644 linedance-app/local/tag_reader.py create mode 100644 linedance-app/main.py create mode 100644 linedance-app/player/__init__.py create mode 100644 linedance-app/player/player.py create mode 100644 linedance-app/requirements.txt create mode 100644 linedance-app/ui/__init__.py create mode 100644 linedance-app/ui/library_manager.py create mode 100644 linedance-app/ui/library_panel.py create mode 100644 linedance-app/ui/login_dialog.py create mode 100644 linedance-app/ui/main_window.py create mode 100644 linedance-app/ui/next_up_bar.py create mode 100644 linedance-app/ui/playlist_manager.py create mode 100644 linedance-app/ui/playlist_panel.py create mode 100644 linedance-app/ui/scan_worker.py create mode 100644 linedance-app/ui/settings_dialog.py create mode 100644 linedance-app/ui/tag_editor.py create mode 100644 linedance-app/ui/themes.py create mode 100644 linedance-app/ui/vu_meter.py diff --git a/linedance-api/.env.example b/linedance-api/.env.example new file mode 100644 index 00000000..4bf3033c --- /dev/null +++ b/linedance-api/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=mysql+pymysql://bruger:kodeord@localhost:3306/linedance +SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng +ACCESS_TOKEN_EXPIRE_MINUTES=10080 diff --git a/linedance-api/README.md b/linedance-api/README.md new file mode 100644 index 00000000..6711f83f --- /dev/null +++ b/linedance-api/README.md @@ -0,0 +1,87 @@ +# Linedance API + +FastAPI backend med MySQL, JWT auth og WebSocket live-opdateringer. + +## Opsætning på VPS + +### 1. Klon og installer +```bash +git clone +cd linedance-api +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### 2. Konfigurer miljøvariabler +```bash +cp .env.example .env +nano .env +``` + +Udfyld disse værdier: +``` +DATABASE_URL=mysql+pymysql://BRUGER:KODEORD@localhost:3306/linedance +SECRET_KEY= +ACCESS_TOKEN_EXPIRE_MINUTES=10080 +``` + +### 3. Opret MySQL-database +```sql +CREATE DATABASE linedance CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'linedance'@'localhost' IDENTIFIED BY 'dit-kodeord'; +GRANT ALL PRIVILEGES ON linedance.* TO 'linedance'@'localhost'; +FLUSH PRIVILEGES; +``` + +### 4. Start API (udvikling) +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 5. Start API (produktion med systemd) +Opret `/etc/systemd/system/linedance.service`: +```ini +[Unit] +Description=Linedance API +After=network.target + +[Service] +User=www-data +WorkingDirectory=/var/www/linedance-api +Environment="PATH=/var/www/linedance-api/venv/bin" +ExecStart=/var/www/linedance-api/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +systemctl enable linedance +systemctl start linedance +``` + +## API-oversigt + +| Metode | Sti | Beskrivelse | +|--------|-----|-------------| +| POST | `/auth/register` | Opret bruger | +| POST | `/auth/login` | Log ind, få token | +| GET | `/projects/` | Mine projekter | +| POST | `/projects/` | Opret projekt | +| PATCH | `/projects/{id}` | Rediger projekt | +| POST | `/projects/{id}/invite` | Inviter bruger | +| GET | `/projects/invitations/pending` | Afventende invitationer | +| POST | `/projects/invitations/{id}/accept` | Accepter invitation | +| GET | `/projects/{id}/songs` | Danseliste | +| POST | `/projects/{id}/songs` | Tilføj sang | +| PATCH | `/projects/{id}/songs/{ps_id}/status` | Opdater status (playing/played/skipped) | +| GET | `/songs/` | Mine sange | +| POST | `/songs/` | Opret sang | +| POST | `/songs/{id}/dances` | Tilføj dans til sang | +| POST | `/songs/{id}/dances/{did}/alternatives` | Tilføj alternativ-dans | +| WS | `/ws/{project_id}` | Live opdateringer | + +## Interaktiv dokumentation +Åbn `http://din-server:8000/docs` i browseren. diff --git a/linedance-api/app/__init__.py b/linedance-api/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/linedance-api/app/core/config.py b/linedance-api/app/core/config.py new file mode 100644 index 00000000..d6272904 --- /dev/null +++ b/linedance-api/app/core/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_URL: str + SECRET_KEY: str + ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage + + class Config: + env_file = ".env" + +settings = Settings() diff --git a/linedance-api/app/core/database.py b/linedance-api/app/core/database.py new file mode 100644 index 00000000..7eb83441 --- /dev/null +++ b/linedance-api/app/core/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker +from app.core.config import settings + +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, # genforbinder hvis connection er død + pool_recycle=3600, # genbruger ikke forbindelser ældre end 1 time +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +class Base(DeclarativeBase): + pass + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/linedance-api/app/core/security.py b/linedance-api/app/core/security.py new file mode 100644 index 00000000..2f51eaea --- /dev/null +++ b/linedance-api/app/core/security.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta, timezone +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from app.core.config import settings +from app.core.database import get_db + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + +ALGORITHM = "HS256" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict) -> str: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def get_current_user( + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db), +): + from app.models.user import User + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Kunne ikke validere token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise credentials_exception + return user diff --git a/linedance-api/app/main.py b/linedance-api/app/main.py new file mode 100644 index 00000000..c40654fb --- /dev/null +++ b/linedance-api/app/main.py @@ -0,0 +1,52 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.database import engine, Base +from app.routers import auth, projects, songs, alternatives, dances +from app.websocket.manager import router as ws_router + +# Opret tabeller hvis de ikke findes (til udvikling — brug Alembic i produktion) +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="Linedance API", + version="0.1.0", + description="Backend for linedance-afspiller og projektstyring", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Stram til i produktion + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(projects.router) +app.include_router(songs.router) +app.include_router(alternatives.router) +app.include_router(dances.router) + + +@app.on_event("startup") +def seed_dance_levels(): + """Opret standard dans-niveauer hvis tabellen er tom.""" + from sqlalchemy.orm import Session + from app.models import DanceLevel + with Session(engine) as db: + if db.query(DanceLevel).count() == 0: + defaults = [ + DanceLevel(sort_order=1, name="Begynder", description="Passer til alle"), + DanceLevel(sort_order=2, name="Let øvet", description="Lidt erfaring kræves"), + DanceLevel(sort_order=3, name="Øvet", description="Kræver regelmæssig træning"), + DanceLevel(sort_order=4, name="Erfaren", description="For dedikerede dansere"), + DanceLevel(sort_order=5, name="Ekspert", description="Konkurrenceniveau"), + ] + db.add_all(defaults) + db.commit() +app.include_router(ws_router) + + +@app.get("/") +def root(): + return {"status": "ok", "service": "Linedance API"} diff --git a/linedance-api/app/models/__init__.py b/linedance-api/app/models/__init__.py new file mode 100644 index 00000000..964a5319 --- /dev/null +++ b/linedance-api/app/models/__init__.py @@ -0,0 +1,180 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Float, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +def new_uuid() -> str: + return str(uuid.uuid4()) + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +# ── User ────────────────────────────────────────────────────────────────────── + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + + projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner") + memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user") + songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner") + alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user") + + +# ── Song ────────────────────────────────────────────────────────────────────── + +class Song(Base): + __tablename__ = "songs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + artist: Mapped[str] = mapped_column(String(255), default="") + album: Mapped[str] = mapped_column(String(255), default="") + bpm: Mapped[int] = mapped_column(Integer, default=0) + duration_sec: Mapped[int] = mapped_column(Integer, default=0) + file_format: Mapped[str] = mapped_column(String(8), default="") + mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) # MusicBrainz ID + acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True) # AcoustID fingerprint + synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + + owner: Mapped["User"] = relationship("User", back_populates="songs") + project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song") + + +# ── Dans-entitet ────────────────────────────────────────────────────────────── + +class DanceLevel(Base): + __tablename__ = "dance_levels" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False) + name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + description: Mapped[str] = mapped_column(String(255), default="") + + +class Dance(Base): + """Dans-entitet: navn + niveau er unik kombination.""" + __tablename__ = "dances" + __table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True) + use_count: Mapped[int] = mapped_column(Integer, default=1) + source: Mapped[str] = mapped_column(String(16), default="local") # local | community + synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True) + + level: Mapped["DanceLevel|None"] = relationship("DanceLevel") + + +# ── Project / Playlist ──────────────────────────────────────────────────────── + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + description: Mapped[str] = mapped_column(Text, default="") + is_public: Mapped[bool] = mapped_column(Boolean, default=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc) + + owner: Mapped["User"] = relationship("User", back_populates="projects") + members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan") + project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="project", order_by="ProjectSong.position", cascade="all, delete-orphan") + + +class ProjectMember(Base): + __tablename__ = "project_members" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) + role: Mapped[str] = mapped_column(String(16), default="viewer") # owner | editor | viewer + status: Mapped[str] = mapped_column(String(16), default="pending") # pending | accepted + invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + + project: Mapped["Project"] = relationship("Project", back_populates="members") + user: Mapped["User"] = relationship("User", back_populates="memberships") + + +class ProjectSong(Base): + __tablename__ = "project_songs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False) + song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False) + position: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="pending") # pending|played|skipped + + project: Mapped["Project"] = relationship("Project", back_populates="project_songs") + song: Mapped["Song"] = relationship("Song", back_populates="project_songs") + + +# ── Community dans-tags ─────────────────────────────────────────────────────── + +class CommunityDance(Base): + """Fællesskabets dans-tags på sange — identificeret ved mbid eller titel+artist.""" + __tablename__ = "community_dances" + __table_args__ = (UniqueConstraint("song_mbid", "dance_id", name="uq_comm_dance"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) + song_title: Mapped[str] = mapped_column(String(255), default="") + song_artist: Mapped[str] = mapped_column(String(255), default="") + dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False) + submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + + dance: Mapped["Dance"] = relationship("Dance") + + +# ── Community alternativ-dans + rating ──────────────────────────────────────── + +class CommunityDanceAlt(Base): + """Fællesskabets alternativ-danse til en sang — uafhængigt af sangens hoveddanse.""" + __tablename__ = "community_dance_alts" + __table_args__ = ( + UniqueConstraint("song_mbid", "song_title", "song_artist", + "alt_dance_id", name="uq_comm_alt"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) + song_title: Mapped[str] = mapped_column(String(255), default="") + song_artist: Mapped[str] = mapped_column(String(255), default="") + alt_dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False) + note: Mapped[str] = mapped_column(Text, default="") + submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) + avg_rating: Mapped[float] = mapped_column(Float, default=0.0) + rating_count: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + + alt_dance: Mapped["Dance"] = relationship("Dance", foreign_keys=[alt_dance_id]) + ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="alternative", cascade="all, delete-orphan") + + +class DanceAltRating(Base): + """En brugers 1-5 stjerne rating — knyttet til sang + dans + alternativ + bruger.""" + __tablename__ = "dance_alt_ratings" + __table_args__ = ( + UniqueConstraint("alternative_id", "user_id", name="uq_rating"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) + score: Mapped[int] = mapped_column(Integer, nullable=False) # 1-5 + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + + alternative: Mapped["CommunityDanceAlt"] = relationship("CommunityDanceAlt", back_populates="ratings") + user: Mapped["User"] = relationship("User", back_populates="alt_ratings") diff --git a/linedance-api/app/routers/alternatives.py b/linedance-api/app/routers/alternatives.py new file mode 100644 index 00000000..12fedf9a --- /dev/null +++ b/linedance-api/app/routers/alternatives.py @@ -0,0 +1,235 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func +from pydantic import BaseModel +from app.core.database import get_db +from app.core.security import get_current_user +from app.models import User, SongDance, DanceAlternative, DanceAlternativeRating + +router = APIRouter(prefix="/alternatives", tags=["alternatives"]) + +# Bayesiansk minimum — alternativer med færre ratings trækkes mod gennemsnittet +BAYESIAN_MIN_VOTES = 5 + + +# ── Schemas ─────────────────────────────────────────────────────────────────── + +class AlternativeCreate(BaseModel): + song_dance_id: str # dans der foreslås alternativ TIL + alt_song_dance_id: str # den alternative dans + note: str = "" + +class AlternativeOut(BaseModel): + id: str + song_dance_id: str + alt_song_dance_id: str + alt_dance_name: str + alt_song_title: str + created_by_username: str + note: str + my_score: int | None # den indloggede brugers egen rating + avg_score: float | None # simpelt gennemsnit (til visning) + bayesian_score: float # bruges til sortering + rating_count: int + model_config = {"from_attributes": True} + +class RatingUpsert(BaseModel): + score: int # 1-5 + + +# ── Hjælpefunktion: genberegn bayesian score ───────────────────────────────── + +def _recalculate_bayesian(alternative: DanceAlternative, db: Session): + """ + Bayesiansk score: vægter gennemsnittet mod et globalt gennemsnit + når der er få ratings, så nye alternativer ikke dominerer listen. + + Formel: (n × avg + m × global_avg) / (n + m) + n = antal ratings på dette alternativ + avg = gennemsnit for dette alternativ + m = BAYESIAN_MIN_VOTES (tillid-konstant) + global_avg = gennemsnit på tværs af ALLE ratings + """ + # Beregn stats for dette alternativ + result = db.query( + func.count(DanceAlternativeRating.id), + func.avg(DanceAlternativeRating.score), + ).filter(DanceAlternativeRating.alternative_id == alternative.id).one() + + n = result[0] or 0 + avg = float(result[1]) if result[1] else 0.0 + + # Globalt gennemsnit på tværs af alle ratings + global_avg_result = db.query(func.avg(DanceAlternativeRating.score)).scalar() + global_avg = float(global_avg_result) if global_avg_result else 3.0 # 3.0 som neutral fallback + + m = BAYESIAN_MIN_VOTES + bayesian = (n * avg + m * global_avg) / (n + m) if (n + m) > 0 else global_avg + + alternative.bayesian_score = round(bayesian, 4) + db.flush() + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.post("/", status_code=201) +def create_alternative( + data: AlternativeCreate, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Opret et nyt alternativ-dans forslag. Alle registrerede brugere kan bidrage.""" + dance = db.query(SongDance).filter(SongDance.id == data.song_dance_id).first() + if not dance: + raise HTTPException(404, "Dans ikke fundet") + + alt_dance = db.query(SongDance).filter(SongDance.id == data.alt_song_dance_id).first() + if not alt_dance: + raise HTTPException(404, "Alternativ-dans ikke fundet") + + if data.song_dance_id == data.alt_song_dance_id: + raise HTTPException(400, "En dans kan ikke være sit eget alternativ") + + # Undgå dubletter fra samme bruger + existing = db.query(DanceAlternative).filter_by( + song_dance_id=data.song_dance_id, + alt_song_dance_id=data.alt_song_dance_id, + created_by=me.id, + ).first() + if existing: + raise HTTPException(400, "Du har allerede foreslået dette alternativ") + + alt = DanceAlternative( + song_dance_id=data.song_dance_id, + alt_song_dance_id=data.alt_song_dance_id, + created_by=me.id, + note=data.note, + bayesian_score=3.0, # starter på globalt neutral + ) + db.add(alt) + db.commit() + db.refresh(alt) + return {"id": alt.id, "detail": "Alternativ oprettet"} + + +@router.get("/for-dance/{song_dance_id}", response_model=list[AlternativeOut]) +def list_alternatives_for_dance( + song_dance_id: str, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """ + Hent alle alternativer til en given dans, sorteret efter bayesiansk score. + Viser din egen rating og gennemsnittet. + """ + alternatives = ( + db.query(DanceAlternative) + .filter(DanceAlternative.song_dance_id == song_dance_id) + .order_by(DanceAlternative.bayesian_score.desc()) + .all() + ) + + result = [] + for alt in alternatives: + # Din egen rating + my_rating = db.query(DanceAlternativeRating).filter_by( + alternative_id=alt.id, user_id=me.id + ).first() + + # Aggregeret stats + stats = db.query( + func.count(DanceAlternativeRating.id), + func.avg(DanceAlternativeRating.score), + ).filter(DanceAlternativeRating.alternative_id == alt.id).one() + + result.append(AlternativeOut( + id=alt.id, + song_dance_id=alt.song_dance_id, + alt_song_dance_id=alt.alt_song_dance_id, + alt_dance_name=alt.alt_song_dance.dance_name, + alt_song_title=alt.alt_song_dance.song.title, + created_by_username=alt.creator.username, + note=alt.note, + my_score=my_rating.score if my_rating else None, + avg_score=round(float(stats[1]), 1) if stats[1] else None, + bayesian_score=alt.bayesian_score, + rating_count=stats[0] or 0, + )) + + return result + + +@router.put("/{alternative_id}/rate") +def rate_alternative( + alternative_id: str, + data: RatingUpsert, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Sæt eller opdater din rating (1-5) på et alternativ.""" + if not 1 <= data.score <= 5: + raise HTTPException(400, "Score skal være mellem 1 og 5") + + alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first() + if not alt: + raise HTTPException(404, "Alternativ ikke fundet") + + # Upsert — opdater eksisterende rating eller opret ny + existing = db.query(DanceAlternativeRating).filter_by( + alternative_id=alternative_id, user_id=me.id + ).first() + + if existing: + existing.score = data.score + else: + db.add(DanceAlternativeRating( + alternative_id=alternative_id, + user_id=me.id, + score=data.score, + )) + + db.flush() + _recalculate_bayesian(alt, db) + db.commit() + + return { + "detail": "Rating gemt", + "my_score": data.score, + "bayesian_score": alt.bayesian_score, + } + + +@router.delete("/{alternative_id}/rate", status_code=204) +def remove_rating( + alternative_id: str, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Fjern din rating fra et alternativ.""" + rating = db.query(DanceAlternativeRating).filter_by( + alternative_id=alternative_id, user_id=me.id + ).first() + if not rating: + raise HTTPException(404, "Du har ikke rated dette alternativ") + + alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first() + db.delete(rating) + db.flush() + _recalculate_bayesian(alt, db) + db.commit() + + +@router.delete("/{alternative_id}", status_code=204) +def delete_alternative( + alternative_id: str, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Slet et alternativ — kun den der oprettede det.""" + alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first() + if not alt: + raise HTTPException(404, "Alternativ ikke fundet") + if alt.created_by != me.id: + raise HTTPException(403, "Du kan kun slette dine egne forslag") + db.delete(alt) + db.commit() diff --git a/linedance-api/app/routers/auth.py b/linedance-api/app/routers/auth.py new file mode 100644 index 00000000..0e16aaac --- /dev/null +++ b/linedance-api/app/routers/auth.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.security import hash_password, verify_password, create_access_token +from app.models import User +from app.schemas import UserCreate, UserOut, Token + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserOut, status_code=201) +def register(data: UserCreate, db: Session = Depends(get_db)): + if db.query(User).filter(User.username == data.username).first(): + raise HTTPException(400, "Brugernavnet er allerede i brug") + if db.query(User).filter(User.email == data.email).first(): + raise HTTPException(400, "E-mailen er allerede i brug") + + user = User( + username=data.username, + email=data.email, + password_hash=hash_password(data.password), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/login", response_model=Token) +def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = db.query(User).filter(User.username == form.username).first() + if not user or not verify_password(form.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Forkert brugernavn eller kodeord", + ) + token = create_access_token({"sub": user.id}) + return {"access_token": token} diff --git a/linedance-api/app/routers/dances.py b/linedance-api/app/routers/dances.py new file mode 100644 index 00000000..bf33512c --- /dev/null +++ b/linedance-api/app/routers/dances.py @@ -0,0 +1,108 @@ +""" +dances.py — Endpoints til dans-navne, niveauer og community alternativer. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func +from pydantic import BaseModel +from app.core.database import get_db +from app.core.security import get_current_user +from app.models import User + +router = APIRouter(prefix="/dances", tags=["dances"]) + + +# ── Schemas ─────────────────────────────────────────────────────────────────── + +class DanceLevelOut(BaseModel): + id: int + sort_order: int + name: str + description: str + model_config = {"from_attributes": True} + +class DanceNameOut(BaseModel): + name: str + use_count: int + +class DanceNameSubmit(BaseModel): + name: str + +class CommunityDanceOut(BaseModel): + id: str + song_mbid: str | None + dance_name: str + level_id: int | None + level_name: str | None + submitted_by: str + use_count: int + +class CommunityAltOut(BaseModel): + id: str + song_mbid: str | None + dance_name: str + alt_dance_name: str + level_id: int | None + level_name: str | None + note: str + bayesian_score: float + rating_count: int + my_rating: int | None + + +# ── Dans-niveauer ───────────────────────────────────────────────────────────── + +@router.get("/levels", response_model=list[DanceLevelOut]) +def get_levels(db: Session = Depends(get_db)): + """Hent alle dans-niveauer — bruges til synkronisering i appen.""" + from sqlalchemy import text + rows = db.execute(text( + "SELECT id, sort_order, name, description FROM dance_levels ORDER BY sort_order" + )).fetchall() + return [{"id": r[0], "sort_order": r[1], "name": r[2], "description": r[3]} for r in rows] + + +# ── Dans-navne ──────────────────────────────────────────────────────────────── + +@router.get("/names", response_model=list[DanceNameOut]) +def get_dance_names(prefix: str = "", limit: int = 50, db: Session = Depends(get_db)): + """Hent kendte dans-navne — bruges til autoudfyld og synkronisering.""" + from sqlalchemy import text + pattern = f"{prefix}%" + rows = db.execute(text( + "SELECT name, use_count FROM dance_names " + "WHERE name LIKE :pattern " + "ORDER BY use_count DESC, name " + "LIMIT :limit" + ), {"pattern": pattern, "limit": limit}).fetchall() + return [{"name": r[0], "use_count": r[1]} for r in rows] + + +@router.post("/names", status_code=201) +def submit_dance_name( + data: DanceNameSubmit, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Indsend et dans-navn — opretter eller tæller op.""" + from sqlalchemy import text + name = data.name.strip() + if not name: + raise HTTPException(400, "Navn må ikke være tomt") + existing = db.execute( + text("SELECT id FROM dance_names WHERE name = :name COLLATE NOCASE"), + {"name": name} + ).fetchone() + if existing: + db.execute( + text("UPDATE dance_names SET use_count = use_count + 1 WHERE id = :id"), + {"id": existing[0]} + ) + else: + db.execute( + text("INSERT INTO dance_names (name, use_count) VALUES (:name, 1)"), + {"name": name} + ) + db.commit() + return {"detail": "ok"} diff --git a/linedance-api/app/routers/projects.py b/linedance-api/app/routers/projects.py new file mode 100644 index 00000000..1d341b0e --- /dev/null +++ b/linedance-api/app/routers/projects.py @@ -0,0 +1,190 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.security import get_current_user +from app.models import User, Project, ProjectMember, ProjectSong, Song +from app.schemas import ( + ProjectCreate, ProjectUpdate, ProjectOut, + InviteMember, ProjectSongAdd, ProjectSongStatusUpdate, ProjectSongOut, +) + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +def _get_project_or_404(project_id: str, db: Session) -> Project: + p = db.query(Project).filter(Project.id == project_id).first() + if not p: + raise HTTPException(404, "Projekt ikke fundet") + return p + + +def _assert_role(project: Project, user: User, db: Session, min_role: str = "viewer"): + roles = ["viewer", "editor", "owner"] + if project.owner_id == user.id: + return # ejer har altid adgang + member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first() + if not member: + if project.is_public and min_role == "viewer": + return + raise HTTPException(403, "Du har ikke adgang til dette projekt") + if roles.index(member.role) < roles.index(min_role): + raise HTTPException(403, "Din rolle giver ikke rettighed til dette") + + +# ── CRUD ────────────────────────────────────────────────────────────────────── + +@router.get("/", response_model=list[ProjectOut]) +def list_projects(db: Session = Depends(get_db), me: User = Depends(get_current_user)): + owned = db.query(Project).filter(Project.owner_id == me.id).all() + member_ids = [m.project_id for m in db.query(ProjectMember).filter_by(user_id=me.id, status="accepted").all()] + shared = db.query(Project).filter(Project.id.in_(member_ids)).all() + return list({p.id: p for p in owned + shared}.values()) + + +@router.post("/", response_model=ProjectOut, status_code=201) +def create_project(data: ProjectCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + project = Project(owner_id=me.id, **data.model_dump()) + db.add(project) + db.commit() + db.refresh(project) + return project + + +@router.get("/{project_id}", response_model=ProjectOut) +def get_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + _assert_role(p, me, db, "viewer") + return p + + +@router.patch("/{project_id}", response_model=ProjectOut) +def update_project(project_id: str, data: ProjectUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + _assert_role(p, me, db, "editor") + for field, val in data.model_dump(exclude_none=True).items(): + setattr(p, field, val) + db.commit() + db.refresh(p) + return p + + +@router.delete("/{project_id}", status_code=204) +def delete_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + if p.owner_id != me.id: + raise HTTPException(403, "Kun ejeren kan slette projektet") + db.delete(p) + db.commit() + + +# ── Invitationer ────────────────────────────────────────────────────────────── + +@router.post("/{project_id}/invite", status_code=201) +def invite_member(project_id: str, data: InviteMember, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + if p.owner_id != me.id: + raise HTTPException(403, "Kun ejeren kan invitere") + + target = db.query(User).filter(User.username == data.username).first() + if not target: + raise HTTPException(404, f"Brugeren '{data.username}' findes ikke") + if target.id == me.id: + raise HTTPException(400, "Du kan ikke invitere dig selv") + + existing = db.query(ProjectMember).filter_by(project_id=project_id, user_id=target.id).first() + if existing: + raise HTTPException(400, "Brugeren er allerede inviteret eller medlem") + + member = ProjectMember(project_id=project_id, user_id=target.id, role=data.role, status="pending") + db.add(member) + db.commit() + return {"detail": f"{data.username} er inviteret som {data.role}"} + + +@router.get("/invitations/pending") +def get_pending_invitations(db: Session = Depends(get_db), me: User = Depends(get_current_user)): + invitations = db.query(ProjectMember).filter_by(user_id=me.id, status="pending").all() + return [ + {"invitation_id": inv.id, "project_id": inv.project_id, "role": inv.role, "invited_at": inv.invited_at} + for inv in invitations + ] + + +@router.post("/invitations/{invitation_id}/accept") +def accept_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first() + if not inv: + raise HTTPException(404, "Invitation ikke fundet") + inv.status = "accepted" + db.commit() + return {"detail": "Invitation accepteret"} + + +@router.delete("/invitations/{invitation_id}") +def decline_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first() + if not inv: + raise HTTPException(404, "Invitation ikke fundet") + db.delete(inv) + db.commit() + return {"detail": "Invitation afvist"} + + +# ── Danseliste (ProjectSongs) ───────────────────────────────────────────────── + +@router.get("/{project_id}/songs", response_model=list[ProjectSongOut]) +def list_project_songs(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + _assert_role(p, me, db, "viewer") + return p.project_songs + + +@router.post("/{project_id}/songs", response_model=ProjectSongOut, status_code=201) +def add_song_to_project(project_id: str, data: ProjectSongAdd, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + _assert_role(p, me, db, "editor") + + song = db.query(Song).filter(Song.id == data.song_id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + + position = data.position + if position is None: + last = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position.desc()).first() + position = (last.position + 1) if last else 1 + + ps = ProjectSong(project_id=project_id, song_id=data.song_id, position=position) + db.add(ps) + db.commit() + db.refresh(ps) + return ps + + +@router.patch("/{project_id}/songs/{ps_id}/status", response_model=ProjectSongOut) +def update_song_status(project_id: str, ps_id: str, data: ProjectSongStatusUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + _assert_role(p, me, db, "editor") + + ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first() + if not ps: + raise HTTPException(404, "Sang ikke fundet i projektet") + + valid = {"pending", "playing", "played", "skipped"} + if data.status not in valid: + raise HTTPException(400, f"Ugyldig status. Vælg én af: {valid}") + + ps.status = data.status + db.commit() + db.refresh(ps) + return ps + + +@router.delete("/{project_id}/songs/{ps_id}", status_code=204) +def remove_song_from_project(project_id: str, ps_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + p = _get_project_or_404(project_id, db) + _assert_role(p, me, db, "editor") + ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first() + if not ps: + raise HTTPException(404, "Sang ikke fundet i projektet") + db.delete(ps) + db.commit() diff --git a/linedance-api/app/routers/songs.py b/linedance-api/app/routers/songs.py new file mode 100644 index 00000000..3e10b145 --- /dev/null +++ b/linedance-api/app/routers/songs.py @@ -0,0 +1,109 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.security import get_current_user +from app.models import User, Song, SongDance, DanceAlternative +from app.schemas import ( + SongCreate, SongOut, + SongDanceCreate, SongDanceOut, + DanceAlternativeCreate, DanceAlternativeOut, +) + +router = APIRouter(prefix="/songs", tags=["songs"]) + + +# ── Sange ───────────────────────────────────────────────────────────────────── + +@router.get("/", response_model=list[SongOut]) +def list_songs(db: Session = Depends(get_db), me: User = Depends(get_current_user)): + return db.query(Song).filter(Song.owner_id == me.id).all() + + +@router.post("/", response_model=SongOut, status_code=201) +def create_song(data: SongCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + song = Song(owner_id=me.id, **data.model_dump()) + db.add(song) + db.commit() + db.refresh(song) + return song + + +@router.get("/{song_id}", response_model=SongOut) +def get_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + return song + + +@router.delete("/{song_id}", status_code=204) +def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + db.delete(song) + db.commit() + + +# ── Danse på en sang ────────────────────────────────────────────────────────── + +@router.post("/{song_id}/dances", response_model=SongDanceOut, status_code=201) +def add_dance(song_id: str, data: SongDanceCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + dance = SongDance(song_id=song_id, **data.model_dump()) + db.add(dance) + db.commit() + db.refresh(dance) + return dance + + +@router.delete("/{song_id}/dances/{dance_id}", status_code=204) +def remove_dance(song_id: str, dance_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first() + if not dance: + raise HTTPException(404, "Dans ikke fundet") + db.delete(dance) + db.commit() + + +# ── Alternativ-danse ────────────────────────────────────────────────────────── + +@router.post("/{song_id}/dances/{dance_id}/alternatives", response_model=DanceAlternativeOut, status_code=201) +def add_alternative(song_id: str, dance_id: str, data: DanceAlternativeCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first() + if not dance: + raise HTTPException(404, "Dans ikke fundet") + alt_dance = db.query(SongDance).filter(SongDance.id == data.alt_song_dance_id).first() + if not alt_dance: + raise HTTPException(404, "Alternativ-dans ikke fundet") + + alt = DanceAlternative(song_dance_id=dance_id, **data.model_dump()) + db.add(alt) + db.commit() + db.refresh(alt) + return alt + + +@router.get("/{song_id}/dances/{dance_id}/alternatives", response_model=list[DanceAlternativeOut]) +def list_alternatives(song_id: str, dance_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first() + if not dance: + raise HTTPException(404, "Dans ikke fundet") + return dance.alternatives + + +@router.delete("/{song_id}/dances/{dance_id}/alternatives/{alt_id}", status_code=204) +def remove_alternative(song_id: str, dance_id: str, alt_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)): + alt = db.query(DanceAlternative).filter(DanceAlternative.id == alt_id, DanceAlternative.song_dance_id == dance_id).first() + if not alt: + raise HTTPException(404, "Alternativ ikke fundet") + db.delete(alt) + db.commit() diff --git a/linedance-api/app/schemas/__init__.py b/linedance-api/app/schemas/__init__.py new file mode 100644 index 00000000..21227c3c --- /dev/null +++ b/linedance-api/app/schemas/__init__.py @@ -0,0 +1,115 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel, EmailStr + + +# ── Auth ────────────────────────────────────────────────────────────────────── + +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + +class UserOut(BaseModel): + id: str + username: str + email: str + created_at: datetime + model_config = {"from_attributes": True} + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +# ── Project ─────────────────────────────────────────────────────────────────── + +class ProjectCreate(BaseModel): + name: str + description: str = "" + is_public: bool = False + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + is_public: bool | None = None + +class ProjectOut(BaseModel): + id: str + owner_id: str + name: str + description: str + is_public: bool + updated_at: datetime + model_config = {"from_attributes": True} + +class InviteMember(BaseModel): + username: str + role: str = "viewer" # editor | viewer + + +# ── Song ────────────────────────────────────────────────────────────────────── + +class SongCreate(BaseModel): + title: str + artist: str = "" + local_path: str = "" + bpm: int = 0 + duration_sec: int = 0 + +class SongOut(BaseModel): + id: str + owner_id: str + title: str + artist: str + local_path: str + bpm: int + duration_sec: int + synced_at: datetime + dances: list[SongDanceOut] = [] + model_config = {"from_attributes": True} + + +# ── Dance ───────────────────────────────────────────────────────────────────── + +class SongDanceCreate(BaseModel): + dance_name: str + dance_order: int = 1 + +class SongDanceOut(BaseModel): + id: str + dance_name: str + dance_order: int + model_config = {"from_attributes": True} + +class DanceAlternativeCreate(BaseModel): + alt_song_dance_id: str + note: str = "" + +class DanceAlternativeOut(BaseModel): + id: str + song_dance_id: str + alt_song_dance_id: str + note: str + model_config = {"from_attributes": True} + + +# ── ProjectSong ─────────────────────────────────────────────────────────────── + +class ProjectSongAdd(BaseModel): + song_id: str + position: int | None = None # None = tilføj sidst + +class ProjectSongStatusUpdate(BaseModel): + status: str # pending | playing | played | skipped + +class ProjectSongOut(BaseModel): + id: str + song_id: str + position: int + status: str + song: SongOut + model_config = {"from_attributes": True} + + +SongOut.model_rebuild() diff --git a/linedance-api/app/websocket/manager.py b/linedance-api/app/websocket/manager.py new file mode 100644 index 00000000..42f05c3b --- /dev/null +++ b/linedance-api/app/websocket/manager.py @@ -0,0 +1,78 @@ +import json +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.models import Project, ProjectSong + +router = APIRouter(prefix="/ws", tags=["websocket"]) + + +class ConnectionManager: + def __init__(self): + # project_id -> liste af aktive forbindelser + self.rooms: dict[str, list[WebSocket]] = {} + + async def connect(self, project_id: str, ws: WebSocket): + await ws.accept() + self.rooms.setdefault(project_id, []).append(ws) + + def disconnect(self, project_id: str, ws: WebSocket): + if project_id in self.rooms: + self.rooms[project_id].discard(ws) if hasattr(self.rooms[project_id], 'discard') else None + try: + self.rooms[project_id].remove(ws) + except ValueError: + pass + + async def broadcast(self, project_id: str, message: dict): + dead = [] + for ws in self.rooms.get(project_id, []): + try: + await ws.send_text(json.dumps(message)) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(project_id, ws) + + +manager = ConnectionManager() + + +@router.websocket("/{project_id}") +async def project_live( + project_id: str, + websocket: WebSocket, + db: Session = Depends(get_db), +): + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + await websocket.close(code=4004) + return + + await manager.connect(project_id, websocket) + + # Send nuværende tilstand med det samme ved opkobling + songs = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position).all() + await websocket.send_text(json.dumps({ + "event": "state", + "project_id": project_id, + "songs": [ + {"id": ps.id, "position": ps.position, "status": ps.status, "song_id": ps.song_id} + for ps in songs + ], + })) + + try: + while True: + await websocket.receive_text() # hold forbindelsen åben + except WebSocketDisconnect: + manager.disconnect(project_id, websocket) + + +async def notify_status_change(project_id: str, project_song_id: str, new_status: str): + """Kaldes fra projects-router når en sangs status ændres.""" + await manager.broadcast(project_id, { + "event": "status_update", + "project_song_id": project_song_id, + "status": new_status, + }) diff --git a/linedance-api/local/__init__.py b/linedance-api/local/__init__.py new file mode 100644 index 00000000..4c57abf7 --- /dev/null +++ b/linedance-api/local/__init__.py @@ -0,0 +1,29 @@ +""" +local/ — Lokalt data-lag til Linedance-afspilleren. + +Moduler: + local_db.py — SQLite database (sange, afspilningslister, biblioteker) + tag_reader.py — Læser/skriver metadata fra lydfiler + file_watcher.py — Overvåger mapper og holder SQLite opdateret + +Typisk brug ved app-start: + + from local.local_db import init_db + from local.file_watcher import get_watcher + + # Initialiser database + init_db() + + # Start fil-overvågning (on_change kaldes ved ændringer — opdater GUI) + def on_file_change(event_type, path, song_id): + print(f"{event_type}: {path}") + + watcher = get_watcher(on_change=on_file_change) + watcher.start() + + # Tilføj et bibliotek (scanner automatisk + starter overvågning) + watcher.add_library("/home/carsten/Musik") + + # Ved app-luk: + watcher.stop() +""" diff --git a/linedance-api/local/file_watcher.py b/linedance-api/local/file_watcher.py new file mode 100644 index 00000000..97847e5f --- /dev/null +++ b/linedance-api/local/file_watcher.py @@ -0,0 +1,258 @@ +""" +file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret. + +Bruger watchdog til at reagere på fil-ændringer i realtid. +Kører fuld scan ved opstart for at fange ændringer lavet mens appen var lukket. +""" + +import threading +import time +import logging +from pathlib import Path +from typing import Callable + +try: + from watchdog.observers import Observer + from watchdog.events import ( + FileSystemEventHandler, + FileCreatedEvent, + FileModifiedEvent, + FileDeletedEvent, + FileMovedEvent, + ) + WATCHDOG_AVAILABLE = True +except ImportError: + WATCHDOG_AVAILABLE = False + print("Advarsel: watchdog ikke installeret — fil-overvågning deaktiveret") + +from local.tag_reader import is_supported, read_tags, get_file_modified_at +from local.local_db import ( + get_libraries, add_library, remove_library, + upsert_song, mark_song_missing, + get_all_song_paths_for_library, update_library_scan_time, +) + +logger = logging.getLogger(__name__) + + +class MusicLibraryHandler(FileSystemEventHandler): + """ + Reagerer på ændringer i et musikbibliotek. + Kører i watchdog's baggrundstråd — DB-operationer er thread-safe via WAL. + """ + + def __init__(self, library_id: int, on_change: Callable | None = None): + self.library_id = library_id + self.on_change = on_change # valgfrit callback til GUI-opdatering + self._debounce: dict[str, float] = {} + self._debounce_lock = threading.Lock() + + def _debounced(self, path: str) -> bool: + """ + Forhindrer at samme fil behandles flere gange på kort tid. + Nogle programmer gemmer filer i flere trin (temp-fil → rename). + """ + now = time.time() + with self._debounce_lock: + last = self._debounce.get(path, 0) + if now - last < 1.5: # 1.5 sekunder cooldown + return False + self._debounce[path] = now + return True + + def on_created(self, event): + if event.is_directory or not is_supported(event.src_path): + return + if self._debounced(event.src_path): + self._process_file(event.src_path) + + def on_modified(self, event): + if event.is_directory or not is_supported(event.src_path): + return + if self._debounced(event.src_path): + self._process_file(event.src_path) + + def on_deleted(self, event): + if event.is_directory or not is_supported(event.src_path): + return + logger.info(f"Fil slettet: {event.src_path}") + mark_song_missing(event.src_path) + if self.on_change: + self.on_change("deleted", event.src_path, None) + + def on_moved(self, event): + if event.is_directory: + return + # Behandl som slet + opret + if is_supported(event.src_path): + mark_song_missing(event.src_path) + if is_supported(event.dest_path): + if self._debounced(event.dest_path): + self._process_file(event.dest_path) + + def _process_file(self, path: str): + """Læs tags og gem i SQLite.""" + try: + logger.debug(f"Høster tags fra: {path}") + tags = read_tags(path) + tags["library_id"] = self.library_id + song_id = upsert_song(tags) + logger.info(f"Opdateret: {Path(path).name} ({len(tags.get('dances', []))} danse)") + if self.on_change: + self.on_change("upserted", path, song_id) + except Exception as e: + logger.error(f"Fejl ved behandling af {path}: {e}") + + +class LibraryWatcher: + """ + Styrer watchdog-observere for alle aktive musikbiblioteker. + Én instans per applikation. + """ + + def __init__(self, on_change: Callable | None = None): + self.on_change = on_change + self._observer: Observer | None = None + self._running = False + + def start(self): + """Start overvågning af alle aktive biblioteker + kør fuld scan.""" + if not WATCHDOG_AVAILABLE: + logger.warning("watchdog ikke tilgængelig — starter kun fuld scan") + self._full_scan_all() + return + + self._observer = Observer() + libraries = get_libraries(active_only=True) + + for lib in libraries: + path = Path(lib["path"]) + if not path.exists(): + logger.warning(f"Bibliotek findes ikke: {path}") + continue + + handler = MusicLibraryHandler(lib["id"], self.on_change) + self._observer.schedule(handler, str(path), recursive=True) + logger.info(f"Overvåger: {path}") + + self._observer.start() + self._running = True + + # Fuld scan i baggrundstråd så GUI ikke blokeres + threading.Thread(target=self._full_scan_all, daemon=True).start() + + def stop(self): + if self._observer and self._running: + self._observer.stop() + self._observer.join() + self._running = False + + def add_library(self, path: str) -> int: + """Tilføj et nyt bibliotek og start overvågning af det med det samme.""" + library_id = add_library(path) + + if self._observer and self._running: + handler = MusicLibraryHandler(library_id, self.on_change) + self._observer.schedule(handler, path, recursive=True) + logger.info(f"Tilføjet bibliotek: {path}") + + # Scan det nye bibliotek i baggrunden + threading.Thread( + target=self._full_scan_library, + args=(library_id, path), + daemon=True, + ).start() + + return library_id + + def remove_library(self, library_id: int): + """Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart.""" + remove_library(library_id) + # Genstart observer for at fjerne watch (watchdog understøtter ikke unschedule by id) + if self._observer and self._running: + self._observer.unschedule_all() + self._reschedule_all() + + def _reschedule_all(self): + """Genplanlæg alle aktive biblioteker på observeren.""" + for lib in get_libraries(active_only=True): + path = Path(lib["path"]) + if path.exists(): + handler = MusicLibraryHandler(lib["id"], self.on_change) + self._observer.schedule(handler, str(path), recursive=True) + + def _full_scan_all(self): + """Kør fuld scan på alle aktive biblioteker.""" + for lib in get_libraries(active_only=True): + path = Path(lib["path"]) + if path.exists(): + self._full_scan_library(lib["id"], str(path)) + + def _full_scan_library(self, library_id: int, library_path: str): + """ + Sammenligner filer på disk med SQLite og synkroniserer forskelle. + + Tre operationer: + 1. Nye filer → indsæt i SQLite + 2. Ændrede filer → opdater SQLite (baseret på fil-timestamp) + 3. Forsvundne → marker som missing i SQLite + """ + logger.info(f"Fuld scan starter: {library_path}") + base = Path(library_path) + + # Hvad SQLite kender til + known = get_all_song_paths_for_library(library_id) + + # Hvad der faktisk er på disk + found_paths = set() + processed = 0 + errors = 0 + + for file_path in base.rglob("*"): + if not file_path.is_file() or not is_supported(file_path): + continue + + path_str = str(file_path) + found_paths.add(path_str) + disk_modified = get_file_modified_at(file_path) + + # Ny fil eller ændret siden sidst + if path_str not in known or known[path_str] != disk_modified: + try: + tags = read_tags(file_path) + tags["library_id"] = library_id + upsert_song(tags) + processed += 1 + if self.on_change: + self.on_change("upserted", path_str, None) + except Exception as e: + logger.error(f"Scan-fejl for {file_path}: {e}") + errors += 1 + + # Marker forsvundne filer + missing_count = 0 + for known_path in known: + if known_path not in found_paths: + mark_song_missing(known_path) + missing_count += 1 + if self.on_change: + self.on_change("deleted", known_path, None) + + update_library_scan_time(library_id) + logger.info( + f"Scan færdig: {library_path} — " + f"{processed} opdateret, {missing_count} mangler, {errors} fejl" + ) + + +# ── Singleton til brug i appen ──────────────────────────────────────────────── + +_watcher: LibraryWatcher | None = None + + +def get_watcher(on_change: Callable | None = None) -> LibraryWatcher: + """Returnerer den globale LibraryWatcher-instans.""" + global _watcher + if _watcher is None: + _watcher = LibraryWatcher(on_change=on_change) + return _watcher diff --git a/linedance-api/local/local_db.py b/linedance-api/local/local_db.py new file mode 100644 index 00000000..55d66818 --- /dev/null +++ b/linedance-api/local/local_db.py @@ -0,0 +1,330 @@ +""" +local_db.py — Lokal SQLite database til offline brug. + +Håndterer: + - Musikbiblioteker (stier der overvåges) + - Sange høstet fra filsystemet + - Lokale afspilningslister (offline-projekter) + - Synkroniseringsstatus mod API +""" + +import sqlite3 +import threading +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +DB_PATH = Path.home() / ".linedance" / "local.db" + +_local = threading.local() + + +def _get_conn() -> sqlite3.Connection: + """Returnerer en thread-lokal forbindelse.""" + if not hasattr(_local, "conn") or _local.conn is None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang + conn.execute("PRAGMA foreign_keys=ON") + _local.conn = conn + return _local.conn + + +@contextmanager +def get_db(): + conn = _get_conn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + + +def init_db(): + """Opret alle tabeller hvis de ikke findes.""" + with get_db() as conn: + conn.executescript(""" + -- Musikbiblioteker der overvåges + CREATE TABLE IF NOT EXISTS libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + is_active INTEGER NOT NULL DEFAULT 1, + last_full_scan TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Sange høstet fra filsystemet + CREATE TABLE IF NOT EXISTS songs ( + id TEXT PRIMARY KEY, + library_id INTEGER REFERENCES libraries(id), + local_path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT '', + artist TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', + bpm INTEGER NOT NULL DEFAULT 0, + duration_sec INTEGER NOT NULL DEFAULT 0, + file_format TEXT NOT NULL DEFAULT '', + file_modified_at TEXT NOT NULL, + file_missing INTEGER NOT NULL DEFAULT 0, + api_song_id TEXT, -- NULL hvis ikke synkroniseret + last_synced_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Danse knyttet til en sang (kun MP3 kan skrive tags) + CREATE TABLE IF NOT EXISTS song_dances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, + dance_name TEXT NOT NULL, + dance_order INTEGER NOT NULL DEFAULT 1 + ); + + -- Lokale afspilningslister (offline-projekter) + CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + api_project_id TEXT, -- NULL hvis ikke synkroniseret + last_synced_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Sange i en afspilningsliste + CREATE TABLE IF NOT EXISTS playlist_songs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + song_id TEXT NOT NULL REFERENCES songs(id), + position INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending|playing|played|skipped + UNIQUE(playlist_id, position) + ); + + -- Synkroniseringskø — ændringer der venter på at komme online + CREATE TABLE IF NOT EXISTS sync_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, -- 'song'|'playlist'|'playlist_song' + entity_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'create'|'update'|'delete' + payload TEXT NOT NULL, -- JSON + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Indekser til hurtig søgning + CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title); + CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist); + CREATE INDEX IF NOT EXISTS idx_songs_missing ON songs(file_missing); + CREATE INDEX IF NOT EXISTS idx_songs_library ON songs(library_id); + CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id); + """) + + +# ── Biblioteker ─────────────────────────────────────────────────────────────── + +def add_library(path: str) -> int: + with get_db() as conn: + cur = conn.execute( + "INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,) + ) + if cur.lastrowid: + return cur.lastrowid + row = conn.execute("SELECT id FROM libraries WHERE path=?", (path,)).fetchone() + return row["id"] + + +def get_libraries(active_only: bool = True) -> list[sqlite3.Row]: + with get_db() as conn: + if active_only: + return conn.execute( + "SELECT * FROM libraries WHERE is_active=1 ORDER BY path" + ).fetchall() + return conn.execute("SELECT * FROM libraries ORDER BY path").fetchall() + + +def remove_library(library_id: int): + with get_db() as conn: + conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (library_id,)) + + +def update_library_scan_time(library_id: int): + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + conn.execute( + "UPDATE libraries SET last_full_scan=? WHERE id=?", (now, library_id) + ) + + +# ── Sange ───────────────────────────────────────────────────────────────────── + +def upsert_song(song_data: dict) -> str: + """ + Indsæt eller opdater en sang baseret på local_path. + Returnerer song_id. + """ + import uuid + with get_db() as conn: + existing = conn.execute( + "SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],) + ).fetchone() + + if existing: + song_id = existing["id"] + conn.execute(""" + UPDATE songs SET + title=?, artist=?, album=?, bpm=?, duration_sec=?, + file_format=?, file_modified_at=?, file_missing=0 + WHERE id=? + """, ( + song_data.get("title", ""), + song_data.get("artist", ""), + song_data.get("album", ""), + song_data.get("bpm", 0), + song_data.get("duration_sec", 0), + song_data.get("file_format", ""), + song_data.get("file_modified_at", ""), + song_id, + )) + else: + song_id = str(uuid.uuid4()) + conn.execute(""" + INSERT INTO songs + (id, library_id, local_path, title, artist, album, + bpm, duration_sec, file_format, file_modified_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, ( + song_id, + song_data.get("library_id"), + song_data["local_path"], + song_data.get("title", ""), + song_data.get("artist", ""), + song_data.get("album", ""), + song_data.get("bpm", 0), + song_data.get("duration_sec", 0), + song_data.get("file_format", ""), + song_data.get("file_modified_at", ""), + )) + + # Opdater danse hvis de er med i data + if "dances" in song_data: + conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,)) + for i, dance_name in enumerate(song_data["dances"], start=1): + conn.execute( + "INSERT INTO song_dances (song_id, dance_name, dance_order) VALUES (?,?,?)", + (song_id, dance_name, i), + ) + + return song_id + + +def mark_song_missing(local_path: str): + with get_db() as conn: + conn.execute( + "UPDATE songs SET file_missing=1 WHERE local_path=?", (local_path,) + ) + + +def get_song_by_path(local_path: str) -> sqlite3.Row | None: + with get_db() as conn: + return conn.execute( + "SELECT * FROM songs WHERE local_path=?", (local_path,) + ).fetchone() + + +def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]: + """Søg i titel, artist og dansenavne.""" + pattern = f"%{query}%" + with get_db() as conn: + return conn.execute(""" + SELECT DISTINCT s.* FROM songs s + LEFT JOIN song_dances sd ON sd.song_id = s.id + WHERE s.file_missing = 0 + AND (s.title LIKE ? OR s.artist LIKE ? OR s.album LIKE ? OR sd.dance_name LIKE ?) + ORDER BY s.artist, s.title + LIMIT ? + """, (pattern, pattern, pattern, pattern, limit)).fetchall() + + +def get_songs_for_library(library_id: int) -> list[sqlite3.Row]: + with get_db() as conn: + return conn.execute( + "SELECT * FROM songs WHERE library_id=? ORDER BY artist, title", + (library_id,) + ).fetchall() + + +def get_all_song_paths_for_library(library_id: int) -> dict[str, str]: + """Returnerer {local_path: file_modified_at} — bruges til fuld scan.""" + with get_db() as conn: + rows = conn.execute( + "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", + (library_id,) + ).fetchall() + return {row["local_path"]: row["file_modified_at"] for row in rows} + + +# ── Afspilningslister ───────────────────────────────────────────────────────── + +def create_playlist(name: str, description: str = "") -> int: + with get_db() as conn: + cur = conn.execute( + "INSERT INTO playlists (name, description) VALUES (?,?)", + (name, description) + ) + return cur.lastrowid + + +def get_playlists() -> list[sqlite3.Row]: + with get_db() as conn: + return conn.execute( + "SELECT * FROM playlists ORDER BY created_at DESC" + ).fetchall() + + +def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int: + with get_db() as conn: + if position is None: + row = conn.execute( + "SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?", + (playlist_id,) + ).fetchone() + position = (row["max_pos"] or 0) + 1 + + cur = conn.execute( + "INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?,?,?)", + (playlist_id, song_id, position) + ) + return cur.lastrowid + + +def update_playlist_song_status(playlist_song_id: int, status: str): + valid = {"pending", "playing", "played", "skipped"} + if status not in valid: + raise ValueError(f"Ugyldig status: {status}") + with get_db() as conn: + conn.execute( + "UPDATE playlist_songs SET status=? WHERE id=?", + (status, playlist_song_id) + ) + + +def get_playlist_with_songs(playlist_id: int) -> dict: + with get_db() as conn: + playlist = conn.execute( + "SELECT * FROM playlists WHERE id=?", (playlist_id,) + ).fetchone() + if not playlist: + return {} + + songs = conn.execute(""" + SELECT ps.id as ps_id, ps.position, ps.status, + s.*, GROUP_CONCAT(sd.dance_name ORDER BY sd.dance_order) as dances + FROM playlist_songs ps + JOIN songs s ON s.id = ps.song_id + LEFT JOIN song_dances sd ON sd.song_id = s.id + WHERE ps.playlist_id = ? + GROUP BY ps.id + ORDER BY ps.position + """, (playlist_id,)).fetchall() + + return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]} diff --git a/linedance-api/local/tag_reader.py b/linedance-api/local/tag_reader.py new file mode 100644 index 00000000..a869827c --- /dev/null +++ b/linedance-api/local/tag_reader.py @@ -0,0 +1,280 @@ +""" +tag_reader.py — Læser og skriver metadata fra lydfiler. + +Understøttede formater og danse-tag support: + MP3 — læs + skriv danse (ID3 TXXX-felter) + FLAC — læs + skriv danse (Vorbis Comments) + OGG — læs + skriv danse (Vorbis Comments) + OPUS — læs + skriv danse (Vorbis Comments) + M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE) + WAV — læs metadata, ingen danse-tag support + WMA — læs metadata, ingen danse-tag support + AIFF — læs metadata, ingen danse-tag support + +Danse gemmes ALTID i SQLite uanset format. +Fil-skrivning er kun muligt for de formater der understøtter custom tags. +""" + +import os +from datetime import datetime, timezone +from pathlib import Path + +try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TXXX + from mutagen.flac import FLAC + from mutagen.mp4 import MP4, MP4FreeForm + MUTAGEN_AVAILABLE = True +except ImportError: + MUTAGEN_AVAILABLE = False + print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret") + + +# Filtyper vi høster metadata fra +SUPPORTED_EXTENSIONS = { + ".mp3", ".flac", ".wav", ".m4a", ".aac", + ".ogg", ".opus", ".wma", ".aiff", ".aif", +} + +# Formater der understøtter skrivning af danse-tags til fil +WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"} + +# Tag-nøgler brugt på tværs af formater +TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1 +VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1 +M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste) + + +def is_supported(path: str | Path) -> bool: + return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS + + +def can_write_dances(path: str | Path) -> bool: + """Returnerer True hvis formatet understøtter skrivning af danse-tags til fil.""" + return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS + + +def get_file_modified_at(path: str | Path) -> str: + ts = os.path.getmtime(str(path)) + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +# ── Læsning ─────────────────────────────────────────────────────────────────── + +def read_tags(path: str | Path) -> dict: + """ + Læser metadata og danse fra en lydfil. + Returnerer dict med: title, artist, album, bpm, duration_sec, + file_format, file_modified_at, dances, can_write_dances. + """ + path = Path(path) + result = { + "local_path": str(path), + "title": path.stem, + "artist": "", + "album": "", + "bpm": 0, + "duration_sec": 0, + "file_format": path.suffix.lower().lstrip("."), + "file_modified_at": get_file_modified_at(path), + "dances": [], + "can_write_dances": can_write_dances(path), + } + + if not MUTAGEN_AVAILABLE: + return result + + try: + audio = MutagenFile(str(path), easy=False) + if audio is None: + return result + + if hasattr(audio, "info") and audio.info: + result["duration_sec"] = int(getattr(audio.info, "length", 0)) + + ext = path.suffix.lower() + + if ext == ".mp3": + _read_mp3(audio, result) + elif ext == ".flac": + _read_vorbis(audio, result) + elif ext in (".ogg", ".opus"): + _read_vorbis(audio, result) + elif ext in (".m4a", ".aac", ".mp4"): + _read_m4a(audio, result) + else: + _read_generic(audio, result) + + except Exception as e: + print(f"Fejl ved læsning af {path}: {e}") + + return result + + +def _read_mp3(audio, result: dict): + tags = audio.tags + if not tags: + return + if "TIT2" in tags: + result["title"] = str(tags["TIT2"].text[0]) + if "TPE1" in tags: + result["artist"] = str(tags["TPE1"].text[0]) + if "TALB" in tags: + result["album"] = str(tags["TALB"].text[0]) + if "TBPM" in tags: + try: + result["bpm"] = int(float(str(tags["TBPM"].text[0]))) + except (ValueError, TypeError): + pass + dances = {} + for key, frame in tags.items(): + if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key: + try: + num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", "")) + dances[num] = str(frame.text[0]) + except (ValueError, IndexError): + pass + result["dances"] = [dances[k] for k in sorted(dances.keys())] + + +def _read_vorbis(audio, result: dict): + """FLAC og OGG/Opus bruger begge Vorbis Comments.""" + tags = audio.tags + if not tags: + return + result["title"] = tags.get("title", [result["title"]])[0] + result["artist"] = tags.get("artist", [""])[0] + result["album"] = tags.get("album", [""])[0] + try: + result["bpm"] = int(tags.get("bpm", [0])[0]) + except (ValueError, TypeError): + pass + # Danse gemmes som linedance_dance.1, linedance_dance.2 ... + dances = {} + for key, values in tags.items(): + if key.lower().startswith(f"{VORBIS_DANCE_KEY}."): + try: + num = int(key.split(".")[-1]) + dances[num] = values[0] + except (ValueError, IndexError): + pass + # Fallback: enkelt felt linedance_dance med komma-separeret liste + if not dances and VORBIS_DANCE_KEY in tags: + result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()] + return + result["dances"] = [dances[k] for k in sorted(dances.keys())] + + +def _read_m4a(audio, result: dict): + tags = audio.tags + if not tags: + return + if "\xa9nam" in tags: + result["title"] = str(tags["\xa9nam"][0]) + if "\xa9ART" in tags: + result["artist"] = str(tags["\xa9ART"][0]) + if "\xa9alb" in tags: + result["album"] = str(tags["\xa9alb"][0]) + if "tmpo" in tags: + try: + result["bpm"] = int(tags["tmpo"][0]) + except (ValueError, TypeError): + pass + # Danse gemmes som ----:LINEDANCE:DANCE — én værdi per dans + if M4A_DANCE_FREEFORM in tags: + result["dances"] = [ + v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v) + for v in tags[M4A_DANCE_FREEFORM] + ] + + +def _read_generic(audio, result: dict): + try: + easy = MutagenFile(result["local_path"], easy=True) + if easy and easy.tags: + result["title"] = easy.tags.get("title", [result["title"]])[0] + result["artist"] = easy.tags.get("artist", [""])[0] + result["album"] = easy.tags.get("album", [""])[0] + except Exception: + pass + + +# ── Skrivning ───────────────────────────────────────────────────────────────── + +def write_dances(path: str | Path, dances: list[str]) -> bool: + """ + Skriver danse til filen hvis formatet understøtter det. + Returnerer True ved succes, False hvis formatet ikke understøtter det. + Kaster Exception ved fejl under skrivning. + """ + if not MUTAGEN_AVAILABLE: + return False + + path = Path(path) + ext = path.suffix.lower() + + if ext not in WRITABLE_DANCE_FORMATS: + return False + + if ext == ".mp3": + return _write_mp3_dances(path, dances) + elif ext in (".flac", ".ogg", ".opus"): + return _write_vorbis_dances(path, dances) + elif ext in (".m4a", ".aac"): + return _write_m4a_dances(path, dances) + + return False + + +def _write_mp3_dances(path: Path, dances: list[str]) -> bool: + try: + tags = ID3(str(path)) + for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]: + del tags[key] + for i, name in enumerate(dances, start=1): + tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name)) + tags.save(str(path)) + return True + except Exception as e: + print(f"MP3 skrivefejl {path}: {e}") + return False + + +def _write_vorbis_dances(path: Path, dances: list[str]) -> bool: + try: + audio = MutagenFile(str(path), easy=False) + if audio is None or audio.tags is None: + return False + # Slet eksisterende danse-felter + keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")] + for key in keys_to_delete: + del audio.tags[key] + # Skriv nye — ét felt per dans + for i, name in enumerate(dances, start=1): + audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name + audio.save() + return True + except Exception as e: + print(f"Vorbis skrivefejl {path}: {e}") + return False + + +def _write_m4a_dances(path: Path, dances: list[str]) -> bool: + try: + audio = MP4(str(path)) + audio.tags[M4A_DANCE_FREEFORM] = [ + MP4FreeForm(name.encode("utf-8")) for name in dances + ] + audio.save() + return True + except Exception as e: + print(f"M4A skrivefejl {path}: {e}") + return False + + +# ── Hurtig læsning af kun danse (uden fuld tag-scan) ───────────────────────── + +def read_dances_from_file(path: str | Path) -> list[str]: + """Læser kun danse fra en fil — hurtigere end fuld read_tags().""" + tags = read_tags(path) + return tags.get("dances", []) diff --git a/linedance-api/requirements.txt b/linedance-api/requirements.txt new file mode 100644 index 00000000..3312fe95 --- /dev/null +++ b/linedance-api/requirements.txt @@ -0,0 +1,15 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +sqlalchemy>=2.0.0 +pymysql>=1.1.0 +alembic>=1.13.0 +passlib[bcrypt]>=1.7.4 +python-jose[cryptography]>=3.3.0 +pydantic[email]>=2.0.0 +pydantic-settings>=2.0.0 +python-dotenv>=1.0.0 +python-multipart>=0.0.9 + +# Lokalt data-lag +mutagen>=1.47.0 +watchdog>=4.0.0 diff --git a/linedance-app/BUILD_VEJLEDNING.md b/linedance-app/BUILD_VEJLEDNING.md new file mode 100644 index 00000000..e22b5a92 --- /dev/null +++ b/linedance-app/BUILD_VEJLEDNING.md @@ -0,0 +1,47 @@ +# Byg LineDance Player til Windows .exe + +## Krav + +1. **Python 3.11+** installeret +2. **VLC** installeret (skal også være på den maskine der kører .exe) +3. Alle Python-pakker installeret (`pip install -r requirements.txt`) + +## Bygge på Windows + +```cmd +cd linedance-app +build.bat +``` + +Det færdige program ligger i `dist\LineDancePlayer\LineDancePlayer.exe` + +## Bygge på Linux (til Linux) + +```bash +cd linedance-app +./build_linux.sh +``` + +## Distribuere til andre + +Kopiér hele `dist\LineDancePlayer\` mappen — IKKE kun .exe filen! +Mappen indeholder alle nødvendige DLL-filer og biblioteker. + +Modtageren skal stadig have **VLC installeret**: +- Windows: https://www.videolan.org/vlc/ +- Linux: `sudo apt install vlc` + +## Hvis VLC ikke kan findes + +PyInstaller kan ikke automatisk inkludere VLC da det er et system-program. +Alternativt kan du kopiere `libvlc.dll` og `libvlccore.dll` fra +`C:\Program Files\VideoLAN\VLC\` ind i `dist\LineDancePlayer\`-mappen. + +## Fejlsøgning + +Hvis .exe crasher uden fejlbesked, byg med `console=True` i spec-filen +og kør fra kommandoprompten for at se fejlbeskeder. + +## Størrelse + +Den færdige mappe er typisk 80-150 MB med PyQt6. diff --git a/linedance-app/LineDancePlayer.spec b/linedance-app/LineDancePlayer.spec new file mode 100644 index 00000000..ef1bc0ac --- /dev/null +++ b/linedance-app/LineDancePlayer.spec @@ -0,0 +1,161 @@ +# -*- mode: python ; coding: utf-8 -*- +# +# LineDancePlayer.spec +# +# Byg med: pyinstaller LineDancePlayer.spec +# Output: dist\LineDancePlayer.exe +# +# Kræver: VLC installeret på byggemaskinen +# (typisk C:\Program Files\VideoLAN\VLC) + +import os +import sys +from pathlib import Path + +# ── Find VLC-installation ───────────────────────────────────────────────────── + +def find_vlc_path() -> Path | None: + """Find VLC på Windows — tjekker de mest almindelige installationsstier.""" + candidates = [ + Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "VideoLAN" / "VLC", + Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "VideoLAN" / "VLC", + Path("C:/Program Files/VideoLAN/VLC"), + Path("C:/Program Files (x86)/VideoLAN/VLC"), + ] + # Tjek også PYTHONPATH og registry via python-vlc + try: + import vlc + vlc_path = Path(vlc.plugin_path).parent if vlc.plugin_path else None + if vlc_path and vlc_path.exists(): + candidates.insert(0, vlc_path) + except Exception: + pass + + for path in candidates: + if path.exists() and (path / "libvlc.dll").exists(): + return path + return None + + +VLC_PATH = find_vlc_path() +if VLC_PATH is None: + print("=" * 60) + print("ADVARSEL: VLC ikke fundet!") + print("Installer VLC fra https://www.videolan.org/vlc/") + print("og kør pyinstaller igen.") + print("=" * 60) + VLC_PATH = Path("C:/Program Files/VideoLAN/VLC") # fallback + +print(f"VLC fundet: {VLC_PATH}") + +# ── Saml VLC binære filer ───────────────────────────────────────────────────── + +vlc_binaries = [] +vlc_datas = [] + +if VLC_PATH.exists(): + # Hoved-DLL filer + for dll in ["libvlc.dll", "libvlccore.dll", "libvlc.lib"]: + dll_path = VLC_PATH / dll + if dll_path.exists(): + vlc_binaries.append((str(dll_path), ".")) + + # Plugins-mappe — indeholder codecs, demuxers osv. + plugins_dir = VLC_PATH / "plugins" + if plugins_dir.exists(): + vlc_datas.append((str(plugins_dir), "plugins")) + + # Locale-filer + locale_dir = VLC_PATH / "locale" + if locale_dir.exists(): + vlc_datas.append((str(locale_dir), "locale")) + +# ── PyInstaller konfiguration ───────────────────────────────────────────────── + +block_cipher = None + +a = Analysis( + ["main.py"], + pathex=["."], + binaries=vlc_binaries, + datas=[ + ("ui", "ui"), + ("local", "local"), + ("player", "player"), + ] + vlc_datas, + hiddenimports=[ + # PyQt6 + "PyQt6.sip", + "PyQt6.QtCore", + "PyQt6.QtGui", + "PyQt6.QtWidgets", + # Lyd og tags + "vlc", + "mutagen", + "mutagen.mp3", + "mutagen.id3", + "mutagen.flac", + "mutagen.mp4", + "mutagen.oggvorbis", + "mutagen.oggopus", + # Fil-overvågning + "watchdog", + "watchdog.observers", + "watchdog.observers.polling", + "watchdog.events", + # Database + "sqlite3", + # Standard + "json", + "pathlib", + "threading", + "urllib.request", + "urllib.parse", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # Ting vi ikke bruger — reducerer filstørrelse + "tkinter", + "matplotlib", + "numpy", + "pandas", + "scipy", + "PIL", + "cv2", + "pytest", + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="LineDancePlayer", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, # komprimer med UPX hvis tilgængeligt + upx_exclude=[ + "libvlc.dll", # VLC må ikke komprimeres — den loader plugins dynamisk + "libvlccore.dll", + ], + runtime_tmpdir=None, + console=False, # ingen konsol-vindue + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + # Ikon — kommenter ud hvis du ikke har en .ico fil endnu + # icon="assets/icon.ico", +) diff --git a/linedance-app/README.md b/linedance-app/README.md new file mode 100644 index 00000000..57877f5f --- /dev/null +++ b/linedance-app/README.md @@ -0,0 +1,57 @@ +# LineDance Player — Desktop App + +PyQt6-baseret afspiller til linedance-events. + +## Installation + +```bash +python -m venv venv +source venv/bin/activate # Linux/Mac +venv\Scripts\activate # Windows + +pip install -r requirements.txt +``` + +VLC skal også være installeret på systemet: +- **Linux**: `sudo apt install vlc` +- **Windows**: Download fra https://www.videolan.org/vlc/ +- **Mac**: `brew install vlc` + +## Start + +```bash +python main.py +``` + +## Mappestruktur + +``` +linedance-app/ +├── main.py # Entry point +├── requirements.txt +├── local/ # Lokal SQLite + fil-scanning +│ ├── local_db.py # Database operationer +│ ├── tag_reader.py # Læs/skriv MP3-tags +│ └── file_watcher.py # Overvåg mapper med watchdog +├── player/ +│ └── player.py # VLC afspiller wrapper +└── ui/ + ├── main_window.py # Hoved-vindue + ├── playlist_panel.py # Danseliste + ├── library_panel.py # Musikbibliotek med søgning + ├── next_up_bar.py # "Næste sang klar" banner + ├── vu_meter.py # VU-meter widget + └── themes.py # Lyst / mørkt tema +``` + +## Brug + +1. Klik **+ MAPPE** i biblioteks-panelet og peg på din musikmappe +2. Appen scanner automatisk alle undermapper og høster tags +3. Dobbeltklik på en sang for at afspille, eller højreklik → Tilføj til danseliste +4. Brug **▶ 10 SEK** knappen til at høre introen inden dansen starter +5. Sangen stopper automatisk når den er færdig — tryk **▶ AFSPIL NÆSTE** for at fortsætte + +## Lokal database + +Gemmes i `~/.linedance/local.db` — bevares mellem sessioner. diff --git a/linedance-app/app_logger.py b/linedance-app/app_logger.py new file mode 100644 index 00000000..a1249700 --- /dev/null +++ b/linedance-app/app_logger.py @@ -0,0 +1,33 @@ +""" +app_logger.py — Central logging til fil i stedet for konsol. +P Windows uden konsol skrives alt til ~/.linedance/app.log +""" + +import logging +import sys +from pathlib import Path + +LOG_PATH = Path.home() / ".linedance" / "app.log" + + +def setup_logging(): + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")] + # Kun tilføj konsol-handler hvis vi kører med konsol (development) + if sys.stdout and hasattr(sys.stdout, 'write'): + try: + sys.stdout.write("") # test om konsol virker + handlers.append(logging.StreamHandler(sys.stdout)) + except Exception: + pass + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + handlers=handlers, + force=True, + ) + + +logger = logging.getLogger("linedance") diff --git a/linedance-app/build.bat b/linedance-app/build.bat new file mode 100644 index 00000000..3a5a5585 --- /dev/null +++ b/linedance-app/build.bat @@ -0,0 +1,35 @@ +@echo off +echo === LineDance Player - Windows Build === +echo. + +if exist "venv\Scripts\activate.bat" ( + call venv\Scripts\activate.bat +) else ( + echo ADVARSEL: venv ikke fundet +) + +pip install pyinstaller >nul 2>&1 + +if exist "dist\LineDancePlayer" rmdir /s /q "dist\LineDancePlayer" +if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer" + +echo Bygger... (1-3 minutter) +echo. + +pyinstaller build_windows.spec --clean --noconfirm + +if errorlevel 1 ( + echo. + echo FEJL: Se fejlbesked ovenfor + pause + exit /b 1 +) + +echo. +echo === FAERDIG === +echo Program: dist\LineDancePlayer\LineDancePlayer.exe +echo. +echo HUSK: Kopieer hele dist\LineDancePlayer\ mappen - ikke kun .exe! +echo HUSK: VLC skal vaere installeret paa maskinen. +echo. +pause diff --git a/linedance-app/build_linux.sh b/linedance-app/build_linux.sh new file mode 100755 index 00000000..1df1469b --- /dev/null +++ b/linedance-app/build_linux.sh @@ -0,0 +1,30 @@ +#!/bin/bash +echo "=== LineDance Player - Linux Build ===" +echo + +# Aktiver venv +source venv/bin/activate 2>/dev/null || echo "ADVARSEL: venv ikke aktiveret" + +# Installer PyInstaller +pip show pyinstaller > /dev/null 2>&1 || pip install pyinstaller + +# Ryd tidligere build +rm -rf dist/LineDancePlayer build/LineDancePlayer + +echo "Bygger LineDance Player..." +echo "Dette tager 1-3 minutter..." +echo + +pyinstaller build_windows.spec --clean + +if [ $? -eq 0 ]; then + echo + echo "=== BUILD FÆRDIG ===" + echo "Programmet ligger i: dist/LineDancePlayer/LineDancePlayer" + echo + echo "HUSK: VLC skal stadig være installeret på maskinen!" + echo " sudo apt install vlc" +else + echo "FEJL: Build mislykkedes!" + exit 1 +fi diff --git a/linedance-app/build_windows.spec b/linedance-app/build_windows.spec new file mode 100644 index 00000000..e56deb62 --- /dev/null +++ b/linedance-app/build_windows.spec @@ -0,0 +1,84 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all, collect_submodules + +block_cipher = None + +# Saml ALT fra PyQt6 inkl. plugins og DLL-filer +pyqt6_datas, pyqt6_binaries, pyqt6_hiddenimports = collect_all('PyQt6') + +a = Analysis( + ['main.py'], + pathex=['.'], + binaries=pyqt6_binaries, + datas=pyqt6_datas, + hiddenimports=pyqt6_hiddenimports + [ + 'PyQt6.sip', + 'PyQt6.QtCore', + 'PyQt6.QtGui', + 'PyQt6.QtWidgets', + # UI moduler + 'ui.main_window', + 'ui.playlist_panel', + 'ui.library_panel', + 'ui.library_manager', + 'ui.themes', + 'ui.vu_meter', + 'ui.scan_worker', + 'ui.tag_editor', + 'ui.login_dialog', + 'ui.settings_dialog', + 'ui.playlist_manager', + 'ui.next_up_bar', + # Player + local + 'player.player', + 'local.local_db', + 'local.tag_reader', + 'local.file_watcher', + # Biblioteker + 'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac', + 'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg', + 'mutagen.wave', 'mutagen.aiff', 'mutagen.asf', + 'watchdog', 'watchdog.observers', 'watchdog.events', + 'watchdog.observers.winapi', + 'vlc', 'sqlite3', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['tkinter', 'matplotlib', 'pandas', 'scipy', 'IPython'], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='LineDancePlayer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, # UPX kan give problemer med PyQt6 DLL-filer + console=False, # Ingen konsol-vindue + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='LineDancePlayer', +) diff --git a/linedance-app/local/__init__.py b/linedance-app/local/__init__.py new file mode 100644 index 00000000..4c57abf7 --- /dev/null +++ b/linedance-app/local/__init__.py @@ -0,0 +1,29 @@ +""" +local/ — Lokalt data-lag til Linedance-afspilleren. + +Moduler: + local_db.py — SQLite database (sange, afspilningslister, biblioteker) + tag_reader.py — Læser/skriver metadata fra lydfiler + file_watcher.py — Overvåger mapper og holder SQLite opdateret + +Typisk brug ved app-start: + + from local.local_db import init_db + from local.file_watcher import get_watcher + + # Initialiser database + init_db() + + # Start fil-overvågning (on_change kaldes ved ændringer — opdater GUI) + def on_file_change(event_type, path, song_id): + print(f"{event_type}: {path}") + + watcher = get_watcher(on_change=on_file_change) + watcher.start() + + # Tilføj et bibliotek (scanner automatisk + starter overvågning) + watcher.add_library("/home/carsten/Musik") + + # Ved app-luk: + watcher.stop() +""" diff --git a/linedance-app/local/file_watcher.py b/linedance-app/local/file_watcher.py new file mode 100644 index 00000000..db739ae2 --- /dev/null +++ b/linedance-app/local/file_watcher.py @@ -0,0 +1,274 @@ +""" +file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret. + +Bruger watchdog til at reagere på fil-ændringer i realtid. +Kører fuld scan ved opstart for at fange ændringer lavet mens appen var lukket. +""" + +import threading +import time +import logging +from pathlib import Path +from typing import Callable + +try: + from watchdog.observers import Observer + from watchdog.events import ( + FileSystemEventHandler, + FileCreatedEvent, + FileModifiedEvent, + FileDeletedEvent, + FileMovedEvent, + ) + WATCHDOG_AVAILABLE = True +except ImportError: + WATCHDOG_AVAILABLE = False + print("Advarsel: watchdog ikke installeret — fil-overvågning deaktiveret") + +from local.tag_reader import is_supported, read_tags, get_file_modified_at +from local.local_db import ( + get_libraries, add_library, remove_library, + upsert_song, mark_song_missing, + get_all_song_paths_for_library, update_library_scan_time, +) + +logger = logging.getLogger(__name__) + + +class MusicLibraryHandler(FileSystemEventHandler): + """ + Reagerer på ændringer i et musikbibliotek. + Kører i watchdog's baggrundstråd — DB-operationer er thread-safe via WAL. + """ + + def __init__(self, library_id: int, on_change: Callable | None = None): + self.library_id = library_id + self.on_change = on_change # valgfrit callback til GUI-opdatering + self._debounce: dict[str, float] = {} + self._debounce_lock = threading.Lock() + + def _debounced(self, path: str) -> bool: + """ + Forhindrer at samme fil behandles flere gange på kort tid. + Nogle programmer gemmer filer i flere trin (temp-fil → rename). + """ + now = time.time() + with self._debounce_lock: + last = self._debounce.get(path, 0) + if now - last < 1.5: # 1.5 sekunder cooldown + return False + self._debounce[path] = now + return True + + def on_created(self, event): + if event.is_directory or not is_supported(event.src_path): + return + if self._debounced(event.src_path): + self._process_file(event.src_path) + + def on_modified(self, event): + if event.is_directory or not is_supported(event.src_path): + return + if self._debounced(event.src_path): + self._process_file(event.src_path) + + def on_deleted(self, event): + if event.is_directory or not is_supported(event.src_path): + return + logger.info(f"Fil slettet: {event.src_path}") + mark_song_missing(event.src_path) + if self.on_change: + self.on_change("deleted", event.src_path, None) + + def on_moved(self, event): + if event.is_directory: + return + # Behandl som slet + opret + if is_supported(event.src_path): + mark_song_missing(event.src_path) + if is_supported(event.dest_path): + if self._debounced(event.dest_path): + self._process_file(event.dest_path) + + def _process_file(self, path: str): + """Læs tags og gem i SQLite.""" + try: + logger.debug(f"Høster tags fra: {path}") + tags = read_tags(path) + tags["library_id"] = self.library_id + song_id = upsert_song(tags) + logger.info(f"Opdateret: {Path(path).name} ({len(tags.get('dances', []))} danse)") + if self.on_change: + self.on_change("upserted", path, song_id) + except Exception as e: + logger.error(f"Fejl ved behandling af {path}: {e}") + + +class LibraryWatcher: + """ + Styrer watchdog-observere for alle aktive musikbiblioteker. + Én instans per applikation. + """ + + def __init__(self, on_change: Callable | None = None): + self.on_change = on_change + self._observer: Observer | None = None + self._running = False + + def start(self): + """Start overvågning af alle aktive biblioteker + kør fuld scan.""" + if not WATCHDOG_AVAILABLE: + logger.warning("watchdog ikke tilgængelig — starter kun fuld scan") + self._full_scan_all() + return + + self._observer = Observer() + libraries = get_libraries(active_only=True) + + for lib in libraries: + path = Path(lib["path"]) + if not path.exists(): + logger.warning(f"Bibliotek findes ikke: {path}") + continue + + handler = MusicLibraryHandler(lib["id"], self.on_change) + self._observer.schedule(handler, str(path), recursive=True) + logger.info(f"Overvåger: {path}") + + self._observer.start() + self._running = True + + # Fuld scan i baggrundstråd så GUI ikke blokeres + threading.Thread(target=self._full_scan_all, daemon=True).start() + + def stop(self): + if self._observer and self._running: + self._observer.stop() + self._observer.join() + self._running = False + + def add_library(self, path: str) -> int: + """Tilføj et nyt bibliotek og start overvågning af det med det samme.""" + library_id = add_library(path) + + if self._observer and self._running: + handler = MusicLibraryHandler(library_id, self.on_change) + self._observer.schedule(handler, path, recursive=True) + logger.info(f"Tilføjet bibliotek: {path}") + + # Scan det nye bibliotek i baggrunden + threading.Thread( + target=self._full_scan_library, + args=(library_id, path), + daemon=True, + ).start() + + return library_id + + def remove_library(self, library_id: int): + """Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart.""" + remove_library(library_id) + # Genstart observer for at fjerne watch (watchdog understøtter ikke unschedule by id) + if self._observer and self._running: + self._observer.unschedule_all() + self._reschedule_all() + + def _reschedule_all(self): + """Genplanlæg alle aktive biblioteker på observeren.""" + for lib in get_libraries(active_only=True): + path = Path(lib["path"]) + if path.exists(): + handler = MusicLibraryHandler(lib["id"], self.on_change) + self._observer.schedule(handler, str(path), recursive=True) + + def _full_scan_all(self): + """Kør fuld scan på alle aktive biblioteker.""" + for lib in get_libraries(active_only=True): + path = Path(lib["path"]) + if path.exists(): + self._full_scan_library(lib["id"], str(path)) + + def _full_scan_library(self, library_id: int, library_path: str): + """ + Sammenligner filer på disk med SQLite og synkroniserer forskelle. + Håndterer utilgængelige mapper og symlinks sikkert. + """ + logger.info(f"Fuld scan starter: {library_path}") + base = Path(library_path) + + # Tjek at mappen faktisk er tilgængelig — med timeout + if not self._path_accessible(base): + logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}") + return + + known = get_all_song_paths_for_library(library_id) + found_paths = set() + processed = 0 + errors = 0 + + import os + for dirpath, dirnames, filenames in os.walk( + str(base), followlinks=False, + onerror=lambda e: logger.warning(f"Adgang nægtet: {e}") + ): + for filename in filenames: + file_path = Path(dirpath) / filename + try: + if not is_supported(file_path): + continue + path_str = str(file_path) + found_paths.add(path_str) + disk_modified = get_file_modified_at(file_path) + + if path_str not in known or known[path_str] != disk_modified: + tags = read_tags(file_path) + tags["library_id"] = library_id + upsert_song(tags) + processed += 1 + if self.on_change: + self.on_change("upserted", path_str, None) + except Exception as e: + logger.error(f"Scan-fejl for {file_path}: {e}") + errors += 1 + + # Marker forsvundne filer + missing_count = 0 + for known_path in known: + if known_path not in found_paths: + mark_song_missing(known_path) + missing_count += 1 + if self.on_change: + self.on_change("deleted", known_path, None) + + update_library_scan_time(library_id) + logger.info( + f"Scan færdig: {library_path} — " + f"{processed} opdateret, {missing_count} mangler, {errors} fejl" + ) + + def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool: + """Tjek om en sti er tilgængelig inden for timeout.""" + import threading + result = [False] + def check(): + try: + result[0] = path.exists() and path.is_dir() + except Exception: + result[0] = False + t = threading.Thread(target=check, daemon=True) + t.start() + t.join(timeout=timeout_sec) + return result[0] + + +# ── Singleton til brug i appen ──────────────────────────────────────────────── + +_watcher: LibraryWatcher | None = None + + +def get_watcher(on_change: Callable | None = None) -> LibraryWatcher: + """Returnerer den globale LibraryWatcher-instans.""" + global _watcher + if _watcher is None: + _watcher = LibraryWatcher(on_change=on_change) + return _watcher diff --git a/linedance-app/local/local_db.py b/linedance-app/local/local_db.py new file mode 100644 index 00000000..548116ca --- /dev/null +++ b/linedance-app/local/local_db.py @@ -0,0 +1,688 @@ +""" +local_db.py — Lokal SQLite database til offline brug. + +Håndterer: + - Musikbiblioteker (stier der overvåges) + - Sange høstet fra filsystemet + - Lokale afspilningslister (offline-projekter) + - Synkroniseringsstatus mod API +""" + +import sqlite3 +import threading +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +DB_PATH = Path.home() / ".linedance" / "local.db" + +_local = threading.local() +_global_conn: sqlite3.Connection | None = None + + +def _get_conn() -> sqlite3.Connection: + """Returnerer en global forbindelse i autocommit mode.""" + global _global_conn + if _global_conn is None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + _global_conn = sqlite3.connect(str(DB_PATH), check_same_thread=False, + isolation_level=None) # autocommit + _global_conn.row_factory = sqlite3.Row + _global_conn.execute("PRAGMA journal_mode=WAL") + _global_conn.execute("PRAGMA foreign_keys=ON") + return _global_conn + + +def new_conn() -> sqlite3.Connection: + """Åbn en frisk forbindelse til brug i tag_editor og dialogs.""" + conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=OFF") # FK checker forhindrer level_id gem + return conn + + +@contextmanager +def get_db(): + """Context manager der bruger app-forbindelsen i autocommit mode. + Hver statement committer med det samme — ingen eksplicit transaktion.""" + conn = _get_conn() + try: + yield conn + except Exception: + raise + + +def get_db_raw() -> sqlite3.Connection: + return _get_conn() + + +def init_db(): + """Opret alle tabeller hvis de ikke findes.""" + conn = _get_conn() + + # executescript committer automatisk og nulstiller isolation_level + # Kør det direkte på den underliggende connection + conn.executescript(""" + CREATE TABLE IF NOT EXISTS libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + is_active INTEGER NOT NULL DEFAULT 1, + last_full_scan TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS songs ( + id TEXT PRIMARY KEY, + library_id INTEGER REFERENCES libraries(id), + local_path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT '', + artist TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', + bpm INTEGER NOT NULL DEFAULT 0, + duration_sec INTEGER NOT NULL DEFAULT 0, + file_format TEXT NOT NULL DEFAULT '', + file_modified_at TEXT NOT NULL, + file_missing INTEGER NOT NULL DEFAULT 0, + extra_tags TEXT NOT NULL DEFAULT '{}', + api_song_id TEXT, + last_synced_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS dance_levels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sort_order INTEGER NOT NULL, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + synced_at TEXT + ); + + -- Dans-entitet: navn + niveau er unik kombination + CREATE TABLE IF NOT EXISTS dances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE, + level_id INTEGER REFERENCES dance_levels(id), + use_count INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'local', + synced_at TEXT, + UNIQUE(name, level_id) + ); + + -- Hoveddanse på en sang + CREATE TABLE IF NOT EXISTS song_dances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, + dance_id INTEGER NOT NULL REFERENCES dances(id), + dance_order INTEGER NOT NULL DEFAULT 1, + UNIQUE(song_id, dance_id) + ); + + -- Alternativ-danse på en sang + CREATE TABLE IF NOT EXISTS song_alt_dances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, + dance_id INTEGER NOT NULL REFERENCES dances(id), + note TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'local', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(song_id, dance_id) + ); + + CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + api_project_id TEXT, + last_synced_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS playlist_songs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + song_id TEXT NOT NULL REFERENCES songs(id), + position INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + UNIQUE(playlist_id, position) + ); + + CREATE TABLE IF NOT EXISTS sync_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + action TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS event_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title); + CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist); + CREATE INDEX IF NOT EXISTS idx_songs_missing ON songs(file_missing); + CREATE INDEX IF NOT EXISTS idx_songs_library ON songs(library_id); + CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id); + CREATE INDEX IF NOT EXISTS idx_song_alt_dances ON song_alt_dances(song_id); + CREATE INDEX IF NOT EXISTS idx_dances_name ON dances(name); + """) + + # executescript slår foreign_keys fra — genaktiver + conn.execute("PRAGMA foreign_keys=ON") + + # Tilføj db_version tabel hvis den ikke findes + conn.execute(""" + CREATE TABLE IF NOT EXISTS db_version ( + version INTEGER PRIMARY KEY + ) + """) + + # Kør versionsbaserede migrationer + _run_versioned_migrations(conn) + + # Seed standard-niveauer + count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0] + if count == 0: + defaults = [ + (1, "Begynder", "Passer til alle"), + (2, "Let øvet", "Lidt erfaring kræves"), + (3, "Øvet", "Kræver regelmæssig træning"), + (4, "Erfaren", "For dedikerede dansere"), + (5, "Ekspert", "Konkurrenceniveau"), + ] + for row in defaults: + conn.execute( + "INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)", + row + ) + + +# ── Versionsbaserede migrationer ────────────────────────────────────────────── +# Tilføj aldrig gamle — tilføj kun nye versioner nederst. + +MIGRATIONS: dict[int, list[str]] = { + 1: [ + "ALTER TABLE songs ADD COLUMN extra_tags TEXT NOT NULL DEFAULT '{}'", + ], + 2: [ + # Ny dans-entitet model + """CREATE TABLE IF NOT EXISTS dances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE, + level_id INTEGER REFERENCES dance_levels(id), + use_count INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'local', + synced_at TEXT, + UNIQUE(name, level_id) + )""", + """CREATE TABLE IF NOT EXISTS song_alt_dances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, + dance_id INTEGER NOT NULL REFERENCES dances(id), + note TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'local', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(song_id, dance_id) + )""", + # Migrer eksisterende song_dances data til ny model + # (kører kun på ældre databaser der har dance_name kolonnen) + """INSERT OR IGNORE INTO dances (name, level_id, source) + SELECT DISTINCT dance_name, level_id, 'local' + FROM song_dances WHERE dance_name IS NOT NULL AND dance_name != ''""", + ], +} + + +def _run_versioned_migrations(conn): + """Kør kun migrationer der ikke allerede er kørt vha. db_version tabel.""" + row = conn.execute("SELECT version FROM db_version").fetchone() + current_version = row["version"] if row else 0 + + for version in sorted(MIGRATIONS.keys()): + if version <= current_version: + continue + for sql in MIGRATIONS[version]: + try: + conn.execute(sql) + except Exception: + pass # kolonnen eksisterer allerede + conn.execute( + "INSERT OR REPLACE INTO db_version (version) VALUES (?)", (version,) + ) + + + + + + + +# ── Biblioteker ─────────────────────────────────────────────────────────────── + + +def add_library(path: str) -> int: + with get_db() as conn: + cur = conn.execute( + "INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,) + ) + if cur.lastrowid: + return cur.lastrowid + row = conn.execute("SELECT id FROM libraries WHERE path=?", (path,)).fetchone() + return row["id"] + + +def get_libraries(active_only: bool = True) -> list[sqlite3.Row]: + with get_db() as conn: + if active_only: + return conn.execute( + "SELECT * FROM libraries WHERE is_active=1 ORDER BY path" + ).fetchall() + return conn.execute("SELECT * FROM libraries ORDER BY path").fetchall() + + +def remove_library(library_id: int): + with get_db() as conn: + # Marker sange som manglende + conn.execute( + "UPDATE songs SET file_missing=1 WHERE library_id=?", (library_id,) + ) + # Slet biblioteket helt + conn.execute("DELETE FROM libraries WHERE id=?", (library_id,)) + + +def update_library_scan_time(library_id: int): + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + conn.execute( + "UPDATE libraries SET last_full_scan=? WHERE id=?", (now, library_id) + ) + + +# ── Sange ───────────────────────────────────────────────────────────────────── + +def upsert_song(song_data: dict) -> str: + """ + Indsæt eller opdater en sang baseret på local_path. + Returnerer song_id. + """ + import uuid, json + with get_db() as conn: + existing = conn.execute( + "SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],) + ).fetchone() + + extra_tags_json = json.dumps(song_data.get("extra_tags", {}), ensure_ascii=False) + + if existing: + song_id = existing["id"] + conn.execute(""" + UPDATE songs SET + library_id=?, title=?, artist=?, album=?, bpm=?, duration_sec=?, + file_format=?, file_modified_at=?, file_missing=0, extra_tags=? + WHERE id=? + """, ( + song_data.get("library_id"), + song_data.get("title", ""), + song_data.get("artist", ""), + song_data.get("album", ""), + song_data.get("bpm", 0), + song_data.get("duration_sec", 0), + song_data.get("file_format", ""), + song_data.get("file_modified_at", ""), + extra_tags_json, + song_id, + )) + else: + song_id = str(uuid.uuid4()) + conn.execute(""" + INSERT INTO songs + (id, library_id, local_path, title, artist, album, + bpm, duration_sec, file_format, file_modified_at, extra_tags) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, ( + song_id, + song_data.get("library_id"), + song_data["local_path"], + song_data.get("title", ""), + song_data.get("artist", ""), + song_data.get("album", ""), + song_data.get("bpm", 0), + song_data.get("duration_sec", 0), + song_data.get("file_format", ""), + song_data.get("file_modified_at", ""), + extra_tags_json, + )) + + # Opdater danse hvis de er med i data — bevar eksisterende og merge + if "dances" in song_data: + file_dances = [] + for dance in song_data["dances"]: + name = dance.get("name", dance) if isinstance(dance, dict) else dance + if name: + file_dances.append(name.strip()) + + # Find eksisterende song_dances via dances tabel + existing = conn.execute(""" + SELECT sd.id, d.name, sd.dance_order, d.level_id, d.id as dance_id + FROM song_dances sd + JOIN dances d ON d.id = sd.dance_id + WHERE sd.song_id=? ORDER BY sd.dance_order + """, (song_id,)).fetchall() + existing_map = {r["name"].lower(): r for r in existing} + file_lower = [d.lower() for d in file_dances] + + # Slet danse der ikke længere er i filen + for row in existing: + if row["name"].lower() not in file_lower: + conn.execute("DELETE FROM song_dances WHERE id=?", (row["id"],)) + + # Tilføj eller opdater danse fra filen + for i, name in enumerate(file_dances, start=1): + ex = existing_map.get(name.lower()) + if ex: + conn.execute( + "UPDATE song_dances SET dance_order=? WHERE id=?", + (i, ex["id"]) + ) + else: + # Opret eller find dans (name + NULL level = ny dans uden niveau) + dance_id = get_or_create_dance(name, None, conn) + conn.execute( + "INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) " + "VALUES (?,?,?)", + (song_id, dance_id, i) + ) + + return song_id + + +def mark_song_missing(local_path: str): + with get_db() as conn: + conn.execute( + "UPDATE songs SET file_missing=1 WHERE local_path=?", (local_path,) + ) + + +def get_song_by_path(local_path: str) -> sqlite3.Row | None: + with get_db() as conn: + return conn.execute( + "SELECT * FROM songs WHERE local_path=?", (local_path,) + ).fetchone() + + +def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]: + """Søg i alle tags — titel, artist, album, danse og alle øvrige tags.""" + pattern = f"%{query}%" + with get_db() as conn: + return conn.execute(""" + SELECT DISTINCT s.* FROM songs s + LEFT JOIN song_dances sd ON sd.song_id = s.id + LEFT JOIN dances d ON d.id = sd.dance_id + WHERE s.file_missing = 0 + AND ( + s.title LIKE ? OR + s.artist LIKE ? OR + s.album LIKE ? OR + d.name LIKE ? OR + s.extra_tags LIKE ? + ) + ORDER BY s.artist, s.title + LIMIT ? + """, (pattern, pattern, pattern, pattern, pattern, limit)).fetchall() + + +def get_songs_for_library(library_id: int) -> list[sqlite3.Row]: + with get_db() as conn: + return conn.execute( + "SELECT * FROM songs WHERE library_id=? ORDER BY artist, title", + (library_id,) + ).fetchall() + + +def get_all_song_paths_for_library(library_id: int) -> dict[str, str]: + """Returnerer {local_path: file_modified_at} — bruges til fuld scan.""" + with get_db() as conn: + rows = conn.execute( + "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", + (library_id,) + ).fetchall() + return {row["local_path"]: row["file_modified_at"] for row in rows} + + +# ── Afspilningslister ───────────────────────────────────────────────────────── + +def create_playlist(name: str, description: str = "") -> int: + with get_db() as conn: + cur = conn.execute( + "INSERT INTO playlists (name, description) VALUES (?,?)", + (name, description) + ) + return cur.lastrowid + + +def get_playlists() -> list[sqlite3.Row]: + with get_db() as conn: + return conn.execute( + "SELECT * FROM playlists ORDER BY created_at DESC" + ).fetchall() + + +def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int: + with get_db() as conn: + if position is None: + row = conn.execute( + "SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?", + (playlist_id,) + ).fetchone() + position = (row["max_pos"] or 0) + 1 + + cur = conn.execute( + "INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?,?,?)", + (playlist_id, song_id, position) + ) + return cur.lastrowid + + +def update_playlist_song_status(playlist_song_id: int, status: str): + valid = {"pending", "playing", "played", "skipped"} + if status not in valid: + raise ValueError(f"Ugyldig status: {status}") + with get_db() as conn: + conn.execute( + "UPDATE playlist_songs SET status=? WHERE id=?", + (status, playlist_song_id) + ) + + +def get_playlist_with_songs(playlist_id: int) -> dict: + with get_db() as conn: + playlist = conn.execute( + "SELECT * FROM playlists WHERE id=?", (playlist_id,) + ).fetchone() + if not playlist: + return {} + + songs = conn.execute(""" + SELECT ps.id as ps_id, ps.position, ps.status, + s.*, GROUP_CONCAT(sd.dance_name ORDER BY sd.dance_order) as dances + FROM playlist_songs ps + JOIN songs s ON s.id = ps.song_id + LEFT JOIN song_dances sd ON sd.song_id = s.id + WHERE ps.playlist_id = ? + GROUP BY ps.id + ORDER BY ps.position + """, (playlist_id,)).fetchall() + + return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]} + + +# ── Event-state (gemmes løbende så man kan genstarte efter strømsvigt) ──────── + +def save_event_state(current_idx: int, statuses: list[str]): + """Gem event-fremgang — overskrives ved hver ændring.""" + import json + with get_db() as conn: + conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('current_idx',?)", + (str(current_idx),)) + conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('statuses',?)", + (json.dumps(statuses),)) + + +def load_event_state() -> tuple[int, list[str]] | None: + """Indlæs gemt event-fremgang. Returnerer None hvis ingen gemt tilstand.""" + import json + with get_db() as conn: + idx_row = conn.execute( + "SELECT value FROM event_state WHERE key='current_idx'" + ).fetchone() + sta_row = conn.execute( + "SELECT value FROM event_state WHERE key='statuses'" + ).fetchone() + if not idx_row or not sta_row: + return None + return int(idx_row["value"]), json.loads(sta_row["value"]) + + +def clear_event_state(): + """Nulstil gemt event-tilstand (bruges ved 'Start event').""" + with get_db() as conn: + conn.execute("DELETE FROM event_state") + + +# ── Dans-navne ordbog ───────────────────────────────────────────────────────── + +# ── Dans-entitet funktioner ─────────────────────────────────────────────────── + +def get_or_create_dance(name: str, level_id: int | None, + conn=None) -> int: + """Find eller opret en dans (name + level_id kombination). + Returnerer dance_id. conn er valgfri — bruges ved nested kald.""" + name = name.strip() + close = False + if conn is None: + conn = new_conn() + close = True + try: + existing = conn.execute( + "SELECT id FROM dances WHERE name=? COLLATE NOCASE AND level_id IS ?", + (name, level_id) + ).fetchone() + if existing: + conn.execute( + "UPDATE dances SET use_count=use_count+1 WHERE id=?", + (existing["id"],) + ) + return existing["id"] + conn.execute( + "INSERT INTO dances (name, level_id, use_count, source) VALUES (?,?,1,'local')", + (name, level_id) + ) + return conn.execute( + "SELECT id FROM dances WHERE name=? COLLATE NOCASE AND level_id IS ?", + (name, level_id) + ).fetchone()["id"] + finally: + if close: + conn.commit() + conn.close() + + +def get_dance_suggestions(prefix: str, limit: int = 20) -> list[dict]: + """Returnerer danse der starter med prefix som {id, name, level_id, level_name}. + Sorteret efter popularitet — bruges til autoudfyld.""" + with get_db() as conn: + rows = conn.execute(""" + SELECT d.id, d.name, d.level_id, d.use_count, + dl.name as level_name, dl.sort_order + FROM dances d + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE d.name LIKE ? COLLATE NOCASE + ORDER BY d.use_count DESC, dl.sort_order, d.name + LIMIT ? + """, (f"{prefix}%", limit)).fetchall() + return [dict(r) for r in rows] + + +def get_dances_for_song(song_id: str) -> list[dict]: + """Hent hoveddanse for en sang med niveau-info.""" + with get_db() as conn: + rows = conn.execute(""" + SELECT d.id as dance_id, d.name, d.level_id, + dl.name as level_name, sd.dance_order, sd.id as song_dance_id + FROM song_dances sd + JOIN dances d ON d.id = sd.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE sd.song_id=? ORDER BY sd.dance_order + """, (song_id,)).fetchall() + return [dict(r) for r in rows] + + +def get_alt_dances_for_song(song_id: str) -> list[dict]: + """Hent alternativ-danse for en sang med niveau-info.""" + with get_db() as conn: + rows = conn.execute(""" + SELECT d.id as dance_id, d.name, d.level_id, + dl.name as level_name, sad.note, sad.source, sad.id as alt_id + FROM song_alt_dances sad + JOIN dances d ON d.id = sad.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE sad.song_id=? ORDER BY d.name + """, (song_id,)).fetchall() + return [dict(r) for r in rows] + + +# ── Dans-niveauer ───────────────────────────────────────────────────────────── + +def get_dance_levels() -> list[sqlite3.Row]: + """Hent alle niveauer sorteret efter sort_order.""" + with get_db() as conn: + return conn.execute( + "SELECT * FROM dance_levels ORDER BY sort_order" + ).fetchall() + + +def sync_dance_levels_from_api(levels: list[dict]): + """Synkroniser niveauer fra API.""" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + for lvl in levels: + conn.execute(""" + INSERT INTO dance_levels (sort_order, name, description, synced_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + sort_order = excluded.sort_order, + description = excluded.description, + synced_at = excluded.synced_at + """, (lvl["sort_order"], lvl["name"], lvl.get("description", ""), now)) + + +def sync_dances_from_api(dances: list[dict]): + """Synkroniser danse fra API — {name, level_id, use_count}.""" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + for d in dances: + conn.execute(""" + INSERT INTO dances (name, level_id, use_count, source, synced_at) + VALUES (?, ?, ?, 'community', ?) + ON CONFLICT(name, level_id) DO UPDATE SET + use_count = MAX(use_count, excluded.use_count), + synced_at = excluded.synced_at + """, (d["name"], d.get("level_id"), d.get("use_count", 1), now)) + + +# Backwards compat alias +def get_dance_name_suggestions(prefix: str, limit: int = 20) -> list[str]: + """Returnerer dans-navne som strings — bruges af AutoLineEdit.""" + suggestions = get_dance_suggestions(prefix, limit) + result = [] + for s in suggestions: + if s.get("level_name"): + result.append(f"{s['name']} / {s['level_name']}") + else: + result.append(s["name"]) + return result + + diff --git a/linedance-app/local/tag_reader.py b/linedance-app/local/tag_reader.py new file mode 100644 index 00000000..3df1ee8e --- /dev/null +++ b/linedance-app/local/tag_reader.py @@ -0,0 +1,391 @@ +""" +tag_reader.py — Læser og skriver metadata fra lydfiler. + +Understøttede formater og danse-tag support: + MP3 — læs + skriv danse (ID3 TXXX-felter) + FLAC — læs + skriv danse (Vorbis Comments) + OGG — læs + skriv danse (Vorbis Comments) + OPUS — læs + skriv danse (Vorbis Comments) + M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE) + WAV — læs metadata, ingen danse-tag support + WMA — læs metadata, ingen danse-tag support + AIFF — læs metadata, ingen danse-tag support + +Danse gemmes ALTID i SQLite uanset format. +Fil-skrivning er kun muligt for de formater der understøtter custom tags. +""" + +import os +from datetime import datetime, timezone +from pathlib import Path + +try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TXXX + from mutagen.flac import FLAC + from mutagen.mp4 import MP4, MP4FreeForm + MUTAGEN_AVAILABLE = True +except ImportError: + MUTAGEN_AVAILABLE = False + print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret") + + +# Filtyper vi høster metadata fra +SUPPORTED_EXTENSIONS = { + ".mp3", ".flac", ".wav", ".m4a", ".aac", + ".ogg", ".opus", ".wma", ".aiff", ".aif", +} + +# Formater der understøtter skrivning af danse-tags til fil +WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"} + +# Tag-nøgler brugt på tværs af formater +TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1 +VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1 +M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste) + + +def is_supported(path: str | Path) -> bool: + return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS + + +def can_write_dances(path: str | Path) -> bool: + """Returnerer True hvis formatet understøtter skrivning af danse-tags til fil.""" + return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS + + +def get_file_modified_at(path: str | Path) -> str: + ts = os.path.getmtime(str(path)) + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +# ── Læsning ─────────────────────────────────────────────────────────────────── + +def read_tags(path: str | Path) -> dict: + """ + Læser metadata og danse fra en lydfil. + Returnerer dict med: title, artist, album, bpm, duration_sec, + file_format, file_modified_at, dances, can_write_dances, + extra_tags (dict med alle øvrige tags som {navn: værdi}). + """ + path = Path(path) + result = { + "local_path": str(path), + "title": path.stem, + "artist": "", + "album": "", + "bpm": 0, + "duration_sec": 0, + "file_format": path.suffix.lower().lstrip("."), + "file_modified_at": get_file_modified_at(path), + "dances": [], + "can_write_dances": can_write_dances(path), + "extra_tags": {}, + } + + if not MUTAGEN_AVAILABLE: + return result + + try: + audio = MutagenFile(str(path), easy=False) + if audio is None: + return result + + if hasattr(audio, "info") and audio.info: + result["duration_sec"] = int(getattr(audio.info, "length", 0)) + + ext = path.suffix.lower() + + if ext == ".mp3": + _read_mp3(audio, result) + elif ext == ".flac": + _read_vorbis(audio, result) + elif ext in (".ogg", ".opus"): + _read_vorbis(audio, result) + elif ext in (".m4a", ".aac", ".mp4"): + _read_m4a(audio, result) + else: + _read_generic(audio, result) + + except Exception as e: + print(f"Fejl ved læsning af {path}: {e}") + + return result + + +def _read_mp3(audio, result: dict): + tags = audio.tags + if not tags: + return + if "TIT2" in tags: + result["title"] = str(tags["TIT2"].text[0]) + if "TPE1" in tags: + result["artist"] = str(tags["TPE1"].text[0]) + if "TALB" in tags: + result["album"] = str(tags["TALB"].text[0]) + if "TBPM" in tags: + try: + result["bpm"] = int(float(str(tags["TBPM"].text[0]))) + except (ValueError, TypeError): + pass + dances = {} + extra = {} + # Kendte ID3-felt-navne til menneskelige navne + ID3_NAMES = { + "TIT2": "titel", "TPE1": "artist", "TALB": "album", "TBPM": "bpm", + "TYER": "år", "TDRC": "dato", "TCON": "genre", "TPE2": "albumartist", + "TPOS": "disknummer", "TRCK": "spornummer", "TCOM": "komponist", + "TLYR": "sangtekst", "TCOP": "copyright", "TPUB": "udgiver", + "TENC": "kodet_af", "TLAN": "sprog", "TMOO": "stemning", + "TPE3": "dirigent", "TPE4": "fortolket_af", "TOAL": "original_album", + "TOPE": "original_artist", "TORY": "original_år", + } + for key, frame in tags.items(): + if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key: + try: + num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", "")) + dances[num] = str(frame.text[0]) + except (ValueError, IndexError): + pass + elif key.startswith("TXXX:"): + # Custom TXXX-felt — gem under dets beskrivelse + desc = key[5:] # fjern "TXXX:" + try: + extra[desc] = str(frame.text[0]) + except Exception: + pass + elif key in ID3_NAMES and key not in ("TIT2","TPE1","TALB","TBPM"): + # Standardfelt vi ikke allerede har gemt + try: + val = str(frame.text[0]) if hasattr(frame, "text") else str(frame) + if val: + extra[ID3_NAMES[key]] = val + except Exception: + pass + elif hasattr(frame, "text") and key not in ("TIT2","TPE1","TALB","TBPM"): + # Alle andre tekstfelter + try: + val = str(frame.text[0]) + if val and not key.startswith("APIC"): # spring albumcover over + extra[key] = val + except Exception: + pass + result["dances"] = [dances[k] for k in sorted(dances.keys())] + result["extra_tags"] = extra + + +def _read_vorbis(audio, result: dict): + """FLAC og OGG/Opus bruger begge Vorbis Comments.""" + tags = audio.tags + if not tags: + return + result["title"] = tags.get("title", [result["title"]])[0] + result["artist"] = tags.get("artist", [""])[0] + result["album"] = tags.get("album", [""])[0] + try: + result["bpm"] = int(tags.get("bpm", [0])[0]) + except (ValueError, TypeError): + pass + # Danse + dances = {} + for key, values in tags.items(): + if key.lower().startswith(f"{VORBIS_DANCE_KEY}."): + try: + num = int(key.split(".")[-1]) + dances[num] = values[0] + except (ValueError, IndexError): + pass + if not dances and VORBIS_DANCE_KEY in tags: + result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()] + else: + result["dances"] = [dances[k] for k in sorted(dances.keys())] + # Alle øvrige tags som extra_tags + skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY} + extra = {} + for key, values in tags.items(): + k = key.lower() + if k not in skip and not k.startswith(VORBIS_DANCE_KEY): + try: + extra[k] = str(values[0]) + except Exception: + pass + result["extra_tags"] = extra + + +def _read_m4a(audio, result: dict): + tags = audio.tags + if not tags: + return + if "\xa9nam" in tags: + result["title"] = str(tags["\xa9nam"][0]) + if "\xa9ART" in tags: + result["artist"] = str(tags["\xa9ART"][0]) + if "\xa9alb" in tags: + result["album"] = str(tags["\xa9alb"][0]) + if "tmpo" in tags: + try: + result["bpm"] = int(tags["tmpo"][0]) + except (ValueError, TypeError): + pass + if M4A_DANCE_FREEFORM in tags: + result["dances"] = [ + v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v) + for v in tags[M4A_DANCE_FREEFORM] + ] + # Menneskelige navne til M4A-nøgler + M4A_NAMES = { + "\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album", + "\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist", + "\xa9cmt": "kommentar", "aART": "albumartist", "trkn": "spornummer", + "disk": "disknummer", "cprt": "copyright", "\xa9lyr": "sangtekst", + "tmpo": "bpm", + } + skip_keys = {"\xa9nam", "\xa9ART", "\xa9alb", "tmpo", M4A_DANCE_FREEFORM, "covr"} + extra = {} + for key, values in tags.items(): + if key in skip_keys: + continue + label = M4A_NAMES.get(key, key) + try: + val = values[0] + if isinstance(val, (bytes, MP4FreeForm)): + val = val.decode("utf-8", errors="replace") + extra[label] = str(val) + except Exception: + pass + result["extra_tags"] = extra + + +def _read_generic(audio, result: dict): + try: + easy = MutagenFile(result["local_path"], easy=True) + if easy and easy.tags: + result["title"] = easy.tags.get("title", [result["title"]])[0] + result["artist"] = easy.tags.get("artist", [""])[0] + result["album"] = easy.tags.get("album", [""])[0] + except Exception: + pass + + +# ── Skrivning ───────────────────────────────────────────────────────────────── + +def write_dances(path: str | Path, dances: list[str]) -> bool: + """ + Skriver danse til filen hvis formatet understøtter det. + Returnerer True ved succes, False hvis formatet ikke understøtter det. + Kaster Exception ved fejl under skrivning. + """ + if not MUTAGEN_AVAILABLE: + return False + + path = Path(path) + ext = path.suffix.lower() + + if ext not in WRITABLE_DANCE_FORMATS: + return False + + if ext == ".mp3": + return _write_mp3_dances(path, dances) + elif ext in (".flac", ".ogg", ".opus"): + return _write_vorbis_dances(path, dances) + elif ext in (".m4a", ".aac"): + return _write_m4a_dances(path, dances) + + return False + + +def _write_mp3_dances(path: Path, dances: list[str]) -> bool: + try: + tags = ID3(str(path)) + for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]: + del tags[key] + for i, name in enumerate(dances, start=1): + tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name)) + tags.save(str(path)) + return True + except Exception as e: + print(f"MP3 skrivefejl {path}: {e}") + return False + + +def _write_vorbis_dances(path: Path, dances: list[str]) -> bool: + try: + audio = MutagenFile(str(path), easy=False) + if audio is None or audio.tags is None: + return False + # Slet eksisterende danse-felter + keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")] + for key in keys_to_delete: + del audio.tags[key] + # Skriv nye — ét felt per dans + for i, name in enumerate(dances, start=1): + audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name + audio.save() + return True + except Exception as e: + print(f"Vorbis skrivefejl {path}: {e}") + return False + + +def _write_m4a_dances(path: Path, dances: list[str]) -> bool: + try: + audio = MP4(str(path)) + audio.tags[M4A_DANCE_FREEFORM] = [ + MP4FreeForm(name.encode("utf-8")) for name in dances + ] + audio.save() + return True + except Exception as e: + print(f"M4A skrivefejl {path}: {e}") + return False + + +# ── Hurtig læsning af kun danse (uden fuld tag-scan) ───────────────────────── + +def read_dances_from_file(path: str | Path) -> list[str]: + """Læser kun danse fra en fil — hurtigere end fuld read_tags().""" + tags = read_tags(path) + return tags.get("dances", []) + + +# ── BPM-analyse ─────────────────────────────────────────────────────────────── + +def analyze_bpm(path: str | Path) -> float | None: + """ + Analysér BPM fra lydfilen ved hjælp af librosa. + Returnerer BPM som float eller None ved fejl. + Tager 2-5 sekunder per sang — kør i baggrundstråd. + """ + try: + import librosa + # Indlæs kun de første 60 sekunder for hastighed + y, sr = librosa.load(str(path), duration=60.0, mono=True) + tempo, _ = librosa.beat.beat_track(y=y, sr=sr) + # librosa returnerer array i nyere versioner + if hasattr(tempo, "__len__"): + bpm = float(tempo[0]) if len(tempo) > 0 else 0.0 + else: + bpm = float(tempo) + return round(bpm, 1) if bpm > 0 else None + except ImportError: + print("librosa ikke installeret — installer med: pip install librosa") + return None + except Exception as e: + print(f"BPM-analyse fejl for {path}: {e}") + return None + + +def analyze_and_save_bpm(path: str | Path, song_id: str) -> float | None: + """Analysér BPM og gem i SQLite. Returnerer målt BPM.""" + bpm = analyze_bpm(path) + if bpm and bpm > 0: + try: + from local.local_db import get_db + with get_db() as conn: + conn.execute( + "UPDATE songs SET bpm=? WHERE id=? AND (bpm IS NULL OR bpm=0)", + (int(round(bpm)), song_id) + ) + except Exception as e: + print(f"BPM gem fejl: {e}") + return bpm diff --git a/linedance-app/main.py b/linedance-app/main.py new file mode 100644 index 00000000..ad5f9af2 --- /dev/null +++ b/linedance-app/main.py @@ -0,0 +1,33 @@ +""" +main.py — Linedance afspiller. + +Start: + python main.py +""" + +import sys +import os + +# Sørg for at rodmappen er i Python-stien +sys.path.insert(0, os.path.dirname(__file__)) + +from PyQt6.QtWidgets import QApplication +from ui.main_window import MainWindow +from ui.themes import apply_theme + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("LineDance Player") + app.setOrganizationName("LineDance") + + apply_theme(app, dark=True) + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/linedance-app/player/__init__.py b/linedance-app/player/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/linedance-app/player/player.py b/linedance-app/player/player.py new file mode 100644 index 00000000..758077e8 --- /dev/null +++ b/linedance-app/player/player.py @@ -0,0 +1,200 @@ +""" +player.py — VLC-baseret afspiller med PyQt6 signals. + +Sender signals til GUI: + position_changed(float) — 0.0–1.0 progress + time_changed(int, int) — (current_sec, total_sec) + levels_changed(float, float) — VU-meter L/R 0.0–1.0 + song_ended() — sang færdig + state_changed(str) — 'playing'|'paused'|'stopped' +""" + +from PyQt6.QtCore import QObject, pyqtSignal, QTimer +import random +import math + +try: + import vlc + VLC_AVAILABLE = True +except ImportError: + VLC_AVAILABLE = False + print("Advarsel: python-vlc ikke installeret — afspilning deaktiveret") + + +class Player(QObject): + position_changed = pyqtSignal(float) + time_changed = pyqtSignal(int, int) + levels_changed = pyqtSignal(float, float) + song_ended = pyqtSignal() + state_changed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._path: str | None = None + self._duration: int = 0 + self._demo_mode = False + self._demo_stop_sec = 10 + self._demo_fade_sec = 5 + self._demo_fading = False + self._volume = 78 + + if VLC_AVAILABLE: + self._instance = vlc.Instance("--no-video", "--quiet") + self._media_player = self._instance.media_player_new() + self._events = self._media_player.event_manager() + self._events.event_attach( + vlc.EventType.MediaPlayerEndReached, + self._on_end_reached, + ) + else: + self._media_player = None + + # Timer til polling af position + VU-simulation + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(80) + self._poll_timer.timeout.connect(self._poll) + + # ── Indlæsning ──────────────────────────────────────────────────────────── + + def load(self, path: str, duration_sec: int = 0): + """Indlæs en lydfil uden at starte afspilning.""" + self._path = path + self._duration = duration_sec + self._demo_mode = False + + if VLC_AVAILABLE and self._media_player: + media = self._instance.media_new(path) + self._media_player.set_media(media) + self._media_player.audio_set_volume(self._volume) + + self.position_changed.emit(0.0) + self.time_changed.emit(0, self._duration) + self.state_changed.emit("stopped") + + # ── Transport ───────────────────────────────────────────────────────────── + + def play(self): + self._demo_mode = False + if VLC_AVAILABLE and self._media_player: + self._media_player.play() + self._poll_timer.start() + self.state_changed.emit("playing") + + def play_demo(self, stop_at_sec: int = 10, fade_sec: int = 5): + """ + Afspil fra start, fade ud over fade_sec sekunder og stop. + Total afspilningstid = stop_at_sec + fade_sec. + fade_sec=0 giver ingen fade. + """ + self._demo_mode = True + self._demo_stop_sec = stop_at_sec + fade_sec # total tid inkl. fade + self._demo_fade_sec = fade_sec + self._demo_fading = False + if VLC_AVAILABLE and self._media_player: + self._media_player.set_time(0) + self._media_player.audio_set_volume(self._volume) + self._media_player.play() + self._poll_timer.start() + self.state_changed.emit("playing") + + def pause(self): + if VLC_AVAILABLE and self._media_player: + self._media_player.pause() + self.state_changed.emit("paused") + + def stop(self): + self._demo_mode = False + self._demo_fading = False + if VLC_AVAILABLE and self._media_player: + self._media_player.audio_set_volume(self._volume) + self._media_player.stop() + self._poll_timer.stop() + self.position_changed.emit(0.0) + self.time_changed.emit(0, self._duration) + self.state_changed.emit("stopped") + + def is_playing(self) -> bool: + if VLC_AVAILABLE and self._media_player: + return self._media_player.is_playing() + return False + + def set_volume(self, volume: int): + """0–100""" + self._volume = volume + if VLC_AVAILABLE and self._media_player: + self._media_player.audio_set_volume(volume) + + def set_position(self, fraction: float): + """Søg til position 0.0–1.0""" + if VLC_AVAILABLE and self._media_player: + self._media_player.set_position(fraction) + + # ── Intern polling ──────────────────────────────────────────────────────── + + def _poll(self): + """Køres ~12 gange per sekund — opdaterer position og VU-meter.""" + if VLC_AVAILABLE and self._media_player: + pos = self._media_player.get_position() + ms = self._media_player.get_time() + cur = max(0, ms // 1000) + else: + # Simuleret tilstand (til UI-test uden VLC) + pos = getattr(self, "_sim_pos", 0.0) + self._sim_pos = min(1.0, pos + 0.001) + cur = int(self._sim_pos * self._duration) + pos = self._sim_pos + if self._sim_pos >= 1.0: + self._on_end_reached(None) + return + + self.position_changed.emit(pos) + self.time_changed.emit(cur, self._duration) + + # Demo fade-out og stop + if self._demo_mode and cur >= self._demo_stop_sec: + # Færdig — gendan volumen og stop + if VLC_AVAILABLE and self._media_player: + self._media_player.audio_set_volume(self._volume) + self.stop() + self._demo_mode = False + self._demo_fading = False + self.position_changed.emit(0.0) + self.time_changed.emit(0, self._duration) + self.state_changed.emit("demo_ended") + return + + # Demo fade-out — de sidste _demo_fade_sec sekunder (0 = ingen fade) + if self._demo_mode and VLC_AVAILABLE and self._media_player and self._demo_fade_sec > 0: + secs_left = self._demo_stop_sec - cur + if secs_left <= self._demo_fade_sec and secs_left > 0: + fade_fraction = secs_left / self._demo_fade_sec # 1.0 → 0.0 + log_fraction = math.log10(1 + fade_fraction * 9) / math.log10(10) + faded_vol = int(self._volume * log_fraction) + self._media_player.audio_set_volume(max(0, faded_vol)) + self._demo_fading = True + elif not self._demo_fading: + self._media_player.audio_set_volume(self._volume) + + # VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér + if VLC_AVAILABLE and self._media_player and self._media_player.is_playing(): + # VLC eksponerer ikke amplitude direkte — vi bruger en blød simulation + # der er baseret på position så det ser organisk ud + base = 0.55 + 0.3 * abs(pos - 0.5) + l = min(1.0, base + random.gauss(0, 0.12)) + r = min(1.0, base + random.gauss(0, 0.12)) + else: + l = r = 0.0 + + self.levels_changed.emit(max(0.0, l), max(0.0, r)) + + def _on_end_reached(self, event): + """Kaldes fra VLC's event-tråd — må IKKE røre Qt-objekter direkte.""" + # QTimer.singleShot er thread-safe og sender alt til main thread + from PyQt6.QtCore import QTimer as _QTimer + _QTimer.singleShot(0, self._handle_end_in_main_thread) + + def _handle_end_in_main_thread(self): + """Kaldes i main thread — her er det sikkert at røre Qt.""" + self._poll_timer.stop() + self.song_ended.emit() + self.state_changed.emit("stopped") diff --git a/linedance-app/requirements.txt b/linedance-app/requirements.txt new file mode 100644 index 00000000..b005ffb1 --- /dev/null +++ b/linedance-app/requirements.txt @@ -0,0 +1,7 @@ +PyQt6>=6.6.0 +python-vlc>=3.0.18 +mutagen>=1.47.0 +watchdog>=4.0.0 + +# BPM-analyse +librosa>=0.10.0 diff --git a/linedance-app/ui/__init__.py b/linedance-app/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/linedance-app/ui/library_manager.py b/linedance-app/ui/library_manager.py new file mode 100644 index 00000000..3fdf047f --- /dev/null +++ b/linedance-app/ui/library_manager.py @@ -0,0 +1,135 @@ +""" +library_manager.py — Dialog til at se og fjerne musikbiblioteker. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QListWidget, QListWidgetItem, QMessageBox, +) +from PyQt6.QtCore import Qt, pyqtSignal + + +class LibraryManagerDialog(QDialog): + library_removed = pyqtSignal(int) # library_id + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Administrer musikbiblioteker") + self.setMinimumWidth(500) + self.setMinimumHeight(320) + self._build_ui() + self._load() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(10) + + lbl = QLabel("Aktive musikbiblioteker:") + lbl.setObjectName("track_meta") + layout.addWidget(lbl) + + self._list = QListWidget() + layout.addWidget(self._list) + + note = QLabel( + "Når du fjerner et bibliotek, slettes det fra overvågningen.\n" + "Sangene forbliver i databasen men markeres som manglende (⚠)." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + layout.addWidget(note) + + btn_row = QHBoxLayout() + btn_add = QPushButton("+ Tilføj mappe") + btn_add.clicked.connect(self._add_folder) + btn_row.addWidget(btn_add) + + btn_remove = QPushButton("✕ Fjern valgt") + btn_remove.clicked.connect(self._remove_selected) + btn_row.addWidget(btn_remove) + + btn_scan = QPushButton("⟳ Scan alle") + btn_scan.setToolTip("Scan alle mapper for nye og ændrede filer") + btn_scan.clicked.connect(self._scan_all) + btn_row.addWidget(btn_scan) + + btn_row.addStretch() + btn_close = QPushButton("Luk") + btn_close.clicked.connect(self.accept) + btn_row.addWidget(btn_close) + layout.addLayout(btn_row) + + def _load(self): + self._list.clear() + try: + from local.local_db import get_libraries, get_db + libs = get_libraries(active_only=True) # kun aktive + for lib in libs: + from pathlib import Path + path = lib["path"] + exists = Path(path).exists() + last_scan = lib["last_full_scan"] or "aldrig" + if isinstance(last_scan, str) and len(last_scan) > 10: + last_scan = last_scan[:10] + with get_db() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0", + (lib["id"],) + ).fetchone()[0] + exist_icon = "" if exists else " ⚠ mappe ikke fundet" + label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, dict(lib)) + if not exists: + from PyQt6.QtGui import QColor + item.setForeground(QColor("#5a6070")) + self._list.addItem(item) + except Exception as e: + print(f"Library manager load fejl: {e}") + + def _scan_all(self): + mw = self.parent() + if hasattr(mw, "start_scan"): + mw.start_scan() + self._set_status("Scanning startet...") + + def _set_status(self, text: str): + pass # kan udvides med statuslinje i dialogen + + def _add_folder(self): + from PyQt6.QtWidgets import QFileDialog + folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") + if folder: + mw = self.parent() + if hasattr(mw, "add_library_path"): + mw.add_library_path(folder) + # Genindlæs listen efter kort pause så DB er opdateret + from PyQt6.QtCore import QTimer + QTimer.singleShot(600, self._load) + + def _remove_selected(self): + item = self._list.currentItem() + if not item: + return + lib = item.data(Qt.ItemDataRole.UserRole) + reply = QMessageBox.question( + self, "Fjern bibliotek", + f"Fjern overvågningen af:\n{lib['path']}\n\n" + "Sange i biblioteket forbliver i databasen men markeres som manglende.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + try: + mw = self.parent() + if hasattr(mw, "_watcher") and mw._watcher: + mw._watcher.remove_library(lib["id"]) + else: + from local.local_db import remove_library + remove_library(lib["id"]) + self.library_removed.emit(lib["id"]) + if hasattr(mw, "_reload_library"): + mw._reload_library() + self._load() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}") diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py new file mode 100644 index 00000000..b30407da --- /dev/null +++ b/linedance-app/ui/library_panel.py @@ -0,0 +1,364 @@ +""" +library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QListWidget, QListWidgetItem, + QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar, + QAbstractItemView, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray +from PyQt6.QtGui import QColor, QDrag + + +class DraggableLibraryList(QListWidget): + """QListWidget der understøtter drag-start med sang-data som mime.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setDragEnabled(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly) + self.setDefaultDropAction(Qt.DropAction.CopyAction) + + def startDrag(self, supported_actions): + item = self.currentItem() + if not item: + return + song = item.data(Qt.ItemDataRole.UserRole) + if not song: + return + + import json + data = json.dumps(song).encode("utf-8") + + mime = QMimeData() + mime.setData("application/x-linedance-song", QByteArray(data)) + mime.setText(song.get("title", "")) + + drag = QDrag(self) + drag.setMimeData(mime) + drag.exec(Qt.DropAction.CopyAction) + + +class LibraryPanel(QWidget): + song_selected = pyqtSignal(dict) + add_to_playlist = pyqtSignal(dict) + scan_requested = pyqtSignal() + edit_tags_requested = pyqtSignal(dict) + send_mail_requested = pyqtSignal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + self._all_songs: list[dict] = [] + self._filtered: list[dict] = [] + self._bpm_scan_running = False + self._search_timer = QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(150) + self._search_timer.timeout.connect(self._do_search) + self._build_ui() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Header + header = QHBoxLayout() + header.setContentsMargins(10, 6, 10, 6) + lbl = QLabel("BIBLIOTEK") + lbl.setObjectName("section_title") + header.addWidget(lbl) + header.addStretch() + + self._btn_bpm_scan = QPushButton("♩ BPM alle") + self._btn_bpm_scan.setFixedHeight(24) + self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)") + self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan) + header.addWidget(self._btn_bpm_scan) + + btn_manage = QPushButton("⚙ Mapper") + btn_manage.setFixedHeight(24) + btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker") + btn_manage.clicked.connect(self._manage_libraries) + header.addWidget(btn_manage) + layout.addLayout(header) + + # Scan status + self._scan_bar = QProgressBar() + self._scan_bar.setObjectName("scan_bar") + self._scan_bar.setTextVisible(True) + self._scan_bar.setFormat("Scanner...") + self._scan_bar.setFixedHeight(16) + self._scan_bar.setRange(0, 0) + self._scan_bar.hide() + layout.addWidget(self._scan_bar) + + self._scan_label = QLabel("") + self._scan_label.setObjectName("result_count") + self._scan_label.hide() + layout.addWidget(self._scan_label) + + # Søgefelt + self._search = QLineEdit() + self._search.setPlaceholderText("Søg i titel, artist, album, dans...") + self._search.textChanged.connect(self._on_search_changed) + layout.addWidget(self._search) + + # Resultat-tæller + drag-hint + hint_row = QHBoxLayout() + hint_row.setContentsMargins(8, 2, 8, 2) + self._count_label = QLabel("0 sange") + self._count_label.setObjectName("result_count") + hint_row.addWidget(self._count_label) + hint_row.addStretch() + drag_hint = QLabel("træk til danseliste →") + drag_hint.setObjectName("result_count") + hint_row.addWidget(drag_hint) + layout.addLayout(hint_row) + + # Liste — draggable + self._list = DraggableLibraryList() + self._list.setObjectName("library_list") + self._list.itemDoubleClicked.connect(self._on_double_click) + self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._list.customContextMenuRequested.connect(self._show_context_menu) + layout.addWidget(self._list) + + # ── Scanning ────────────────────────────────────────────────────────────── + + def _on_scan_clicked(self): + self.scan_requested.emit() + + def set_scanning(self, scanning: bool, status_text: str = ""): + if scanning: + self._scan_bar.show() + self._scan_label.setText(status_text or "Starter...") + self._scan_label.show() + else: + self._scan_bar.hide() + self._scan_label.hide() + + def update_scan_status(self, text: str): + self._scan_label.setText(text) + + # ── Sange ───────────────────────────────────────────────────────────────── + + def load_songs(self, songs: list[dict]): + self._all_songs = songs + self._do_search() + + # ── Søgning ─────────────────────────────────────────────────────────────── + + def _on_search_changed(self): + self._search_timer.start() + + def _do_search(self): + q = self._search.text().strip().lower() + self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs) + total = len(self._all_songs) + found = len(self._filtered) + q_text = self._search.text().strip() + self._count_label.setText( + f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text + else f"{total} sang{'e' if total != 1 else ''}" + ) + self._render() + + def _matches(self, song: dict, q: str) -> bool: + return any(q in f.lower() for f in [ + song.get("title", ""), song.get("artist", ""), + song.get("album", ""), song.get("file_format", ""), + ] + song.get("dances", [])) + + def _render(self): + self._list.clear() + q = self._search.text().strip().lower() + for song in self._filtered: + dances = song.get("dances", []) + dance_levels = song.get("dance_levels", []) + missing = song.get("file_missing", False) + + dance_parts = [] + for i, d in enumerate(dances): + lvl = dance_levels[i] if i < len(dance_levels) else "" + dance_parts.append(f"{d} / {lvl}" if lvl else d) + dance_str = " · " + " | ".join(dance_parts) if dance_parts else "" + + line1 = ("⚠ " if missing else "") + song.get("title", "—") + bpm = song.get("bpm", 0) + bpm_str = f"{bpm} BPM" if bpm else "? BPM" + line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}" + + row_widget = QWidget() + row_widget.setStyleSheet("background: transparent;") + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(2, 2, 2, 2) + row_layout.setSpacing(8) + + lbl = QLabel(f"{line1}\n{line2}") + lbl.setWordWrap(False) + row_layout.addWidget(lbl, stretch=1) + + btn_danse = QPushButton("Danse") + btn_danse.setFixedHeight(30) + btn_danse.setFixedWidth(70) + btn_danse.setToolTip("Rediger dans-tags") + btn_danse.setStyleSheet( + "QPushButton { background: #e8a020; color: #111; border-radius: 4px; " + "font-weight: bold; font-size: 12px; border: none; }" + "QPushButton:hover { background: #f0b030; }" + ) + btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s)) + row_layout.addWidget(btn_danse) + + item = QListWidgetItem() + item.setData(Qt.ItemDataRole.UserRole, song) + row_widget.adjustSize() + hint = row_widget.sizeHint() + hint.setHeight(max(hint.height(), 52)) + item.setSizeHint(hint) + self._list.addItem(item) + self._list.setItemWidget(item, row_widget) + + def _start_bulk_bpm_scan(self): + """Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet.""" + if self._bpm_scan_running: + return + songs_without_bpm = [s for s in self._all_songs + if not s.get("bpm") and not s.get("file_missing")] + if not songs_without_bpm: + self._btn_bpm_scan.setText("♩ Alle har BPM") + return + + self._bpm_scan_running = True + self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...") + self._btn_bpm_scan.setEnabled(False) + + from PyQt6.QtCore import QThread, pyqtSignal as _sig + + class BulkBpmWorker(QThread): + progress = _sig(int, int, str) # done, total, title + finished = _sig() + + def __init__(self, songs): + super().__init__() + self._songs = songs + + def run(self): + from local.tag_reader import analyze_and_save_bpm + total = len(self._songs) + for i, song in enumerate(self._songs, start=1): + if self.isInterruptionRequested(): + break + try: + bpm = analyze_and_save_bpm(song["local_path"], song["id"]) + if bpm: + song["bpm"] = int(round(bpm)) + except Exception: + pass + self.progress.emit(i, total, song.get("title", "")) + self.finished.emit() + + self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm) + + def on_progress(done, total, title): + self._btn_bpm_scan.setText(f"♩ {done}/{total}...") + # Opdater sangen i listen + for s in self._all_songs: + if s.get("title") == title and s.get("bpm"): + break + self._do_search() + + def on_finished(): + self._bpm_scan_running = False + self._btn_bpm_scan.setEnabled(True) + self._btn_bpm_scan.setText("♩ BPM alle") + self._do_search() + + self._bulk_bpm_worker.progress.connect(on_progress) + self._bulk_bpm_worker.finished.connect(on_finished) + self._bulk_bpm_worker.start() + self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority) + + # ── Handlinger ──────────────────────────────────────────────────────────── + + def _on_double_click(self, item: QListWidgetItem): + song = item.data(Qt.ItemDataRole.UserRole) + if song: + self.song_selected.emit(song) + + def _show_context_menu(self, pos): + from PyQt6.QtWidgets import QMenu + item = self._list.itemAt(pos) + if not item: + return + song = item.data(Qt.ItemDataRole.UserRole) + if not song: + return + menu = QMenu(self) + act_add = menu.addAction("Tilføj til danseliste") + act_play = menu.addAction("Afspil") + menu.addSeparator() + act_tags = menu.addAction("✎ Rediger dans-tags...") + act_bpm = menu.addAction("♩ Analysér BPM") + menu.addSeparator() + send_menu = menu.addMenu("Send til") + act_mail = send_menu.addAction("✉ Send som mail") + action = menu.exec(self._list.mapToGlobal(pos)) + if action == act_add: + self.add_to_playlist.emit(song) + elif action == act_play: + self.song_selected.emit(song) + elif action == act_tags: + self.edit_tags_requested.emit(song) + elif action == act_bpm: + self._analyze_bpm(song) + elif action == act_mail: + self.send_mail_requested.emit(song) + + def _analyze_bpm(self, song: dict): + """Analysér BPM i baggrundstråd og opdater biblioteket.""" + path = song.get("local_path", "") + song_id = song.get("id", "") + if not path or not song_id: + return + from PyQt6.QtCore import QThread, pyqtSignal as _sig + + class BpmWorker(QThread): + done = _sig(float) + def __init__(self, p, sid): + super().__init__() + self._p, self._sid = p, sid + def run(self): + from local.tag_reader import analyze_and_save_bpm + bpm = analyze_and_save_bpm(self._p, self._sid) + if bpm: + self.done.emit(bpm) + + self._bpm_worker = BpmWorker(path, song_id) + + def on_bpm_done(bpm): + # Opdater sangen i _all_songs listen direkte + for s in self._all_songs: + if s.get("id") == song_id: + s["bpm"] = int(round(bpm)) + break + self._do_search() + + self._bpm_worker.done.connect(on_bpm_done) + self._bpm_worker.start() + + def _manage_libraries(self): + from ui.library_manager import LibraryManagerDialog + dialog = LibraryManagerDialog(parent=self.window()) + dialog.library_removed.connect(lambda _: self.scan_requested.emit()) + dialog.exec() + + def _add_folder(self): + from PyQt6.QtWidgets import QFileDialog + folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") + if folder: + mw = self.window() + if hasattr(mw, "add_library_path"): + mw.add_library_path(folder) diff --git a/linedance-app/ui/login_dialog.py b/linedance-app/ui/login_dialog.py new file mode 100644 index 00000000..f87847b1 --- /dev/null +++ b/linedance-app/ui/login_dialog.py @@ -0,0 +1,139 @@ +""" +login_dialog.py — Login-dialog til at gå online. +Server-URL er hardcodet i config. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QFrame, QCheckBox, +) +from PyQt6.QtCore import Qt, QSettings + +# ── Hardcodet server-URL ────────────────────────────────────────────────────── +API_URL = "http://din-server:8000" +# ───────────────────────────────────────────────────────────────────────────── + + +class LoginDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Gå online") + self.setFixedWidth(340) + self.setModal(True) + + self._token: str | None = None + self._username: str | None = None + self._api_url = API_URL + + self._build_ui() + self._load_saved_settings() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(10) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("Log ind på LineDance") + title.setObjectName("track_title") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title) + + sub = QLabel("Synkroniser projekter og alternativ-danse med andre brugere") + sub.setObjectName("track_meta") + sub.setWordWrap(True) + sub.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(sub) + + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + layout.addWidget(line) + + layout.addWidget(QLabel("Brugernavn:")) + self._user_input = QLineEdit() + self._user_input.setPlaceholderText("dit-brugernavn") + layout.addWidget(self._user_input) + + layout.addWidget(QLabel("Kodeord:")) + self._pass_input = QLineEdit() + self._pass_input.setEchoMode(QLineEdit.EchoMode.Password) + self._pass_input.setPlaceholderText("••••••••") + self._pass_input.returnPressed.connect(self._on_login) + layout.addWidget(self._pass_input) + + self._remember = QCheckBox("Husk brugernavn") + self._remember.setChecked(True) + layout.addWidget(self._remember) + + self._status_label = QLabel("") + self._status_label.setObjectName("track_meta") + self._status_label.setWordWrap(True) + layout.addWidget(self._status_label) + + btn_row = QHBoxLayout() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + + self._btn_login = QPushButton("Log ind") + self._btn_login.setObjectName("btn_play") + self._btn_login.setDefault(True) + self._btn_login.clicked.connect(self._on_login) + btn_row.addWidget(self._btn_login) + + layout.addLayout(btn_row) + + def _load_saved_settings(self): + settings = QSettings("LineDance", "Player") + self._user_input.setText(settings.value("username", "")) + + def _save_settings(self): + if self._remember.isChecked(): + settings = QSettings("LineDance", "Player") + settings.setValue("username", self._user_input.text().strip()) + + def _on_login(self): + username = self._user_input.text().strip() + password = self._pass_input.text() + + if not username or not password: + self._set_status("Udfyld brugernavn og kodeord", error=True) + return + + self._btn_login.setEnabled(False) + self._set_status("Forbinder...") + + try: + import urllib.request, urllib.parse, json + + data = urllib.parse.urlencode({ + "username": username, + "password": password, + }).encode() + + req = urllib.request.Request( + f"{API_URL}/auth/login", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=8) as resp: + body = json.loads(resp.read()) + self._token = body.get("access_token") + self._username = username + + self._save_settings() + self._set_status("Logget ind!", error=False) + self.accept() + + except Exception as e: + self._set_status(f"Fejl: {e}", error=True) + self._btn_login.setEnabled(True) + + def _set_status(self, text: str, error: bool = False): + self._status_label.setText(text) + color = "#e74c3c" if error else "#2ecc71" + self._status_label.setStyleSheet(f"color: {color};") + + def get_credentials(self) -> tuple[str, str, str]: + """Returnerer (api_url, username, token) efter succesfuldt login.""" + return self._api_url, self._username, self._token diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py new file mode 100644 index 00000000..96e72101 --- /dev/null +++ b/linedance-app/ui/main_window.py @@ -0,0 +1,943 @@ +""" +main_window.py — Linedance afspiller hovedvindue. +""" + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QSlider, QLabel, QFrame, QSplitter, + QSizePolicy, QMenuBar, QMenu, QStatusBar, QFileDialog, + QMessageBox, +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QAction + +from ui.vu_meter import VUMeter +from ui.playlist_panel import PlaylistPanel +from ui.library_panel import LibraryPanel +from ui.themes import apply_theme +from ui.scan_worker import ScanWorker +from ui.login_dialog import LoginDialog, API_URL +from ui.playlist_manager import PlaylistManagerDialog +from ui.settings_dialog import SettingsDialog, load_settings +from player.player import Player + + +class ProgressBar(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._fraction = 0.0 + self._demo_fraction = 0.0 # hvor musikken stopper (blå) + self._demo_fade_fraction = 0.0 # hvor fade slutter (grå) + self.setFixedHeight(10) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def set_fraction(self, f: float): + self._fraction = max(0.0, min(1.0, f)) + self.update() + + def set_demo_marker(self, demo_f: float, fade_f: float = 0.0): + self._demo_fraction = max(0.0, min(1.0, demo_f)) + self._demo_fade_fraction = max(0.0, min(1.0, fade_f)) + self.update() + + def paintEvent(self, event): + from PyQt6.QtGui import QPainter, QColor + p = QPainter(self) + w, h = self.width(), self.height() + p.fillRect(0, 0, w, h, QColor("#2c3038")) + fill_w = int(w * self._fraction) + if fill_w > 0: + p.fillRect(0, 0, fill_w, h, QColor("#e8a020")) + # Fade-slut markør (grå) — vises bag demo-markøren + if self._demo_fade_fraction > 0: + fx = int(w * self._demo_fade_fraction) + p.fillRect(fx - 1, 0, 2, h, QColor("#6a7080")) + # Demo-stop markør (blå) + if self._demo_fraction > 0: + mx = int(w * self._demo_fraction) + p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4")) + p.end() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + fraction = event.position().x() / self.width() + mw = self.window() + if hasattr(mw, "_on_seek"): + mw._on_seek(fraction) + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("LineDance Player") + self.setMinimumSize(1000, 680) + self.resize(1600, 820) + + self._dark_theme = True + self._player = Player(self) + self._current_idx = -1 + self._song_ended = False + self._demo_active = False + self._watcher = None + self._scan_worker = None + self._api_url: str | None = None + self._api_token: str | None = None + self._api_username: str | None = None + + # Indlæs indstillinger + self._settings = load_settings() + self._dark_theme = self._settings.get("dark_theme", True) + self._demo_seconds = self._settings.get("demo_seconds", 10) + self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5) + + self._connect_player_signals() + self._build_menu() + self._build_ui() + self._build_statusbar() + apply_theme(self._app_ref(), dark=self._dark_theme) + self._theme_btn.setText("☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA") + + # Gendan gemt vinduestørrelse og splitter-position + self._restore_window_state() + + # Start DB og scanning ved opstart + QTimer.singleShot(200, self._init_local_db) + + # Auto-login hvis aktiveret i indstillinger + if self._settings.get("auto_login") and self._settings.get("password"): + QTimer.singleShot(800, self._auto_login) + + def _app_ref(self): + from PyQt6.QtWidgets import QApplication + return QApplication.instance() + + def _connect_player_signals(self): + self._player.position_changed.connect(self._on_position) + self._player.time_changed.connect(self._on_time) + self._player.levels_changed.connect(self._on_levels) + self._player.song_ended.connect(self._on_song_ended) + self._player.state_changed.connect(self._on_state_changed) + + # ── Menu ────────────────────────────────────────────────────────────────── + + def _build_menu(self): + menubar = self.menuBar() + + # ── Filer ───────────────────────────────────────────────────────────── + file_menu = menubar.addMenu("Filer") + + self._act_go_online = QAction("Gå online...", self) + self._act_go_online.setShortcut("Ctrl+L") + self._act_go_online.triggered.connect(self._go_online) + file_menu.addAction(self._act_go_online) + + self._act_go_offline = QAction("Gå offline", self) + self._act_go_offline.triggered.connect(self._go_offline) + self._act_go_offline.setEnabled(False) + file_menu.addAction(self._act_go_offline) + + file_menu.addSeparator() + + act_settings = QAction("Indstillinger...", self) + act_settings.setShortcut("Ctrl+,") + act_settings.triggered.connect(self._open_settings) + file_menu.addAction(act_settings) + + file_menu.addSeparator() + + act_quit = QAction("Afslut", self) + act_quit.setShortcut("Ctrl+Q") + act_quit.triggered.connect(self.close) + file_menu.addAction(act_quit) + + # ── Ingen Danseliste- eller Visning-menu ────────────────────────────── + # Ny/Gem/Hent ligger direkte i danseliste-panelet + # Tema-skift ligger i topbar-knappen + # Mapper og scan ligger i ⚙ Mapper dialogen + + # Gem reference til scan-action (bruges stadig internt) + self._act_scan = QAction("Scan", self) + self._act_scan.triggered.connect(self.start_scan) + + # ── Statuslinje ─────────────────────────────────────────────────────────── + + def _build_statusbar(self): + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._statusbar.showMessage("Klar") + + def _set_status(self, text: str, timeout_ms: int = 0): + """Vis besked i statuslinjen. timeout_ms=0 = permanent.""" + self._statusbar.showMessage(text, timeout_ms) + + # ── UI byggeri ──────────────────────────────────────────────────────────── + + def _build_ui(self): + root = QWidget() + root.setObjectName("root") + self.setCentralWidget(root) + main_layout = QVBoxLayout(root) + main_layout.setContentsMargins(10, 6, 10, 10) + main_layout.setSpacing(4) + + main_layout.addWidget(self._build_topbar()) + main_layout.addWidget(self._build_now_playing()) + main_layout.addWidget(self._build_progress()) + main_layout.addWidget(self._build_transport()) + main_layout.addWidget(self._build_panels(), stretch=1) + + def _build_topbar(self) -> QFrame: + bar = QFrame() + bar.setObjectName("topbar") + layout = QHBoxLayout(bar) + layout.setContentsMargins(12, 6, 12, 6) + + logo = QLabel("LINEDANCE PLAYER") + logo.setObjectName("logo") + logo.setTextFormat(Qt.TextFormat.RichText) + layout.addWidget(logo) + layout.addStretch() + + self._conn_label = QLabel("● OFFLINE") + self._conn_label.setObjectName("conn_label") + layout.addWidget(self._conn_label) + + self._theme_btn = QPushButton("☀ LYS TEMA") + self._theme_btn.setFixedHeight(26) + self._theme_btn.clicked.connect(self._toggle_theme) + layout.addWidget(self._theme_btn) + + return bar + + def _build_now_playing(self) -> QFrame: + frame = QFrame() + frame.setObjectName("now_playing_frame") + layout = QHBoxLayout(frame) + layout.setContentsMargins(12, 10, 12, 10) + + track_frame = QFrame() + track_frame.setObjectName("track_display") + track_layout = QVBoxLayout(track_frame) + track_layout.setContentsMargins(10, 8, 10, 8) + track_layout.setSpacing(3) + + self._lbl_title = QLabel("—") + self._lbl_title.setObjectName("track_title") + track_layout.addWidget(self._lbl_title) + + self._lbl_meta = QLabel("—") + self._lbl_meta.setObjectName("track_meta") + track_layout.addWidget(self._lbl_meta) + + self._lbl_dances = QLabel("") + self._lbl_dances.setObjectName("track_meta") + self._lbl_dances.setWordWrap(True) + track_layout.addWidget(self._lbl_dances) + + layout.addWidget(track_frame, stretch=1) + + self._vu = VUMeter() + layout.addWidget(self._vu) + + return frame + + def _build_progress(self) -> QFrame: + frame = QFrame() + frame.setObjectName("progress_frame") + layout = QHBoxLayout(frame) + layout.setContentsMargins(12, 6, 12, 6) + layout.setSpacing(8) + + self._lbl_cur = QLabel("0:00") + self._lbl_cur.setObjectName("track_meta") + self._lbl_cur.setFixedWidth(36) + layout.addWidget(self._lbl_cur) + + self._progress = ProgressBar(self) + self._progress.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + layout.addWidget(self._progress, stretch=1) + + self._lbl_tot = QLabel("0:00") + self._lbl_tot.setObjectName("track_meta") + self._lbl_tot.setFixedWidth(36) + self._lbl_tot.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(self._lbl_tot) + + return frame + + def _build_transport(self) -> QFrame: + frame = QFrame() + frame.setObjectName("transport_frame") + layout = QHBoxLayout(frame) + layout.setContentsMargins(14, 10, 14, 10) + layout.setSpacing(8) + + def btn(text, name=None, size=52, checkable=False): + b = QPushButton(text) + if name: + b.setObjectName(name) + b.setFixedSize(size, size) + if checkable: + b.setCheckable(True) + return b + + self._btn_prev = btn("⏮", size=52) + self._btn_play = btn("▶", "btn_play", size=72) + self._btn_stop = btn("⏹", "btn_stop", size=52) + self._btn_next = btn("⏭", size=52) + self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True) + + self._btn_prev.clicked.connect(self._prev_song) + self._btn_play.clicked.connect(self._toggle_play) + self._btn_stop.clicked.connect(self._stop) + self._btn_next.clicked.connect(self._next_song) + self._btn_demo.clicked.connect(self._toggle_demo) + + layout.addWidget(self._btn_prev) + layout.addWidget(self._btn_play) + layout.addWidget(self._btn_stop) + layout.addWidget(self._btn_next) + + sep1 = QFrame() + sep1.setFrameShape(QFrame.Shape.VLine) + sep1.setFixedWidth(1) + layout.addWidget(sep1) + + layout.addWidget(self._btn_demo) + layout.addStretch() + + lbl_vol = QLabel("VOL") + lbl_vol.setObjectName("vol_label") + layout.addWidget(lbl_vol) + + self._vol_slider = QSlider(Qt.Orientation.Horizontal) + self._vol_slider.setRange(0, 100) + self._vol_slider.setValue(self._settings.get("volume", 78)) + self._vol_slider.setFixedWidth(100) + self._vol_slider.valueChanged.connect(self._on_volume) + layout.addWidget(self._vol_slider) + + self._lbl_vol = QLabel(str(self._settings.get("volume", 78))) + self._lbl_vol.setObjectName("vol_val") + layout.addWidget(self._lbl_vol) + + return frame + + def _build_panels(self) -> QSplitter: + self._splitter = QSplitter(Qt.Orientation.Horizontal) + + self._playlist_panel = PlaylistPanel() + self._playlist_panel.song_selected.connect(self._load_song_by_idx) + self._playlist_panel.song_dropped.connect(self._on_song_dropped) + self._playlist_panel.event_started.connect(self._on_event_started) + self._playlist_panel.next_song_ready.connect(self._load_song) + + self._library_panel = LibraryPanel() + self._library_panel.song_selected.connect(self._on_library_song_selected) + self._library_panel.add_to_playlist.connect(self._add_song_to_playlist) + self._library_panel.scan_requested.connect(self.start_scan) + self._library_panel.edit_tags_requested.connect(self._open_tag_editor) + self._library_panel.send_mail_requested.connect(self._send_mail) + + self._splitter.addWidget(self._playlist_panel) + self._splitter.addWidget(self._library_panel) + self._splitter.setSizes([700, 900]) + + return self._splitter + + def _restore_window_state(self): + from PyQt6.QtCore import QSettings, QByteArray + settings = QSettings("LineDance", "Player") + geom = settings.value("window/geometry") + if geom: + self.restoreGeometry(geom) + splitter_state = settings.value("window/splitter") + if splitter_state and hasattr(self, "_splitter"): + self._splitter.restoreState(splitter_state) + + def _save_window_state(self): + from PyQt6.QtCore import QSettings + settings = QSettings("LineDance", "Player") + settings.setValue("window/geometry", self.saveGeometry()) + if hasattr(self, "_splitter"): + settings.setValue("window/splitter", self._splitter.saveState()) + + # ── Lokal DB + scanning ─────────────────────────────────────────────────── + + def _init_local_db(self): + try: + import sys, os + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + from local.local_db import init_db + from local.file_watcher import get_watcher + + init_db() + + # Brug et Qt signal til thread-safe reload fra watcher-tråden + from PyQt6.QtCore import QMetaObject, Q_ARG + def on_file_change(event_type, path, song_id): + QTimer.singleShot(0, self._reload_library) + + self._watcher = get_watcher(on_change=on_file_change) + self._watcher.start() + + # Indlæs hvad vi allerede kender fra SQLite + self._reload_library() + + # Gendan sidst aktive danseliste + restored = self._playlist_panel.restore_active_playlist() + + # Gendan event-fremgang hvis liste blev gendannet + if restored: + if self._playlist_panel.restore_event_state(): + # Indlæs den sang vi var nået til + idx = self._playlist_panel._current_idx + song = self._playlist_panel.get_song(idx) + if song: + self._current_idx = idx + self._load_song(song) + self._set_status( + f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte", + 6000, + ) + + # Kør automatisk scanning ved opstart + self._set_status("Starter scanning af biblioteker...") + QTimer.singleShot(100, self.start_scan) + + except Exception as e: + self._set_status(f"DB fejl: {e}") + pass + + def start_scan(self): + """Start fuld scanning af alle biblioteker i baggrundstråd.""" + if self._scan_worker and self._scan_worker.isRunning(): + return # Scanning kører allerede + + if not self._watcher: + self._set_status("Ingen biblioteker at scanne — tilføj en mappe først") + return + + self._library_panel.set_scanning(True, "Forbereder scanning...") + self._act_scan.setEnabled(False) + + self._scan_worker = ScanWorker(self._watcher, parent=self) + self._scan_worker.status_update.connect(self._on_scan_status) + self._scan_worker.scan_done.connect(self._on_scan_done) + self._scan_worker.start() + + def _on_scan_status(self, text: str): + self._set_status(text) + self._library_panel.update_scan_status(text) + + def _on_scan_done(self, count: int): + self._library_panel.set_scanning(False) + self._act_scan.setEnabled(True) + msg = f"Scanning færdig — {count} filer gennemgået" + self._set_status(msg, timeout_ms=5000) + # Genindlæs biblioteket + QTimer.singleShot(200, self._reload_library) + + def _reload_library(self): + try: + from local.local_db import search_songs, get_db + songs_raw = search_songs("", limit=5000) + songs = [] + for row in songs_raw: + with get_db() as conn: + dances_raw = conn.execute(""" + SELECT d.name, dl.name as level_name + FROM song_dances sd + JOIN dances d ON d.id = sd.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE sd.song_id=? ORDER BY sd.dance_order + """, (row["id"],)).fetchall() + songs.append({ + "id": row["id"], + "title": row["title"], + "artist": row["artist"], + "album": row["album"], + "bpm": row["bpm"], + "duration_sec": row["duration_sec"], + "local_path": row["local_path"], + "file_format": row["file_format"], + "file_missing": bool(row["file_missing"]), + "dances": [d["name"] for d in dances_raw], + "dance_levels": [d["level_name"] or "" for d in dances_raw], + }) + self._library_panel.load_songs(songs) + count = len(songs) + self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000) + except Exception as e: + pass + + def add_library_path(self, path: str): + try: + if not self._watcher: + self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000) + return + self._watcher.add_library(path) + self._set_status(f"Tilføjet: {path} — scanner...") + # Genindlæs bibliotekslisten og start scan + QTimer.singleShot(500, self._reload_library) + QTimer.singleShot(1000, self.start_scan) + except Exception as e: + self._set_status(f"Fejl ved tilføjelse: {e}") + + def _open_settings(self): + dialog = SettingsDialog(parent=self) + if dialog.exec(): + self._settings = dialog.get_values() + self._demo_seconds = self._settings.get("demo_seconds", 10) + self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5) + # Opdater tema hvis ændret + new_dark = self._settings.get("dark_theme", True) + if new_dark != self._dark_theme: + self._dark_theme = new_dark + apply_theme(self._app_ref(), dark=self._dark_theme) + self._theme_btn.setText( + "☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA" + ) + self._vu.set_dark(self._dark_theme) + # Opdater demo-knap tekst + self._btn_demo.setText(f"▶\n{self._demo_seconds} SEK") + # Opdater demo-markør hvis en sang er indlæst + if hasattr(self, "_current_song") and self._current_song: + dur = self._current_song.get("duration_sec", 0) + if dur > 0: + self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0)) + self._set_status("Indstillinger gemt", 2000) + + def _auto_login(self): + """Forsøg automatisk login med gemte oplysninger.""" + username = self._settings.get("username", "") + password = self._settings.get("password", "") + if not username or not password: + return + try: + import urllib.request, urllib.parse, json + data = urllib.parse.urlencode({"username": username, "password": password}).encode() + req = urllib.request.Request( + f"{API_URL}/auth/login", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=8) as resp: + body = json.loads(resp.read()) + self._api_token = body.get("access_token") + self._api_url = API_URL + self._api_username = username + self._set_online_state(True) + self._set_status(f"Automatisk logget ind som {username}", 4000) + # Synkroniser dans-niveauer og navne + QTimer.singleShot(500, self._sync_dance_data) + except Exception: + self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000) + + def _go_online(self): + dialog = LoginDialog(self) + if dialog.exec(): + url, username, token = dialog.get_credentials() + self._api_url = url + self._api_token = token + self._api_username = username + self._set_online_state(True) + self._set_status(f"Online som {username}", 5000) + QTimer.singleShot(500, self._sync_dance_data) + + def _sync_dance_data(self): + """Synkroniser dans-niveauer og navne fra API.""" + if not self._api_token: + return + try: + import urllib.request, json + headers = {"Authorization": f"Bearer {self._api_token}"} + + # Hent niveauer + req = urllib.request.Request(f"{API_URL}/dances/levels", headers=headers) + with urllib.request.urlopen(req, timeout=8) as resp: + levels = json.loads(resp.read()) + from local.local_db import sync_dance_levels_from_api + sync_dance_levels_from_api(levels) + + # Hent populære dans-navne + req = urllib.request.Request(f"{API_URL}/dances/names?limit=500", headers=headers) + with urllib.request.urlopen(req, timeout=8) as resp: + names = json.loads(resp.read()) + from local.local_db import sync_dance_names_from_api + sync_dance_names_from_api(names) + + self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000) + except Exception as e: + pass + + def _go_offline(self): + self._api_url = self._api_token = self._api_username = None + self._set_online_state(False) + self._set_status("Offline — arbejder lokalt", 3000) + + def _set_online_state(self, online: bool): + self._act_go_online.setEnabled(not online) + self._act_go_offline.setEnabled(online) + if online: + name = self._api_username or "?" + self._conn_label.setText(f"● ONLINE ({name})") + self._conn_label.setStyleSheet("color: #2ecc71;") + else: + self._conn_label.setText("● OFFLINE") + self._conn_label.setStyleSheet("color: #5a6070;") + + def _new_playlist(self): + self._stop() + self._playlist_panel.load_songs([]) + self._playlist_panel.set_playlist_name("Ny liste") + self._set_status("Ny danseliste oprettet", 2000) + + def _open_playlist_manager(self): + dialog = PlaylistManagerDialog( + current_songs=self._playlist_panel.get_songs(), + parent=self, + ) + dialog.playlist_loaded.connect(self._on_playlist_loaded) + dialog.exec() + + def _on_playlist_loaded(self, name: str, songs: list[dict]): + self._stop() + self._playlist_panel.load_songs(songs) + self._playlist_panel.set_playlist_name(name) + self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000) + + def _open_tag_editor(self, song: dict): + from ui.tag_editor import TagEditorDialog + dialog = TagEditorDialog(song, parent=self) + if dialog.exec(): + # Genindlæs biblioteket så ændringer vises + QTimer.singleShot(200, self._reload_library) + + def _send_mail(self, song: dict): + import subprocess, sys, shutil, urllib.parse + from pathlib import Path + + path = song.get("local_path", "") + title = song.get("title", "") + artist = song.get("artist", "") + + if not path or not Path(path).exists(): + self._set_status("Filen blev ikke fundet — kan ikke sende mail", 4000) + return + + # ── Auto-detekter mailklient ─────────────────────────────────────────── + + def try_thunderbird() -> bool: + """Thunderbird: thunderbird -compose attachment='file:///sti'""" + candidates = [] + if sys.platform == "win32": + import winreg + for base in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + try: + key = winreg.OpenKey(base, + r"SOFTWARE\Mozilla\Mozilla Thunderbird") + inst, _ = winreg.QueryValueEx(key, "Install Directory") + candidates.append(str(Path(inst) / "thunderbird.exe")) + except Exception: + pass + candidates += [ + r"C:\Program Files\Mozilla Thunderbird\thunderbird.exe", + r"C:\Program Files (x86)\Mozilla Thunderbird\thunderbird.exe", + ] + elif sys.platform == "darwin": + candidates = [ + "/Applications/Thunderbird.app/Contents/MacOS/thunderbird", + ] + else: + candidates = [shutil.which("thunderbird") or "", + "/usr/bin/thunderbird", + "/usr/local/bin/thunderbird", + "/snap/bin/thunderbird"] + + tb = next((c for c in candidates if c and Path(c).exists()), None) + if not tb: + return False + + file_uri = Path(path).as_uri() + subject = f"Linedance sang: {title} — {artist}" + compose = ( + f"subject='{subject}'," + f"attachment='{file_uri}'" + ) + subprocess.Popen([tb, "-compose", compose]) + return True + + def try_outlook() -> bool: + """Outlook: outlook.exe /a 'filsti' (kun Windows)""" + if sys.platform != "win32": + return False + candidates = [ + shutil.which("outlook") or "", + r"C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE", + r"C:\Program Files (x86)\Microsoft Office\root\Office16\OUTLOOK.EXE", + r"C:\Program Files\Microsoft Office\Office16\OUTLOOK.EXE", + ] + ol = next((c for c in candidates if c and Path(c).exists()), None) + if not ol: + return False + subprocess.Popen([ol, "/a", path]) + return True + + def fallback_mailto(): + """Ingen vedhæftning — åbn standard-mailprogram via mailto:""" + subject = urllib.parse.quote(f"Linedance sang: {title} — {artist}") + body = urllib.parse.quote( + f"Sang: {title}\nArtist: {artist}\nFil: {path}\n\n" + f"(Vedhæft filen manuelt fra ovenstående sti)" + ) + mailto = f"mailto:?subject={subject}&body={body}" + if sys.platform == "win32": + import os; os.startfile(mailto) + elif sys.platform == "darwin": + subprocess.Popen(["open", mailto]) + else: + subprocess.Popen(["xdg-open", mailto]) + + # ── Prøv i rækkefølge ───────────────────────────────────────────────── + if try_thunderbird(): + self._set_status(f"Thunderbird åbnet med {Path(path).name} vedh.", 4000) + elif try_outlook(): + self._set_status(f"Outlook åbnet med {Path(path).name} vedh.", 4000) + else: + fallback_mailto() + self._set_status( + f"Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", 5000 + ) + + def _on_event_started(self): + """Start event — indlæs første sang i afspilleren klar til afspilning.""" + first = self._playlist_panel.get_song(0) + if not first: + return + self._stop() + self._current_idx = 0 + self._song_ended = False + self._load_song(first) + self._set_status("Event klar — tryk ▶ for at starte", 5000) + + def _on_song_dropped(self, song: dict): + self._set_status(f"Tilføjet: {song.get('title','')}", 2000) + + def _menu_add_folder(self): + folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") + if folder: + self.add_library_path(folder) + + # ── Afspilning ──────────────────────────────────────────────────────────── + + def _load_song(self, song: dict): + self._current_song = song + self._song_ended = False + self._demo_active = False + self._btn_demo.setChecked(False) + + dur = song.get("duration_sec", 0) + self._player.load(song.get("local_path", ""), dur) + + self._lbl_title.setText(song.get("title", "—")) + bpm = song.get("bpm", 0) + fmt_dur = f"{dur//60}:{dur%60:02d}" + self._lbl_meta.setText(f"{song.get('artist','')} · {bpm} BPM · {fmt_dur}") + + dances = song.get("dances", []) + self._lbl_dances.setText( + " · ".join(f"[{d}]" for d in dances) if dances else "ingen danse tagget" + ) + + if dur > 0: + self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0)) + + self._set_status(f"Indlæst: {song.get('title','—')}", 3000) + + def _load_song_by_idx(self, idx: int): + song = self._playlist_panel.get_song(idx) + if not song: + return + self._current_idx = idx + self._load_song(song) + self._playlist_panel.set_current(idx) + + def _toggle_play(self): + if self._demo_active: + self._player.stop() + self._demo_active = False + self._btn_demo.setChecked(False) + self._btn_play.setText("▶") + return + if self._player.is_playing(): + self._player.pause() + else: + self._song_ended = False + self._player.play() + self._btn_play.setText("⏸") + + def _stop(self): + self._player.stop() + self._song_ended = False + self._demo_active = False + self._btn_demo.setChecked(False) + self._btn_play.setText("▶") + self._vu.reset() + + def _toggle_demo(self): + if self._demo_active: + self._player.stop() + self._demo_active = False + self._btn_demo.setChecked(False) + self._btn_play.setText("▶") + else: + self._demo_active = True + self._btn_demo.setChecked(True) + self._player.play_demo( + stop_at_sec=self._demo_seconds, + fade_sec=self._demo_fade_seconds, + ) + self._btn_play.setText("⏸") + + def _prev_song(self): + if self._current_idx > 0: + self._stop() + self._load_song_by_idx(self._current_idx - 1) + + def _next_song(self): + if self._current_idx < self._playlist_panel.count() - 1: + self._stop() + self._playlist_panel.mark_played(self._current_idx) + self._load_song_by_idx(self._current_idx + 1) + + def _play_next(self): + self._song_ended = False + self._player.play() + self._btn_play.setText("⏸") + + def _on_library_song_selected(self, song: dict): + self._load_song(song) + self._player.play() + self._btn_play.setText("⏸") + + def _add_song_to_playlist(self, song: dict): + songs = [self._playlist_panel.get_song(i) + for i in range(self._playlist_panel.count())] + songs = [s for s in songs if s] + songs.append(song) + self._playlist_panel.load_songs(songs) + self._set_status(f"Tilføjet til danseliste: {song.get('title','')}", 2000) + + # ── Player signals ──────────────────────────────────────────────────────── + + def _on_position(self, fraction: float): + self._progress.set_fraction(fraction) + + def _on_time(self, cur: int, tot: int): + self._lbl_cur.setText(f"{cur//60}:{cur%60:02d}") + self._lbl_tot.setText(f"{tot//60}:{tot%60:02d}") + + def _on_levels(self, left: float, right: float): + self._vu.set_levels(left, right) + + def _on_song_ended(self): + self._song_ended = True + self._demo_active = False + self._btn_demo.setChecked(False) + self._btn_play.setText("▶") + self._vu.reset() + + # Markér den afspillede sang + self._playlist_panel.mark_played(self._current_idx) + + # Synkroniser event-status til den gemte navngivne liste + self._sync_event_status_to_playlist() + + # Find første ikke-afspillede og ikke-skippede sang fra TOPPEN + ni = self._playlist_panel.next_playable_idx() + next_song = self._playlist_panel.get_song(ni) if ni is not None else None + if next_song: + self._current_idx = ni + self._playlist_panel.set_next_ready(ni) + self._load_song(next_song) + self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte") + else: + # Danseliste afsluttet — nulstil liste-markering og synkroniser + self._current_idx = -1 + self._playlist_panel._current_idx = -1 + self._playlist_panel._song_ended = False + self._playlist_panel._refresh() + self._sync_event_status_to_playlist() + self._lbl_title.setText("— Danseliste afsluttet —") + self._lbl_meta.setText("") + self._lbl_dances.setText("") + self._set_status("Danselisten er afsluttet") + + def _sync_event_status_to_playlist(self): + """Gem event-fremgang (afspillet/sprunget over) til den navngivne liste.""" + try: + pl_id = self._playlist_panel.get_named_playlist_id() + if not pl_id: + return + statuses = self._playlist_panel.get_statuses() + from local.local_db import get_db + with get_db() as conn: + for position, status in enumerate(statuses, start=1): + conn.execute( + "UPDATE playlist_songs SET status=? " + "WHERE playlist_id=? AND position=?", + (status, pl_id, position) + ) + except Exception as e: + pass + + def _on_state_changed(self, state: str): + if state == "playing": + self._btn_play.setText("⏸") + elif state in ("paused", "stopped"): + self._btn_play.setText("▶") + if state == "stopped" and not self._song_ended: + self._vu.reset() + elif state == "demo_ended": + self._demo_active = False + self._btn_demo.setChecked(False) + self._btn_play.setText("▶") + self._vu.reset() + + def _on_seek(self, fraction: float): + self._player.set_position(fraction) + + def _on_volume(self, value: int): + self._lbl_vol.setText(str(value)) + self._player.set_volume(value) + from ui.settings_dialog import save_settings + self._settings["volume"] = value + save_settings(self._settings) + + # ── Tema ────────────────────────────────────────────────────────────────── + + def _toggle_theme(self): + self._dark_theme = not self._dark_theme + apply_theme(self._app_ref(), dark=self._dark_theme) + self._theme_btn.setText( + "● MØRKT TEMA" if not self._dark_theme else "☀ LYS TEMA" + ) + self._vu.set_dark(self._dark_theme) + + # ── Luk ─────────────────────────────────────────────────────────────────── + + def closeEvent(self, event): + self._save_window_state() + self._player.stop() + if self._scan_worker and self._scan_worker.isRunning(): + self._scan_worker.quit() + self._scan_worker.wait(2000) + try: + if self._watcher: + self._watcher.stop() + except Exception: + pass + event.accept() diff --git a/linedance-app/ui/next_up_bar.py b/linedance-app/ui/next_up_bar.py new file mode 100644 index 00000000..345a7465 --- /dev/null +++ b/linedance-app/ui/next_up_bar.py @@ -0,0 +1,59 @@ +""" +next_up_bar.py — Banner der vises når en sang er færdig. +""" + +from PyQt6.QtWidgets import ( + QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, +) +from PyQt6.QtCore import pyqtSignal + + +class NextUpBar(QFrame): + play_next_clicked = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("next_up_frame") + self.hide() + self._build_ui() + + def _build_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 10, 16, 10) + + # Tekst + text_layout = QVBoxLayout() + text_layout.setSpacing(2) + + self._label = QLabel("NÆSTE SANG KLAR") + self._label.setObjectName("next_up_label") + text_layout.addWidget(self._label) + + self._title = QLabel("—") + self._title.setObjectName("next_up_title") + text_layout.addWidget(self._title) + + self._sub = QLabel("—") + self._sub.setObjectName("next_up_sub") + text_layout.addWidget(self._sub) + + layout.addLayout(text_layout) + layout.addStretch() + + # Knap + self._btn = QPushButton("▶ AFSPIL NÆSTE") + self._btn.setObjectName("btn_play_next") + self._btn.setFixedHeight(44) + self._btn.setMinimumWidth(160) + self._btn.clicked.connect(self.play_next_clicked.emit) + layout.addWidget(self._btn) + + def show_next(self, title: str, artist: str, dances: list[str]): + dance_str = "Dans: " + ", ".join(dances) if dances else "" + sub = f"{artist}{' · ' + dance_str if dance_str else ''}" + self._title.setText(title) + self._sub.setText(sub) + self.show() + + def hide_bar(self): + self.hide() diff --git a/linedance-app/ui/playlist_manager.py b/linedance-app/ui/playlist_manager.py new file mode 100644 index 00000000..bfab4021 --- /dev/null +++ b/linedance-app/ui/playlist_manager.py @@ -0,0 +1,324 @@ +""" +playlist_manager.py — Dialog til danseliste-administration. +Ny liste, gem, load og importer M3U/M3U8/tekst. +""" + +import os +from pathlib import Path +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, QFileDialog, + QMessageBox, QTabWidget, QWidget, QTextEdit, +) +from PyQt6.QtCore import Qt, pyqtSignal + + +class PlaylistManagerDialog(QDialog): + """ + Fanebaseret dialog med tre faner: + 1. Gem aktuel liste + 2. Indlæs gemt liste + 3. Importer fra fil (M3U / M3U8 / tekst) + """ + playlist_loaded = pyqtSignal(str, list) # (navn, liste af dict) + + def __init__(self, current_songs: list[dict], parent=None): + super().__init__(parent) + self.setWindowTitle("Danseliste-administration") + self.setMinimumWidth(500) + self.setMinimumHeight(460) + self._current_songs = current_songs + self._build_ui() + self._load_saved_playlists() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + + tabs = QTabWidget() + tabs.addTab(self._build_save_tab(), "💾 Gem liste") + tabs.addTab(self._build_load_tab(), "📂 Indlæs liste") + tabs.addTab(self._build_import_tab(), "📥 Importer") + layout.addWidget(tabs) + + btn_close = QPushButton("Luk") + btn_close.clicked.connect(self.accept) + row = QHBoxLayout() + row.addStretch() + row.addWidget(btn_close) + layout.addLayout(row) + + # ── Fane 1: Gem ─────────────────────────────────────────────────────────── + + def _build_save_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(10) + + layout.addWidget(QLabel(f"Aktuel liste har {len(self._current_songs)} sange.")) + + layout.addWidget(QLabel("Navn på danselisten:")) + self._save_name = QLineEdit() + self._save_name.setPlaceholderText("f.eks. Sommer Event 2025") + layout.addWidget(self._save_name) + + btn_save = QPushButton("💾 Gem") + btn_save.clicked.connect(self._save_playlist) + layout.addWidget(btn_save) + + self._save_status = QLabel("") + self._save_status.setObjectName("result_count") + layout.addWidget(self._save_status) + layout.addStretch() + return tab + + def _save_playlist(self): + name = self._save_name.text().strip() + if not name: + self._save_status.setText("Angiv et navn") + return + if not self._current_songs: + self._save_status.setText("Danselisten er tom") + return + try: + from local.local_db import create_playlist, add_song_to_playlist, get_db + pl_id = create_playlist(name) + for i, song in enumerate(self._current_songs, start=1): + add_song_to_playlist(pl_id, song["id"], position=i) + self._save_status.setText(f"✓ Gemt som \"{name}\"") + self._load_saved_playlists() + except Exception as e: + self._save_status.setText(f"Fejl: {e}") + + # ── Fane 2: Indlæs ──────────────────────────────────────────────────────── + + def _build_load_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + + layout.addWidget(QLabel("Gemte danselister:")) + self._pl_list = QListWidget() + self._pl_list.itemDoubleClicked.connect(self._load_selected) + layout.addWidget(self._pl_list) + + btn_row = QHBoxLayout() + btn_load = QPushButton("📂 Indlæs valgte") + btn_load.clicked.connect(self._load_selected_btn) + btn_delete = QPushButton("🗑 Slet valgte") + btn_delete.clicked.connect(self._delete_selected) + btn_row.addWidget(btn_load) + btn_row.addWidget(btn_delete) + layout.addLayout(btn_row) + + self._load_status = QLabel("") + self._load_status.setObjectName("result_count") + layout.addWidget(self._load_status) + return tab + + def _load_saved_playlists(self): + if not hasattr(self, "_pl_list"): + return + self._pl_list.clear() + try: + from local.local_db import get_playlists + for pl in get_playlists(): + item = QListWidgetItem(pl["name"]) + item.setData(Qt.ItemDataRole.UserRole, dict(pl)) + self._pl_list.addItem(item) + except Exception: + pass + + def _load_selected_btn(self): + item = self._pl_list.currentItem() + if item: + self._load_selected(item) + + def _load_selected(self, item: QListWidgetItem): + pl = item.data(Qt.ItemDataRole.UserRole) + if not pl: + return + try: + from local.local_db import get_playlist_with_songs, get_db + data = get_playlist_with_songs(pl["id"]) + songs = [] + for row in data.get("songs", []): + with get_db() as conn: + dances = conn.execute( + "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", + (row["id"],) + ).fetchall() + songs.append({ + "id": row["id"], + "title": row.get("title", ""), + "artist": row.get("artist", ""), + "album": row.get("album", ""), + "bpm": row.get("bpm", 0), + "duration_sec": row.get("duration_sec", 0), + "local_path": row.get("local_path", ""), + "file_format": row.get("file_format", ""), + "file_missing": bool(row.get("file_missing", False)), + "dances": [d["dance_name"] for d in dances], + }) + self.playlist_loaded.emit(pl["name"], songs) + self._load_status.setText(f"✓ Indlæst: {pl['name']} ({len(songs)} sange)") + except Exception as e: + self._load_status.setText(f"Fejl: {e}") + + def _delete_selected(self): + item = self._pl_list.currentItem() + if not item: + return + pl = item.data(Qt.ItemDataRole.UserRole) + reply = QMessageBox.question( + self, "Slet liste", + f"Slet danselisten \"{pl['name']}\"?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + try: + from local.local_db import get_db + with get_db() as conn: + conn.execute("DELETE FROM playlists WHERE id=?", (pl["id"],)) + self._load_saved_playlists() + except Exception as e: + self._load_status.setText(f"Fejl: {e}") + + # ── Fane 3: Importer ────────────────────────────────────────────────────── + + def _build_import_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(8) + + lbl = QLabel( + "Importer fra M3U, M3U8 eller en tekstfil med én filsti per linje.\n" + "Sange der ikke er i biblioteket forsøges tilføjet automatisk." + ) + lbl.setWordWrap(True) + lbl.setObjectName("result_count") + layout.addWidget(lbl) + + btn_browse = QPushButton("📂 Vælg fil...") + btn_browse.clicked.connect(self._browse_import) + layout.addWidget(btn_browse) + + layout.addWidget(QLabel("Eller indsæt filstier direkte (én per linje):")) + self._import_text = QTextEdit() + self._import_text.setPlaceholderText( + "/sti/til/sang1.mp3\n/sti/til/sang2.flac\n..." + ) + self._import_text.setMaximumHeight(120) + layout.addWidget(self._import_text) + + layout.addWidget(QLabel("Navn på den importerede liste:")) + self._import_name = QLineEdit() + self._import_name.setPlaceholderText("Importeret liste") + layout.addWidget(self._import_name) + + btn_import = QPushButton("📥 Importer") + btn_import.clicked.connect(self._do_import) + layout.addWidget(btn_import) + + self._import_status = QLabel("") + self._import_status.setObjectName("result_count") + self._import_status.setWordWrap(True) + layout.addWidget(self._import_status) + layout.addStretch() + return tab + + def _browse_import(self): + path, _ = QFileDialog.getOpenFileName( + self, "Vælg afspilningsliste", + filter="Afspilningslister (*.m3u *.m3u8 *.txt);;Alle filer (*)" + ) + if path: + self._import_name.setText(Path(path).stem) + paths = self._parse_playlist_file(path) + self._import_text.setPlainText("\n".join(paths)) + + def _parse_playlist_file(self, path: str) -> list[str]: + """Parser M3U, M3U8 og tekst — returnerer liste af filstier.""" + paths = [] + base_dir = str(Path(path).parent) + try: + enc = "utf-8-sig" if path.lower().endswith(".m3u8") else "latin-1" + with open(path, encoding=enc, errors="replace") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + # Gør relativ sti absolut + if not os.path.isabs(line): + line = os.path.join(base_dir, line) + paths.append(line) + except Exception as e: + self._import_status.setText(f"Læsefejl: {e}") + return paths + + def _do_import(self): + raw = self._import_text.toPlainText().strip() + if not raw: + self._import_status.setText("Ingen filstier angivet") + return + + name = self._import_name.text().strip() or "Importeret liste" + paths = [line.strip() for line in raw.splitlines() if line.strip()] + + found = [] + missing = [] + + try: + from local.local_db import get_song_by_path, upsert_song, get_db + from local.tag_reader import read_tags, is_supported + + for p in paths: + row = get_song_by_path(p) + if row: + # Hent danse + with get_db() as conn: + dances = conn.execute( + "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", + (row["id"],) + ).fetchall() + found.append({ + "id": row["id"], + "title": row["title"], + "artist": row["artist"], + "album": row["album"], + "bpm": row["bpm"], + "duration_sec": row["duration_sec"], + "local_path": row["local_path"], + "file_format": row["file_format"], + "file_missing": bool(row["file_missing"]), + "dances": [d["dance_name"] for d in dances], + }) + elif os.path.exists(p) and is_supported(p): + # Filen er ikke scannet endnu — høst tags og tilføj + tags = read_tags(p) + song_id = upsert_song(tags) + found.append({ + "id": song_id, + "title": tags.get("title", Path(p).stem), + "artist": tags.get("artist", ""), + "album": tags.get("album", ""), + "bpm": tags.get("bpm", 0), + "duration_sec": tags.get("duration_sec", 0), + "local_path": p, + "file_format": tags.get("file_format", ""), + "file_missing": False, + "dances": tags.get("dances", []), + }) + else: + missing.append(p) + + if found: + self.playlist_loaded.emit(name, found) + status = f"✓ Importeret {len(found)} sange som \"{name}\"" + if missing: + status += f"\n⚠ {len(missing)} filer ikke fundet" + self._import_status.setText(status) + else: + self._import_status.setText("Ingen filer fundet — tjek stierne") + + except Exception as e: + self._import_status.setText(f"Importfejl: {e}") diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py new file mode 100644 index 00000000..ba1808d7 --- /dev/null +++ b/linedance-app/ui/playlist_panel.py @@ -0,0 +1,538 @@ +""" +playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QListWidget, QListWidgetItem, + QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, + QMessageBox, QInputDialog, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray +from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent + + +ACTIVE_PLAYLIST_NAME = "__aktiv__" # fast navn til autogem-listen + + +class PlaylistPanel(QWidget): + song_selected = pyqtSignal(int) + status_changed = pyqtSignal(int, str) + song_dropped = pyqtSignal(dict) + playlist_changed = pyqtSignal() + event_started = pyqtSignal() + next_song_ready = pyqtSignal(dict) # udsendes når næste sang ændres — main_window indlæser den # udsendes af Start event — main_window indlæser første sang # udsendes ved enhver ændring → trigger autogem + + STATUS_ICON = {"pending": " ", "playing": " ▶ ", "played": " ✓ ", "skipped": " — ", "next": " ▷ "} + STATUS_COLOR = {"pending": "#5a6070", "playing": "#e8a020", "played": "#2ecc71", "skipped": "#e74c3c", "next": "#3b8fd4"} + + def __init__(self, parent=None): + super().__init__(parent) + self._songs: list[dict] = [] + self._statuses: list[str] = [] + self._current_idx = -1 + self._song_ended = False + self._active_playlist_id: int | None = None + self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste + self._build_ui() + self.setAcceptDrops(True) + # Autogem-timer — venter 800ms efter sidst ændring + self._autosave_timer = QTimer(self) + self._autosave_timer.setSingleShot(True) + self._autosave_timer.setInterval(800) + self._autosave_timer.timeout.connect(self._autosave) + # Event-state gem — hurtig, kritisk for genopstart efter strømsvigt + self._event_state_timer = QTimer(self) + self._event_state_timer.setSingleShot(True) + self._event_state_timer.setInterval(300) + self._event_state_timer.timeout.connect(self._save_event_state) + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── Header med titel ────────────────────────────────────────────────── + header = QHBoxLayout() + header.setContentsMargins(10, 6, 10, 6) + self._title_label = QLabel("DANSELISTE") + self._title_label.setObjectName("section_title") + header.addWidget(self._title_label) + layout.addLayout(header) + + # ── Ny / Gem / Hent knapper ─────────────────────────────────────────── + toolbar = QHBoxLayout() + toolbar.setContentsMargins(8, 2, 8, 4) + toolbar.setSpacing(4) + + btn_new = QPushButton("✚ Ny") + btn_new.setFixedHeight(26) + btn_new.setToolTip("Opret en ny tom danseliste") + btn_new.clicked.connect(self._new_playlist) + toolbar.addWidget(btn_new) + + btn_save = QPushButton("💾 Gem som...") + btn_save.setFixedHeight(26) + btn_save.setToolTip("Gem aktuel liste med et navn") + btn_save.clicked.connect(self._save_as) + toolbar.addWidget(btn_save) + + btn_load = QPushButton("📂 Hent...") + btn_load.setFixedHeight(26) + btn_load.setToolTip("Hent en tidligere gemt danseliste") + btn_load.clicked.connect(self._load_dialog) + toolbar.addWidget(btn_load) + + toolbar.addStretch() + + self._lbl_autosave = QLabel("") + self._lbl_autosave.setObjectName("result_count") + toolbar.addWidget(self._lbl_autosave) + + layout.addLayout(toolbar) + + # ── Event-kontrol ───────────────────────────────────────────────────── + ctrl = QHBoxLayout() + ctrl.setContentsMargins(8, 2, 8, 4) + ctrl.setSpacing(6) + + self._btn_start = QPushButton("▶ START EVENT") + self._btn_start.setFixedHeight(28) + self._btn_start.setToolTip("Nulstil alle statusser og gør klar til event") + self._btn_start.clicked.connect(self._start_event) + ctrl.addWidget(self._btn_start) + ctrl.addStretch() + + self._lbl_progress = QLabel("0 / 0") + self._lbl_progress.setObjectName("result_count") + ctrl.addWidget(self._lbl_progress) + + layout.addLayout(ctrl) + + # ── Liste ───────────────────────────────────────────────────────────── + self._list = QListWidget() + self._list.setObjectName("playlist_list") + self._list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) + self._list.setDefaultDropAction(Qt.DropAction.MoveAction) + self._list.setAcceptDrops(True) + self._list.itemDoubleClicked.connect(self._on_double_click) + self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._list.customContextMenuRequested.connect(self._show_context_menu) + self._list.model().rowsMoved.connect(self._on_rows_moved) + layout.addWidget(self._list) + + # ── Drag & drop ─────────────────────────────────────────────────────────── + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasFormat("application/x-linedance-song"): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event: QDropEvent): + mime = event.mimeData() + if mime.hasFormat("application/x-linedance-song"): + import json + song = json.loads(mime.data("application/x-linedance-song").data().decode()) + self._append_song(song) + self.song_dropped.emit(song) + event.acceptProposedAction() + + def _append_song(self, song: dict): + self._songs.append(song) + self._statuses.append("pending") + self._refresh() + self._trigger_autosave() + + # ── Data API ────────────────────────────────────────────────────────────── + + def load_songs(self, songs: list[dict], reset_statuses: bool = True, name: str = ""): + self._songs = list(songs) + if reset_statuses: + self._statuses = ["pending"] * len(songs) + self._current_idx = -1 + self._song_ended = False + if name: + self._title_label.setText(f"DANSELISTE — {name.upper()}") + self._refresh() + self._trigger_autosave() + + def set_current(self, idx: int, song_ended: bool = False): + self._current_idx = idx + self._song_ended = song_ended + if 0 <= idx < len(self._statuses) and not song_ended: + self._statuses[idx] = "playing" + self._refresh() + self._scroll_to(idx) + + def mark_played(self, idx: int): + if 0 <= idx < len(self._statuses): + self._statuses[idx] = "played" + self._refresh() + self._trigger_autosave() + self._trigger_event_state_save() + + def set_next_ready(self, idx: int): + """Sæt næste sang klar — uden at overskrive skipped/played statusser.""" + self._current_idx = idx + self._song_ended = False + # Ændr KUN status hvis den er pending — rør ikke skipped/played + if 0 <= idx < len(self._statuses): + if self._statuses[idx] not in ("skipped", "played"): + self._statuses[idx] = "pending" + self._refresh() + self._scroll_to(idx) + + def get_song(self, idx: int) -> dict | None: + return self._songs[idx] if 0 <= idx < len(self._songs) else None + + def get_songs(self) -> list[dict]: + return list(self._songs) + + def get_statuses(self) -> list[str]: + return list(self._statuses) + + def count(self) -> int: + return len(self._songs) + + def set_playlist_name(self, name: str): + self._title_label.setText(f"DANSELISTE — {name.upper()}") + + # ── Drag-flytning ───────────────────────────────────────────────────────── + + def _on_rows_moved(self, parent, start, end, dest, dest_row): + """Opdater _songs og _statuses når en sang flyttes via drag.""" + new_songs = [] + new_statuses = [] + for i in range(self._list.count()): + old_idx = self._list.item(i).data(Qt.ItemDataRole.UserRole) + if old_idx is not None and 0 <= old_idx < len(self._songs): + new_songs.append(self._songs[old_idx]) + new_statuses.append(self._statuses[old_idx]) + self._songs = new_songs + self._statuses = new_statuses + self._current_idx = -1 + self._song_ended = False + self._refresh() + self._trigger_autosave() + + # Find første afspilbare sang og udsend signal så afspilleren opdateres + ni = self.next_playable_idx() + if ni is not None: + self._current_idx = ni + self._refresh() + self.next_song_ready.emit(self._songs[ni]) + + # ── Event-state ─────────────────────────────────────────────────────────── + + def _save_event_state(self): + """Gem current_idx og statuses — overlever strømsvigt.""" + try: + from local.local_db import save_event_state + save_event_state(self._current_idx, self._statuses) + except Exception as e: + pass + + def _trigger_event_state_save(self): + self._event_state_timer.start() + + def restore_event_state(self) -> bool: + """Gendan gemt event-fremgang. Returnerer True hvis gendannet.""" + try: + from local.local_db import load_event_state + result = load_event_state() + if not result: + return False + idx, statuses = result + if len(statuses) != len(self._songs): + return False # listen er ændret siden sidst + self._statuses = statuses + self._current_idx = idx + self._song_ended = False + self._refresh() + return True + except Exception as e: + pass + return False + + def get_named_playlist_id(self) -> int | None: + return self._named_playlist_id + + def next_playable_idx(self) -> int | None: + """Find første sang fra toppen der ikke er 'skipped' eller 'played'.""" + for i in range(len(self._songs)): + if self._statuses[i] not in ("skipped", "played"): + return i + return None + + # ── Autogem ─────────────────────────────────────────────────────────────── + + def _trigger_autosave(self): + """Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring.""" + self._autosave_timer.start() + self._lbl_autosave.setText("● ikke gemt") + + def _autosave(self): + """Gem til den faste 'Aktiv liste' i SQLite.""" + try: + from local.local_db import get_db, create_playlist, add_song_to_playlist + with get_db() as conn: + # Slet den gamle aktive liste + conn.execute( + "DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) + ) + # Opret ny + pl_id = create_playlist(ACTIVE_PLAYLIST_NAME) + self._active_playlist_id = pl_id + for i, song in enumerate(self._songs, start=1): + if song.get("id"): + add_song_to_playlist(pl_id, song["id"], position=i) + self._lbl_autosave.setText("✓ gemt") + self.playlist_changed.emit() + except Exception as e: + self._lbl_autosave.setText(f"⚠ gemfejl") + pass + + def restore_active_playlist(self): + """Indlæs den sidst aktive liste ved opstart.""" + try: + from local.local_db import get_db + with get_db() as conn: + pl = conn.execute( + "SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) + ).fetchone() + if not pl: + return False + songs_raw = conn.execute(""" + SELECT s.*, ps.position FROM playlist_songs ps + JOIN songs s ON s.id = ps.song_id + WHERE ps.playlist_id=? ORDER BY ps.position + """, (pl["id"],)).fetchall() + songs = [] + for row in songs_raw: + dances = conn.execute( + "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", + (row["id"],) + ).fetchall() + songs.append({ + "id": row["id"], "title": row["title"], + "artist": row["artist"], "album": row["album"], + "bpm": row["bpm"], "duration_sec": row["duration_sec"], + "local_path": row["local_path"], "file_format": row["file_format"], + "file_missing": bool(row["file_missing"]), + "dances": [d["dance_name"] for d in dances], + }) + if songs: + self._songs = songs + self._statuses = ["pending"] * len(songs) + self._refresh() + self._lbl_autosave.setText("✓ gendannet") + return True + except Exception as e: + pass + return False + + # ── Ny / Gem som / Hent ─────────────────────────────────────────────────── + + def _new_playlist(self): + if self._songs: + reply = QMessageBox.question( + self, "Ny danseliste", + "Ryd den aktuelle liste og start forfra?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self._songs = [] + self._statuses = [] + self._current_idx = -1 + self._song_ended = False + self._title_label.setText("DANSELISTE — NY") + self._refresh() + self._trigger_autosave() + + def _save_as(self): + if not self._songs: + QMessageBox.information(self, "Gem", "Danselisten er tom.") + return + name, ok = QInputDialog.getText( + self, "Gem danseliste", "Navn på danselisten:", + ) + if not ok or not name.strip(): + return + name = name.strip() + try: + from local.local_db import create_playlist, add_song_to_playlist + pl_id = create_playlist(name) + for i, song in enumerate(self._songs, start=1): + if song.get("id"): + add_song_to_playlist(pl_id, song["id"], position=i) + self._named_playlist_id = pl_id + self._title_label.setText(f"DANSELISTE — {name.upper()}") + self._lbl_autosave.setText(f"✓ gemt som \"{name}\"") + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") + + def _load_dialog(self): + """Vis liste af gemte danselister og lad brugeren vælge.""" + try: + from local.local_db import get_db + with get_db() as conn: + lists = conn.execute( + "SELECT id, name, created_at FROM playlists " + "WHERE name != ? ORDER BY created_at DESC", + (ACTIVE_PLAYLIST_NAME,) + ).fetchall() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke hente lister: {e}") + return + + if not lists: + QMessageBox.information(self, "Hent liste", "Ingen gemte danselister fundet.") + return + + names = [f"{row['name']} ({row['created_at'][:10]})" for row in lists] + choice, ok = QInputDialog.getItem( + self, "Hent danseliste", "Vælg en liste:", names, editable=False + ) + if not ok: + return + + idx = names.index(choice) + pl_id = lists[idx]["id"] + pl_name = lists[idx]["name"] + + try: + from local.local_db import get_db + with get_db() as conn: + songs_raw = conn.execute(""" + SELECT s.*, ps.position, ps.status FROM playlist_songs ps + JOIN songs s ON s.id = ps.song_id + WHERE ps.playlist_id=? ORDER BY ps.position + """, (pl_id,)).fetchall() + songs = [] + statuses = [] + for row in songs_raw: + dances = conn.execute( + "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", + (row["id"],) + ).fetchall() + songs.append({ + "id": row["id"], "title": row["title"], + "artist": row["artist"], "album": row["album"], + "bpm": row["bpm"], "duration_sec": row["duration_sec"], + "local_path": row["local_path"], "file_format": row["file_format"], + "file_missing": bool(row["file_missing"]), + "dances": [d["dance_name"] for d in dances], + }) + statuses.append(row["status"] or "pending") + self._songs = songs + self._statuses = statuses + self._current_idx = -1 + self._song_ended = False + self._named_playlist_id = pl_id + self._title_label.setText(f"DANSELISTE — {pl_name.upper()}") + self._lbl_autosave.setText("✓ gendannet") + self._refresh() + self._trigger_autosave() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}") + + # ── Start event ─────────────────────────────────────────────────────────── + + def _start_event(self): + if not self._songs: + return + reply = QMessageBox.question( + self, "Start event", + "Dette nulstiller alle statusser i danselisten.\nFortsæt?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self._statuses = ["pending"] * len(self._songs) + self._current_idx = -1 + self._song_ended = True + try: + from local.local_db import clear_event_state + clear_event_state() + except Exception: + pass + self._refresh() + self._scroll_to(0) + self.event_started.emit() + + # ── Højreklik ───────────────────────────────────────────────────────────── + + def _show_context_menu(self, pos): + item = self._list.itemAt(pos) + if not item: + return + idx = item.data(Qt.ItemDataRole.UserRole) + if idx is None: + return + menu = QMenu(self) + act_play = menu.addAction("▶ Afspil denne") + menu.addSeparator() + act_skip = menu.addAction("— Spring over") + act_unplay = menu.addAction("↺ Sæt til ikke afspillet") + act_played = menu.addAction("✓ Sæt til afspillet") + menu.addSeparator() + act_remove = menu.addAction("✕ Fjern fra liste") + action = menu.exec(self._list.mapToGlobal(pos)) + if action == act_play: + self.song_selected.emit(idx) + elif action == act_skip: + self._statuses[idx] = "skipped" + self.status_changed.emit(idx, "skipped") + self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() + elif action == act_unplay: + self._statuses[idx] = "pending" + self.status_changed.emit(idx, "pending") + self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() + elif action == act_played: + self._statuses[idx] = "played" + self.status_changed.emit(idx, "played") + self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() + elif action == act_remove: + self._songs.pop(idx) + self._statuses.pop(idx) + if self._current_idx >= idx: + self._current_idx = max(-1, self._current_idx - 1) + self._refresh(); self._trigger_autosave() + + # ── Render ──────────────────────────────────────────────────────────────── + + def _refresh(self): + self._list.clear() + played = sum(1 for s in self._statuses if s == "played") + self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet") + for i, song in enumerate(self._songs): + is_current = (i == self._current_idx and not self._song_ended) + is_next = (self._song_ended and i == self._current_idx + 1) or \ + (self._current_idx == -1 and self._song_ended and i == 0) + status = "playing" if is_current else "next" if is_next else self._statuses[i] + icon = self.STATUS_ICON.get(status, " ") + dances = " / ".join(song.get("dances", [])) or "ingen dans tagget" + text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}" + item = QListWidgetItem(f"{icon} {text}") + item.setData(Qt.ItemDataRole.UserRole, i) + color = self.STATUS_COLOR.get(status, "#5a6070") + if status in ("playing", "next"): + item.setForeground(QColor(color)) + f = item.font(); f.setBold(True); item.setFont(f) + elif status == "played": + item.setForeground(QColor("#2ecc71")) + elif status == "skipped": + item.setForeground(QColor("#e74c3c")) + else: + item.setForeground(QColor("#9aa0b0")) + self._list.addItem(item) + + def _scroll_to(self, idx: int): + if 0 <= idx < self._list.count(): + self._list.scrollToItem( + self._list.item(idx), QListWidget.ScrollHint.PositionAtCenter) + + def _on_double_click(self, item: QListWidgetItem): + idx = item.data(Qt.ItemDataRole.UserRole) + if idx is not None: + self.song_selected.emit(idx) diff --git a/linedance-app/ui/scan_worker.py b/linedance-app/ui/scan_worker.py new file mode 100644 index 00000000..13ae61ba --- /dev/null +++ b/linedance-app/ui/scan_worker.py @@ -0,0 +1,64 @@ +""" +scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd +så GUI ikke fryser. +""" + +from PyQt6.QtCore import QThread, pyqtSignal + + +class ScanWorker(QThread): + """ + Kører _full_scan_all() i en baggrundstråd. + Sender status-opdateringer undervejs. + """ + status_update = pyqtSignal(str) # løbende statusbeskeder + scan_done = pyqtSignal(int) # antal behandlede filer + + def __init__(self, watcher, parent=None): + super().__init__(parent) + self._watcher = watcher + self._total = 0 + + def run(self): + try: + from local.local_db import get_libraries + from local.tag_reader import is_supported + import os + libraries = get_libraries(active_only=True) + + if not libraries: + self.status_update.emit("Ingen biblioteker konfigureret") + self.scan_done.emit(0) + return + + total_processed = 0 + for lib in libraries: + from pathlib import Path + path = Path(lib["path"]) + name = path.name + + if not path.exists(): + self.status_update.emit(f"⚠ Mappe ikke fundet: {path}") + continue + + self.status_update.emit(f"Scanner: {name}...") + + # Tæl filer med os.walk — håndterer permission-fejl sikkert + count = 0 + for dirpath, _, filenames in os.walk(str(path), followlinks=False): + for f in filenames: + if is_supported(f): + count += 1 + + self.status_update.emit(f"Scanner: {name} ({count} filer)...") + + # Kør scanning + self._watcher._full_scan_library(lib["id"], str(path)) + total_processed += count + + self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået") + self.scan_done.emit(total_processed) + + except Exception as e: + self.status_update.emit(f"Scan fejl: {e}") + self.scan_done.emit(0) diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py new file mode 100644 index 00000000..c273519c --- /dev/null +++ b/linedance-app/ui/settings_dialog.py @@ -0,0 +1,281 @@ +""" +settings_dialog.py — Indstillinger for LineDance Player. +Gemmes via QSettings og læses ved opstart. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QComboBox, QSpinBox, QCheckBox, QFrame, + QTabWidget, QWidget, QFileDialog, QGroupBox, QFormLayout, +) +from PyQt6.QtCore import Qt, QSettings + + +SETTINGS_KEY_THEME = "appearance/dark_theme" +SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds" +SETTINGS_KEY_DEMO_FADE = "playback/demo_fade_seconds" +SETTINGS_KEY_VOLUME = "playback/volume" +SETTINGS_KEY_MAIL_CLIENT = "mail/client" +SETTINGS_KEY_MAIL_PATH = "mail/custom_path" +SETTINGS_KEY_AUTO_LOGIN = "online/auto_login" +SETTINGS_KEY_USERNAME = "online/username" +SETTINGS_KEY_PASSWORD = "online/password" + + +def load_settings() -> dict: + s = QSettings("LineDance", "Player") + return { + "dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool), + "demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int), + "demo_fade_seconds": s.value(SETTINGS_KEY_DEMO_FADE, 5, type=int), + "volume": s.value(SETTINGS_KEY_VOLUME, 78, type=int), + "mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"), + "mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""), + "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool), + "username": s.value(SETTINGS_KEY_USERNAME, ""), + "password": s.value(SETTINGS_KEY_PASSWORD, ""), + } + + +def save_settings(values: dict): + s = QSettings("LineDance", "Player") + s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True)) + s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10)) + s.setValue(SETTINGS_KEY_DEMO_FADE, values.get("demo_fade_seconds", 5)) + s.setValue(SETTINGS_KEY_VOLUME, values.get("volume", 78)) + s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto")) + s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", "")) + s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False)) + s.setValue(SETTINGS_KEY_USERNAME, values.get("username", "")) + s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", "")) + + +class SettingsDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Indstillinger") + self.setMinimumWidth(480) + self.setModal(True) + self._values = load_settings() + self._build_ui() + self._populate() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) + + tabs = QTabWidget() + tabs.addTab(self._build_appearance_tab(), "🎨 Udseende") + tabs.addTab(self._build_playback_tab(), "▶ Afspilning") + tabs.addTab(self._build_mail_tab(), "✉ Mail") + tabs.addTab(self._build_online_tab(), "🌐 Online") + layout.addWidget(tabs) + + # Knapper + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_save = QPushButton("💾 Gem indstillinger") + btn_save.setObjectName("btn_play") + btn_save.setDefault(True) + btn_save.clicked.connect(self._save_and_close) + btn_row.addWidget(btn_save) + layout.addLayout(btn_row) + + # ── Fane: Udseende ──────────────────────────────────────────────────────── + + def _build_appearance_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Standard tema") + grp_layout = QVBoxLayout(grp) + + self._chk_dark = QCheckBox("Start med mørkt tema") + grp_layout.addWidget(self._chk_dark) + + note = QLabel("Du kan altid skifte tema mens programmet kører via topbar-knappen.") + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addWidget(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + # ── Fane: Afspilning ────────────────────────────────────────────────────── + + def _build_playback_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Forspil (▶ N SEK knappen)") + grp_layout = QFormLayout(grp) + + self._spin_demo = QSpinBox() + self._spin_demo.setRange(3, 60) + self._spin_demo.setSuffix(" sekunder") + self._spin_demo.setFixedWidth(140) + grp_layout.addRow("Forspil-længde:", self._spin_demo) + + self._spin_fade = QSpinBox() + self._spin_fade.setRange(0, 15) + self._spin_fade.setSuffix(" sekunder (0 = ingen fade)") + self._spin_fade.setFixedWidth(220) + self._spin_fade.setToolTip( + "Fade-out tilføjes til forspillets længde.\n" + "F.eks. 10 sek forspil + 5 sek fade = 15 sek total.\n" + "Sæt til 0 for ingen fade." + ) + grp_layout.addRow("Fade-ud:", self._spin_fade) + + note = QLabel( + "Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n" + "at det er den rigtige sang og dans inden eventet starter.\n" + "Fade-ud tilføjes oven i forspillets længde og fades logaritmisk." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addRow(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + # ── Fane: Mail ──────────────────────────────────────────────────────────── + + def _build_mail_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Mailklient") + grp_layout = QFormLayout(grp) + + self._mail_combo = QComboBox() + self._mail_combo.addItem("Auto-detekter (Thunderbird → Outlook → mailto:)", "auto") + self._mail_combo.addItem("Thunderbird", "thunderbird") + self._mail_combo.addItem("Outlook (Windows)", "outlook") + self._mail_combo.addItem("Brugerdefineret sti", "custom") + self._mail_combo.addItem("Kun mailto: (ingen vedhæftning)", "mailto") + self._mail_combo.currentIndexChanged.connect(self._on_mail_combo_changed) + grp_layout.addRow("Klient:", self._mail_combo) + + path_row = QHBoxLayout() + self._mail_path = QLineEdit() + self._mail_path.setPlaceholderText("/usr/bin/thunderbird eller C:\\...\\thunderbird.exe") + path_row.addWidget(self._mail_path) + btn_browse = QPushButton("...") + btn_browse.setFixedWidth(32) + btn_browse.clicked.connect(self._browse_mail_path) + path_row.addWidget(btn_browse) + self._mail_path_row_widget = QWidget() + self._mail_path_row_widget.setLayout(path_row) + grp_layout.addRow("Sti:", self._mail_path_row_widget) + + note = QLabel( + "Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.\n" + "mailto: åbner standard-mailprogrammet men uden automatisk vedhæftning." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addRow(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + def _on_mail_combo_changed(self, idx: int): + is_custom = self._mail_combo.currentData() == "custom" + self._mail_path_row_widget.setVisible(is_custom) + + def _browse_mail_path(self): + path, _ = QFileDialog.getOpenFileName(self, "Vælg mailklient") + if path: + self._mail_path.setText(path) + + # ── Fane: Online ────────────────────────────────────────────────────────── + + def _build_online_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Automatisk login ved opstart") + grp_layout = QFormLayout(grp) + + self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter") + self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed) + grp_layout.addRow(self._chk_auto_login) + + self._user_input = QLineEdit() + self._user_input.setPlaceholderText("dit-brugernavn") + grp_layout.addRow("Brugernavn:", self._user_input) + + self._pass_input = QLineEdit() + self._pass_input.setEchoMode(QLineEdit.EchoMode.Password) + self._pass_input.setPlaceholderText("••••••••") + grp_layout.addRow("Kodeord:", self._pass_input) + + note = QLabel( + "⚠ Kodeordet gemmes lokalt på denne computer.\n" + "Brug kun dette på en personlig maskine." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addRow(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + def _on_auto_login_changed(self, state: int): + enabled = state == Qt.CheckState.Checked.value + self._user_input.setEnabled(enabled) + self._pass_input.setEnabled(enabled) + + # ── Populer fra gemte værdier ───────────────────────────────────────────── + + def _populate(self): + v = self._values + self._chk_dark.setChecked(v.get("dark_theme", True)) + self._spin_demo.setValue(v.get("demo_seconds", 10)) + self._spin_fade.setValue(v.get("demo_fade_seconds", 5)) + + # Mail + client = v.get("mail_client", "auto") + for i in range(self._mail_combo.count()): + if self._mail_combo.itemData(i) == client: + self._mail_combo.setCurrentIndex(i) + break + self._mail_path.setText(v.get("mail_path", "")) + self._on_mail_combo_changed(self._mail_combo.currentIndex()) + + # Online + auto = v.get("auto_login", False) + self._chk_auto_login.setChecked(auto) + self._user_input.setText(v.get("username", "")) + self._pass_input.setText(v.get("password", "")) + self._user_input.setEnabled(auto) + self._pass_input.setEnabled(auto) + + # ── Gem ─────────────────────────────────────────────────────────────────── + + def _save_and_close(self): + values = { + "dark_theme": self._chk_dark.isChecked(), + "demo_seconds": self._spin_demo.value(), + "demo_fade_seconds": self._spin_fade.value(), + "mail_client": self._mail_combo.currentData(), + "mail_path": self._mail_path.text().strip(), + "auto_login": self._chk_auto_login.isChecked(), + "username": self._user_input.text().strip(), + "password": self._pass_input.text(), + } + save_settings(values) + self._values = values + self.accept() + + def get_values(self) -> dict: + return self._values diff --git a/linedance-app/ui/tag_editor.py b/linedance-app/ui/tag_editor.py new file mode 100644 index 00000000..64223db4 --- /dev/null +++ b/linedance-app/ui/tag_editor.py @@ -0,0 +1,345 @@ +""" +tag_editor.py — Rediger danse og alternativ-danse. +Dans = navn + niveau kombination. Autoudfyld viser "Navn / Niveau". +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox, + QScrollArea, QFrame, +) +from PyQt6.QtCore import Qt, QTimer, QStringListModel +from PyQt6.QtWidgets import QCompleter + + +class DanceLineEdit(QLineEdit): + """Autoudfyld der viser 'Navn / Niveau' fra dances tabellen.""" + + def __init__(self, placeholder="", parent=None): + super().__init__(parent) + self.setPlaceholderText(placeholder) + self._model = QStringListModel() + self._suggestions = [] # liste af {id, name, level_id, level_name} + comp = QCompleter(self._model, self) + comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + comp.setMaxVisibleItems(12) + comp.activated.connect(self._on_activated) + self.setCompleter(comp) + self._selected_dance = None # {id, name, level_id, level_name} + t = QTimer(self) + t.setSingleShot(True) + t.setInterval(150) + t.timeout.connect(self._suggest) + self.textChanged.connect(lambda _: (t.start(), self._clear_selection())) + self._timer = t + + def _clear_selection(self): + self._selected_dance = None + + def _suggest(self): + prefix = self.text().strip() + if "/" in prefix: + prefix = prefix.split("/")[0].strip() + if not prefix: + return + try: + from local.local_db import get_dance_suggestions + self._suggestions = get_dance_suggestions(prefix, limit=15) + labels = [] + for s in self._suggestions: + if s.get("level_name"): + labels.append(f"{s['name']} / {s['level_name']}") + else: + labels.append(s["name"]) + self._model.setStringList(labels) + except Exception: + pass + + def _on_activated(self, text: str): + """Bruger valgte et forslag — gem hele dance-objektet.""" + for s in self._suggestions: + label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] + if label == text: + self._selected_dance = s + break + + def get_dance_info(self) -> dict: + """Returnerer {name, level_id} — fra valgt forslag eller fra fritekst.""" + if self._selected_dance: + return { + "name": self._selected_dance["name"], + "level_id": self._selected_dance["level_id"], + } + # Fritekst — parse "Navn / Niveau" hvis bruger har skrevet det manuelt + text = self.text().strip() + if "/" in text: + parts = text.split("/", 1) + name = parts[0].strip() + level_name = parts[1].strip() + # Slå niveau op + try: + from local.local_db import get_dance_levels + for lvl in get_dance_levels(): + if lvl["name"].lower() == level_name.lower(): + return {"name": name, "level_id": lvl["id"]} + except Exception: + pass + return {"name": name, "level_id": None} + return {"name": text, "level_id": None} + + +class TagEditorDialog(QDialog): + def __init__(self, song: dict, parent=None): + super().__init__(parent) + self._song = song + self._levels = [] + self._dances = [] # fra DB: {dance_id, name, level_id, level_name, dance_order} + self._alts = [] # fra DB: {dance_id, name, level_id, level_name, note} + + self.setWindowTitle(f"Rediger tags — {song.get('title', '')}") + self.setMinimumSize(720, 500) + self.resize(820, 580) + + self._load_levels() + self._load_existing() + self._build_ui() + + def _load_levels(self): + try: + from local.local_db import get_dance_levels + self._levels = [dict(r) for r in get_dance_levels()] + except Exception: + self._levels = [] + + def _load_existing(self): + try: + from local.local_db import get_dances_for_song, get_alt_dances_for_song + self._dances = get_dances_for_song(self._song.get("id")) + self._alts = get_alt_dances_for_song(self._song.get("id")) + except Exception as e: + print(f"load fejl: {e}") + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + # Sang-info + info = QFrame() + info.setObjectName("track_display") + il = QHBoxLayout(info) + il.setContentsMargins(10, 8, 10, 8) + lbl_t = QLabel(self._song.get("title", "—")) + lbl_t.setObjectName("track_title") + il.addWidget(lbl_t, stretch=1) + fmt = self._song.get("file_format", "").lower() + can_write = fmt in ("mp3", "flac", "ogg", "opus", "m4a") + lbl_w = QLabel("✓ Danse skrives til filen" if can_write + else "⚠ Dette format understøtter ikke fil-skrivning") + lbl_w.setObjectName("result_count") + il.addWidget(lbl_w) + layout.addWidget(info) + + # Hint om autoudfyld + hint = QLabel("Skriv dansenavn — forslag vises som 'Navn / Niveau'. " + "Vælg fra listen for at få niveau automatisk.") + hint.setObjectName("result_count") + hint.setWordWrap(True) + layout.addWidget(hint) + + # To kolonner + cols = QHBoxLayout() + cols.setSpacing(12) + cols.addWidget(self._build_dances_panel()) + cols.addWidget(self._build_alts_panel()) + layout.addLayout(cols, stretch=1) + + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_save = QPushButton("💾 Gem tags") + btn_save.setObjectName("btn_play") + btn_save.clicked.connect(self._save) + btn_row.addWidget(btn_save) + layout.addLayout(btn_row) + + def _build_dances_panel(self) -> QGroupBox: + grp = QGroupBox("Danse") + layout = QVBoxLayout(grp) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + container = QWidget() + self._dance_layout = QVBoxLayout(container) + self._dance_layout.setSpacing(4) + self._dance_layout.addStretch() + scroll.setWidget(container) + layout.addWidget(scroll, stretch=1) + self._dance_rows = [] + for d in self._dances: + label = f"{d['name']} / {d['level_name']}" if d.get("level_name") else d["name"] + self._add_dance_row(label) + + add_row = QHBoxLayout() + self._new_dance = DanceLineEdit("Ny dans (f.eks. Cowboy Cha Cha / Begynder)...", self) + self._new_dance.returnPressed.connect(self._on_add_dance) + add_row.addWidget(self._new_dance) + btn = QPushButton("+ Tilføj") + btn.setFixedWidth(70) + btn.clicked.connect(self._on_add_dance) + add_row.addWidget(btn) + layout.addLayout(add_row) + return grp + + def _add_dance_row(self, text=""): + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(4) + edit = DanceLineEdit("Dans...", self) + edit.setText(text) + row_layout.addWidget(edit, stretch=1) + btn_rm = QPushButton("✕") + btn_rm.setFixedSize(24, 24) + row_layout.addWidget(btn_rm) + idx = self._dance_layout.count() - 1 + self._dance_layout.insertWidget(idx, row_widget) + entry = {"widget": row_widget, "edit": edit} + self._dance_rows.append(entry) + btn_rm.clicked.connect(lambda: self._remove_dance_row(entry)) + + def _remove_dance_row(self, entry): + self._dance_rows.remove(entry) + entry["widget"].deleteLater() + + def _on_add_dance(self): + if self._new_dance.text().strip(): + self._add_dance_row(self._new_dance.text().strip()) + self._new_dance.clear() + + def _build_alts_panel(self) -> QGroupBox: + grp = QGroupBox("Alternativ-danse") + layout = QVBoxLayout(grp) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + container = QWidget() + self._alt_layout = QVBoxLayout(container) + self._alt_layout.setSpacing(4) + self._alt_layout.addStretch() + scroll.setWidget(container) + layout.addWidget(scroll, stretch=1) + self._alt_rows = [] + for a in self._alts: + label = f"{a['name']} / {a['level_name']}" if a.get("level_name") else a["name"] + self._add_alt_row(label, a.get("note", "")) + + add_row = QHBoxLayout() + self._new_alt = DanceLineEdit("Alternativ dans...", self) + self._new_alt.returnPressed.connect(self._on_add_alt) + add_row.addWidget(self._new_alt) + btn = QPushButton("+ Tilføj") + btn.setFixedWidth(70) + btn.clicked.connect(self._on_add_alt) + add_row.addWidget(btn) + layout.addLayout(add_row) + return grp + + def _add_alt_row(self, text="", note=""): + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(4) + lbl = QLabel("→") + lbl.setObjectName("track_meta") + row_layout.addWidget(lbl) + edit = DanceLineEdit("Dans...", self) + edit.setText(text) + row_layout.addWidget(edit, stretch=1) + note_edit = QLineEdit() + note_edit.setPlaceholderText("note...") + note_edit.setText(note) + note_edit.setFixedWidth(80) + row_layout.addWidget(note_edit) + btn_rm = QPushButton("✕") + btn_rm.setFixedSize(24, 24) + row_layout.addWidget(btn_rm) + idx = self._alt_layout.count() - 1 + self._alt_layout.insertWidget(idx, row_widget) + entry = {"widget": row_widget, "edit": edit, "note": note_edit} + self._alt_rows.append(entry) + btn_rm.clicked.connect(lambda: self._remove_alt_row(entry)) + + def _remove_alt_row(self, entry): + self._alt_rows.remove(entry) + entry["widget"].deleteLater() + + def _on_add_alt(self): + if self._new_alt.text().strip(): + self._add_alt_row(self._new_alt.text().strip()) + self._new_alt.clear() + + def _save(self): + song_id = self._song.get("id") + local_path = self._song.get("local_path", "") + + try: + from local.local_db import new_conn, get_or_create_dance + from local.tag_reader import write_dances, can_write_dances + + # Saml data fra UI + dances = [] + for row in self._dance_rows: + info = row["edit"].get_dance_info() + if info["name"]: + dances.append(info) + + alts = [] + for row in self._alt_rows: + info = row["edit"].get_dance_info() + if info["name"]: + alts.append({**info, "note": row["note"].text().strip()}) + + conn = new_conn() + + # Slet eksisterende + conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,)) + conn.execute("DELETE FROM song_alt_dances WHERE song_id=?", (song_id,)) + + # Indsæt hoveddanse + for i, d in enumerate(dances, 1): + dance_id = get_or_create_dance(d["name"], d["level_id"], conn) + conn.execute( + "INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) " + "VALUES (?,?,?)", + (song_id, dance_id, i) + ) + + # Indsæt alternativ-danse + for a in alts: + dance_id = get_or_create_dance(a["name"], a["level_id"], conn) + conn.execute( + "INSERT OR IGNORE INTO song_alt_dances (song_id, dance_id, note) " + "VALUES (?,?,?)", + (song_id, dance_id, a.get("note", "")) + ) + + conn.commit() + conn.close() + + # Skriv danse-navne til filen + if local_path and can_write_dances(local_path): + dance_names = [d["name"] for d in dances] + if not write_dances(local_path, dance_names): + QMessageBox.warning(self, "Advarsel", + "Gemt i database, men kunne ikke skrive til filen.") + + self.accept() + + except Exception as e: + import traceback + traceback.print_exc() + QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") diff --git a/linedance-app/ui/themes.py b/linedance-app/ui/themes.py new file mode 100644 index 00000000..f5dff76a --- /dev/null +++ b/linedance-app/ui/themes.py @@ -0,0 +1,334 @@ +""" +themes.py — Lyst og mørkt tema til PyQt6. +""" + +DARK = """ +QWidget { + background-color: #1a1c1f; + color: #e8eaf0; + font-family: 'Barlow', 'Segoe UI', sans-serif; + font-size: 13px; +} +QMainWindow, #root { + background-color: #111214; +} + +/* Knapper */ +QPushButton { + background-color: #30343c; + color: #9aa0b0; + border: 1px solid #4a5060; + border-radius: 4px; + padding: 6px 14px; +} +QPushButton:hover { + background-color: #454a56; + color: #e8eaf0; + border-color: #e8a020; +} +QPushButton:pressed { + background-color: #22252a; +} +QPushButton:checked { + background-color: #e8a020; + color: #111214; + border-color: #c47a10; +} +QPushButton#btn_play { + background-color: #e8a020; + color: #111214; + border-color: #c47a10; + font-size: 22px; + font-weight: bold; +} +QPushButton#btn_play:hover { + background-color: #c47a10; +} +QPushButton#btn_stop { + color: #e74c3c; +} +QPushButton#btn_stop:hover { + border-color: #e74c3c; +} +QPushButton#btn_demo { + color: #3b8fd4; + border-color: #3b8fd4; + font-size: 11px; +} +QPushButton#btn_demo:hover, QPushButton#btn_demo:checked { + background-color: #3b8fd4; + color: #111214; + border-color: #3b8fd4; +} + +/* Slider */ +QSlider::groove:horizontal { + height: 4px; + background: #2c3038; + border-radius: 2px; +} +QSlider::sub-page:horizontal { + background: #e8a020; + border-radius: 2px; +} +QSlider::handle:horizontal { + background: #e8a020; + width: 12px; + height: 12px; + margin: -4px 0; + border-radius: 6px; +} + +/* Lister */ +QListWidget { + background-color: #1a1c1f; + border: none; + outline: none; +} +QListWidget::item { + padding: 6px 10px; + border-bottom: 1px solid #22252a; +} +QListWidget::item:selected { + background-color: #2c3038; + color: #e8eaf0; + border-left: 2px solid #e8a020; +} +QListWidget::item:hover { + background-color: #22252a; +} + +/* Søgefelt */ +QLineEdit { + background-color: #111214; + border: 1px solid #3a3e46; + border-radius: 3px; + padding: 5px 8px; + color: #e8eaf0; +} +QLineEdit:focus { + border-color: #e8a020; +} + +/* Labels */ +QLabel#track_title { + font-size: 20px; + font-weight: bold; + color: #e8eaf0; + font-family: 'Rajdhani', 'Segoe UI', sans-serif; +} +QLabel#track_meta { + font-size: 11px; + color: #9aa0b0; + font-family: 'Courier New', monospace; +} +QLabel#section_title { + font-size: 11px; + font-weight: bold; + color: #5a6070; + letter-spacing: 2px; + font-family: 'Courier New', monospace; + padding: 6px 10px; + background-color: #22252a; + border-bottom: 1px solid #3a3e46; +} +QLabel#next_up_label { + color: #e8a020; + font-family: 'Courier New', monospace; + font-size: 11px; + letter-spacing: 2px; +} +QLabel#next_up_title { + font-size: 17px; + font-weight: bold; + color: #e8eaf0; +} +QLabel#next_up_sub { + font-size: 11px; + color: #9aa0b0; + font-family: 'Courier New', monospace; +} +QLabel#vol_label { + font-size: 10px; + color: #5a6070; + font-family: 'Courier New', monospace; + letter-spacing: 1px; +} +QLabel#vol_val { + font-size: 11px; + color: #9aa0b0; + font-family: 'Courier New', monospace; + min-width: 28px; +} +QLabel#result_count { + font-size: 10px; + color: #5a6070; + font-family: 'Courier New', monospace; + padding: 3px 10px; +} + +/* Frames / paneler */ +QFrame#panel { + background-color: #1a1c1f; + border: 1px solid #3a3e46; + border-radius: 4px; +} +QFrame#now_playing_frame { + background-color: #1a1c1f; + border: 1px solid #3a3e46; + border-radius: 4px 4px 0 0; +} +QFrame#track_display { + background-color: #111214; + border: 1px solid #3a3e46; + border-radius: 3px; + padding: 4px; +} +QFrame#transport_frame { + background-color: #1a1c1f; + border: 1px solid #3a3e46; + border-top: none; + border-radius: 0 0 4px 4px; +} +QFrame#next_up_frame { + background-color: #22252a; + border: 1px solid #e8a020; + border-top: none; + border-bottom: none; +} +QFrame#progress_frame { + background-color: #1a1c1f; + border: 1px solid #3a3e46; + border-top: none; + border-bottom: none; +} + +/* Scrollbar */ +QScrollBar:vertical { + background: #1a1c1f; + width: 6px; + border-radius: 3px; +} +QScrollBar::handle:vertical { + background: #4a5060; + border-radius: 3px; + min-height: 20px; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } + +/* Højreklik-menu */ +QMenu { + background-color: #22252a; + color: #e8eaf0; + border: 1px solid #4a5060; + padding: 4px 0; + font-size: 14px; +} +QMenu::item { + padding: 8px 24px; + border-radius: 0; +} +QMenu::item:selected { + background-color: #e8a020; + color: #111214; +} +QMenu::separator { + height: 1px; + background: #3a3e46; + margin: 4px 8px; +} + +/* Topbar */ +QFrame#topbar { + background-color: #1a1c1f; + border: 1px solid #3a3e46; + border-radius: 4px; +} +QLabel#logo { + font-size: 16px; + font-weight: bold; + letter-spacing: 3px; + color: #e8a020; + font-family: 'Rajdhani', 'Segoe UI', sans-serif; +} +QLabel#conn_label { + font-size: 11px; + color: #5a6070; + font-family: 'Courier New', monospace; + letter-spacing: 1px; +} +""" + +LIGHT = DARK + """ +QWidget { + background-color: #d8dae0; + color: #1a1c22; +} +QMainWindow, #root { + background-color: #c8cad0; +} +QPushButton { + background-color: #b0b4bc; + color: #1a1c22; + border-color: #8890a0; +} +QPushButton:hover { + background-color: #c8ccd4; + color: #1a1c22; + border-color: #c07010; +} +QPushButton#btn_play { + background-color: #c07010; + color: #fff; + border-color: #a05808; +} +QListWidget { + background-color: #d8dae0; + color: #1a1c22; +} +QListWidget::item { + color: #1a1c22; +} +QListWidget::item:selected { + background-color: #c07010; + color: #ffffff; + border-left: 2px solid #a05808; +} +QListWidget::item:hover { + background-color: #c8ccd4; + color: #1a1c22; +} +QLineEdit { + background-color: #c8cad0; + border-color: #aab0bc; + color: #1a1c22; +} +QLineEdit:focus { border-color: #c07010; } +QFrame#panel, QFrame#now_playing_frame, +QFrame#transport_frame, QFrame#progress_frame { + background-color: #d8dae0; + border-color: #aab0bc; +} +QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; } +QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; } +QLabel#section_title { background-color: #e4e6ec; color: #1a1c22; border-color: #aab0bc; } +QLabel#track_title { color: #1a1c22; } +QLabel#track_meta { color: #4a5060; } +QLabel#result_count { color: #5a6070; } +QSlider::groove:horizontal { background: #b0b4bc; } +QScrollBar:vertical { background: #d8dae0; } +QScrollBar::handle:vertical { background: #8890a0; } +QMenu { + background-color: #e4e6ec; + color: #1a1c22; + border: 1px solid #aab0bc; +} +QMenu::item:selected { + background-color: #c07010; + color: #ffffff; +} +""" + + +def apply_theme(app, dark: bool = True): + app.setStyleSheet(DARK if dark else LIGHT) diff --git a/linedance-app/ui/vu_meter.py b/linedance-app/ui/vu_meter.py new file mode 100644 index 00000000..b85fcadb --- /dev/null +++ b/linedance-app/ui/vu_meter.py @@ -0,0 +1,96 @@ +""" +vu_meter.py — VU-meter widget der tegner L og R kanaler. +Opdateres via set_levels(left, right) med værdier 0.0–1.0. +""" + +from PyQt6.QtWidgets import QWidget +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QPainter, QColor +import random + + +NUM_BARS = 14 +BAR_W = 14 +BAR_H = 4 +BAR_GAP = 2 +CHAN_GAP = 6 +PADDING = 4 + +COLOR_OFF = QColor("#1a2218") +COLOR_GREEN = QColor("#28a050") +COLOR_YELLOW = QColor("#c8a020") +COLOR_RED = QColor("#c83020") + +# Grænser for farver (bar-indeks fra bunden) +YELLOW_FROM = NUM_BARS - 4 +RED_FROM = NUM_BARS - 2 + + +class VUMeter(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._left = 0.0 + self._right = 0.0 + self._peak_l = 0.0 + self._peak_r = 0.0 + self._dark = True + + total_h = NUM_BARS * (BAR_H + BAR_GAP) + PADDING * 2 + 16 # +16 til label + total_w = (BAR_W + CHAN_GAP) * 2 + PADDING * 2 + self.setFixedSize(total_w, total_h) + + def set_dark(self, dark: bool): + self._dark = dark + self.update() + + def set_levels(self, left: float, right: float): + """Sæt niveauer 0.0–1.0. Kaldes fra afspiller-tråden via signal.""" + self._left = max(0.0, min(1.0, left)) + self._right = max(0.0, min(1.0, right)) + self._peak_l = max(self._peak_l * 0.92, self._left) + self._peak_r = max(self._peak_r * 0.92, self._right) + self.update() + + def reset(self): + self._left = self._right = self._peak_l = self._peak_r = 0.0 + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + off_color = QColor("#d0d8cc") if not self._dark else COLOR_OFF + + for ch_idx, level in enumerate([self._left, self._right]): + x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + active_bars = int(level * NUM_BARS) + + for bar_idx in range(NUM_BARS): + y = PADDING + (NUM_BARS - 1 - bar_idx) * (BAR_H + BAR_GAP) + + if bar_idx < active_bars: + if bar_idx >= RED_FROM: + color = COLOR_RED + elif bar_idx >= YELLOW_FROM: + color = COLOR_YELLOW + else: + color = COLOR_GREEN + else: + color = off_color + + painter.fillRect(x, y, BAR_W, BAR_H, + QColor(color.red(), color.green(), color.blue(), 220)) + + # Kanal-labels + label_y = PADDING + NUM_BARS * (BAR_H + BAR_GAP) + 4 + painter.setPen(QColor("#5a6070")) + font = painter.font() + font.setPointSize(8) + font.setFamily("Courier New") + painter.setFont(font) + + for ch_idx, label in enumerate(["L", "R"]): + x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + BAR_W // 2 + painter.drawText(x - 4, label_y + 10, label) + + painter.end()