This commit is contained in:
2026-04-11 00:38:04 +02:00
parent 99f6a265c0
commit b678787236
48 changed files with 7884 additions and 0 deletions

View File

@@ -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

87
linedance-api/README.md Normal file
View File

@@ -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 <dit-repo>
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=<lang tilfældig streng — kør: openssl rand -hex 32>
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.

View File

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

52
linedance-api/app/main.py Normal file
View File

@@ -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"}

View File

@@ -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")

View File

@@ -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()

View File

@@ -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}

View File

@@ -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"}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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,
})

View File

@@ -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()
"""

View File

@@ -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

View File

@@ -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]}

View File

@@ -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", [])

View File

@@ -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

View File

@@ -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.

View File

@@ -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",
)

57
linedance-app/README.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,33 @@
"""
app_logger.py — Central logging til fil i stedet for konsol.
P<EFBFBD> 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")

35
linedance-app/build.bat Normal file
View File

@@ -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

30
linedance-app/build_linux.sh Executable file
View File

@@ -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

View File

@@ -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',
)

View File

@@ -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()
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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

33
linedance-app/main.py Normal file
View File

@@ -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()

View File

View File

@@ -0,0 +1,200 @@
"""
player.py — VLC-baseret afspiller med PyQt6 signals.
Sender signals til GUI:
position_changed(float) — 0.01.0 progress
time_changed(int, int) — (current_sec, total_sec)
levels_changed(float, float) — VU-meter L/R 0.01.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):
"""0100"""
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.01.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")

View File

@@ -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

View File

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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

View File

@@ -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("LINE<span style='color:#9aa0b0;font-weight:400'>DANCE</span> 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()

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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}")

334
linedance-app/ui/themes.py Normal file
View File

@@ -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)

View File

@@ -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.01.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.01.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()