This commit is contained in:
2026-04-09 21:54:18 +02:00
commit ad33255b88
8906 changed files with 1437726 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL=mysql+pymysql://linedance:20gorm66@mysql.ckvist.lan:3306/linedance
SECRET_KEY=e0a15d5a35d1091261cbdf0fd6310492ebd23d66a6d4a8c4253ab33e2594c67a
ACCESS_TOKEN_EXPIRE_MINUTES=10080

3
.env.example Normal file
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

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

0
app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

11
app/core/config.py Normal 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()

21
app/core/database.py Normal file
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()

53
app/core/security.py Normal file
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

33
app/main.py Normal file
View File

@@ -0,0 +1,33 @@
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
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(ws_router)
@app.get("/")
def root():
return {"status": "ok", "service": "Linedance API"}

137
app/models/__init__.py Normal file
View File

@@ -0,0 +1,137 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Integer, String, Text
)
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")
alternatives: Mapped[list["DanceAlternative"]] = relationship("DanceAlternative", foreign_keys="DanceAlternative.created_by", back_populates="creator")
alt_ratings: Mapped[list["DanceAlternativeRating"]] = relationship("DanceAlternativeRating", foreign_keys="DanceAlternativeRating.user_id", back_populates="user")
# ── Project ───────────────────────────────────────────────────────────────────
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")
# ── 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="")
local_path: Mapped[str] = mapped_column(String(512), default="") # kun relevant på PC
bpm: Mapped[int] = mapped_column(Integer, default=0)
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
owner: Mapped["User"] = relationship("User", back_populates="songs")
dances: Mapped[list["SongDance"]] = relationship("SongDance", back_populates="song", order_by="SongDance.dance_order", cascade="all, delete-orphan")
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
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 | playing | played | skipped
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
song: Mapped["Song"] = relationship("Song", back_populates="project_songs")
# ── Dance ─────────────────────────────────────────────────────────────────────
class SongDance(Base):
__tablename__ = "song_dances"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
dance_name: Mapped[str] = mapped_column(String(128), nullable=False)
dance_order: Mapped[int] = mapped_column(Integer, default=1)
song: Mapped["Song"] = relationship("Song", back_populates="dances")
alternatives: Mapped[list["DanceAlternative"]] = relationship("DanceAlternative", foreign_keys="DanceAlternative.song_dance_id", back_populates="song_dance", cascade="all, delete-orphan")
class DanceAlternative(Base):
__tablename__ = "dance_alternatives"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
song_dance_id: Mapped[str] = mapped_column(String(36), ForeignKey("song_dances.id"), nullable=False)
alt_song_dance_id: Mapped[str] = mapped_column(String(36), ForeignKey("song_dances.id"), nullable=False)
created_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
note: Mapped[str] = mapped_column(Text, default="")
bayesian_score: Mapped[float] = mapped_column(default=0.0) # genberegnes ved hver rating
song_dance: Mapped["SongDance"] = relationship("SongDance", foreign_keys=[song_dance_id], back_populates="alternatives")
alt_song_dance: Mapped["SongDance"] = relationship("SongDance", foreign_keys=[alt_song_dance_id])
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
ratings: Mapped[list["DanceAlternativeRating"]] = relationship("DanceAlternativeRating", back_populates="alternative", cascade="all, delete-orphan")
class DanceAlternativeRating(Base):
__tablename__ = "dance_alternative_ratings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("dance_alternatives.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
alternative: Mapped["DanceAlternative"] = relationship("DanceAlternative", back_populates="ratings")
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

235
app/routers/alternatives.py Normal file
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()

39
app/routers/auth.py Normal file
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}

190
app/routers/projects.py Normal file
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()

109
app/routers/songs.py Normal file
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()

115
app/schemas/__init__.py Normal file
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()

Binary file not shown.

Binary file not shown.

78
app/websocket/manager.py Normal file
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,
})

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

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,172 @@
"""
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
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._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):
"""Afspil fra start og stop automatisk ved stop_at_sec."""
self._demo_mode = True
self._demo_stop_sec = stop_at_sec
if VLC_AVAILABLE and self._media_player:
self._media_player.set_time(0)
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
if VLC_AVAILABLE and self._media_player:
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-stop
if self._demo_mode and cur >= self._demo_stop_sec:
self.stop()
self._demo_mode = False
self.position_changed.emit(0.0)
self.time_changed.emit(0, self._duration)
self.state_changed.emit("demo_ended")
return
# 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,4 @@
PyQt6>=6.6.0
python-vlc>=3.0.18
mutagen>=1.47.0
watchdog>=4.0.0

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,221 @@
"""
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()
def __init__(self, parent=None):
super().__init__(parent)
self._all_songs: list[dict] = []
self._filtered: list[dict] = []
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_scan = QPushButton("⟳ SCAN")
self._btn_scan.setFixedHeight(24)
self._btn_scan.setToolTip("Scan alle biblioteksmapper for nye og ændrede filer")
self._btn_scan.clicked.connect(self._on_scan_clicked)
header.addWidget(self._btn_scan)
btn_add = QPushButton("+ MAPPE")
btn_add.setFixedHeight(24)
btn_add.clicked.connect(self._add_folder)
header.addWidget(btn_add)
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._btn_scan.setEnabled(False)
self._btn_scan.setText("⟳ SCANNER...")
self._scan_bar.show()
self._scan_label.setText(status_text or "Starter...")
self._scan_label.show()
else:
self._btn_scan.setEnabled(True)
self._btn_scan.setText("⟳ SCAN")
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_str = " · " + " / ".join(dances) if dances else ""
missing = song.get("file_missing", False)
line1 = ("" if missing else "") + song.get("title", "")
line2 = f" {song.get('artist','')} · {song.get('bpm',0)} BPM · {song.get('file_format','').upper()}{dance_str}"
item = QListWidgetItem(f"{line1}\n{line2}")
item.setData(Qt.ItemDataRole.UserRole, song)
if missing:
item.setForeground(QColor("#5a6070"))
elif q and any(q in d.lower() for d in dances):
item.setForeground(QColor("#e8a020"))
self._list.addItem(item)
# ── 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")
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)
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,688 @@
"""
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.next_up_bar import NextUpBar
from ui.themes import apply_theme
from ui.scan_worker import ScanWorker
from ui.login_dialog import LoginDialog
from ui.playlist_manager import PlaylistManagerDialog
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
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, f: float):
self._demo_fraction = max(0.0, min(1.0, 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"))
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(860, 680)
self.resize(960, 760)
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
self._connect_player_signals()
self._build_menu()
self._build_ui()
self._build_statusbar()
apply_theme(self._app_ref(), dark=True)
# Start DB og scanning ved opstart
QTimer.singleShot(200, self._init_local_db)
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")
file_menu.addSeparator()
self._act_go_online = QAction("Gå online...", self)
self._act_go_online.setShortcut("Ctrl+L")
self._act_go_online.setToolTip("Log ind og synkroniser med server")
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.setToolTip("Log ud og arbejd lokalt")
self._act_go_offline.triggered.connect(self._go_offline)
self._act_go_offline.setEnabled(False) # kun aktiv når man er online
file_menu.addAction(self._act_go_offline)
file_menu.addSeparator()
act_add_folder = QAction("Tilføj musikmappe...", self)
act_add_folder.setShortcut("Ctrl+O")
act_add_folder.triggered.connect(self._menu_add_folder)
file_menu.addAction(act_add_folder)
file_menu.addSeparator()
act_scan = QAction("Scan biblioteker", self)
act_scan.setShortcut("Ctrl+R")
act_scan.setToolTip("Gennemgå alle biblioteksmapper for nye og ændrede filer")
act_scan.triggered.connect(self.start_scan)
file_menu.addAction(act_scan)
self._act_scan = act_scan
file_menu.addSeparator()
act_quit = QAction("Afslut", self)
act_quit.setShortcut("Ctrl+Q")
act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit)
# Danseliste
pl_menu = menubar.addMenu("Danseliste")
act_new_pl = QAction("Ny tom liste", self)
act_new_pl.setShortcut("Ctrl+N")
act_new_pl.triggered.connect(self._new_playlist)
pl_menu.addAction(act_new_pl)
act_manage = QAction("Gem / Indlæs / Importer...", self)
act_manage.setShortcut("Ctrl+M")
act_manage.triggered.connect(self._open_playlist_manager)
pl_menu.addAction(act_manage)
# Visning
view_menu = menubar.addMenu("Visning")
act_theme = QAction("Skift tema (lyst/mørkt)", self)
act_theme.setShortcut("Ctrl+T")
act_theme.triggered.connect(self._toggle_theme)
view_menu.addAction(act_theme)
# ── 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_next_up())
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_next_up(self) -> NextUpBar:
self._next_up = NextUpBar()
self._next_up.play_next_clicked.connect(self._play_next)
return self._next_up
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("\n10 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(78)
self._vol_slider.setFixedWidth(100)
self._vol_slider.valueChanged.connect(self._on_volume)
layout.addWidget(self._vol_slider)
self._lbl_vol = QLabel("78")
self._lbl_vol.setObjectName("vol_val")
layout.addWidget(self._lbl_vol)
return frame
def _build_panels(self) -> QSplitter:
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._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)
splitter.addWidget(self._playlist_panel)
splitter.addWidget(self._library_panel)
splitter.setSizes([480, 480])
return splitter
# ── 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()
def on_file_change(event_type, path, song_id):
QTimer.singleShot(500, 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()
# 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}")
print(f"DB init fejl: {e}")
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 = 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],
})
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:
print(f"Bibliotek reload fejl: {e}")
def add_library_path(self, path: str):
try:
self._watcher.add_library(path)
self._set_status(f"Tilføjet: {path} — scanner...")
except Exception as e:
self._set_status(f"Fejl: {e}")
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)
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 _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)
self._next_up.hide_bar()
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(10 / 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._song_ended:
self._play_next()
return
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._player.play()
def _stop(self):
self._player.stop()
self._song_ended = False
self._demo_active = False
self._btn_demo.setChecked(False)
self._next_up.hide_bar()
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=10)
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):
ni = self._current_idx + 1
if ni < self._playlist_panel.count():
self._song_ended = False
self._next_up.hide_bar()
self._load_song_by_idx(ni)
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)
# Fremhæv næste sang i listen — men afspil den IKKE
ni = self._current_idx + 1
next_song = self._playlist_panel.get_song(ni)
if next_song:
# set_current med song_ended=True markerer næste som "next" (blå)
# uden at ændre _current_idx i main_window
self._playlist_panel.set_current(self._current_idx, song_ended=True)
self._next_up.show_next(
next_song.get("title", ""),
next_song.get("artist", ""),
next_song.get("dances", []),
)
else:
self._lbl_title.setText("— Danseliste afsluttet —")
self._set_status("Danselisten er afsluttet")
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)
# ── 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._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,278 @@
"""
playlist_panel.py — Danseliste med event-overblik, drag-and-drop og højreklik.
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
QMessageBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QMimeData
from PyQt6.QtGui import QColor, QFont, QDragEnterEvent, QDropEvent
class PlaylistPanel(QWidget):
song_selected = pyqtSignal(int) # dobbeltklik → indlæs sang
status_changed = pyqtSignal(int, str) # (indeks, ny_status)
song_dropped = pyqtSignal(dict) # sang droppet fra bibliotek
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._build_ui()
self.setAcceptDrops(True)
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)
self._title_label = QLabel("DANSELISTE")
self._title_label.setObjectName("section_title")
header.addWidget(self._title_label)
header.addStretch()
layout.addLayout(header)
# Event-kontrol-linje
ctrl = QHBoxLayout()
ctrl.setContentsMargins(8, 4, 8, 4)
ctrl.setSpacing(6)
self._btn_start = QPushButton("▶ START EVENT")
self._btn_start.setObjectName("btn_start_event")
self._btn_start.setFixedHeight(28)
self._btn_start.setToolTip("Nulstil alle statusser og start eventet fra top")
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)
# Kolonneheader
col_header = QHBoxLayout()
col_header.setContentsMargins(10, 2, 10, 2)
for text, stretch in [("#", 0), ("Titel / Dans", 1), ("Status", 0)]:
lbl = QLabel(text)
lbl.setObjectName("result_count")
if stretch:
col_header.addWidget(lbl, stretch=1)
else:
lbl.setFixedWidth(30 if text == "#" else 50)
col_header.addWidget(lbl)
layout.addLayout(col_header)
# Liste
self._list = QListWidget()
self._list.setObjectName("playlist_list")
self._list.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
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)
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
data = mime.data("application/x-linedance-song").data()
song = json.loads(data.decode("utf-8"))
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()
# ── Data ──────────────────────────────────────────────────────────────────
def load_songs(self, songs: list[dict], reset_statuses: bool = True):
self._songs = list(songs)
if reset_statuses:
self._statuses = ["pending"] * len(songs)
self._current_idx = -1
self._song_ended = False
self._refresh()
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()
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)
# ── Event-styring ─────────────────────────────────────────────────────────
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 = False
self._refresh()
# ── Højreklik-menu ────────────────────────────────────────────────────────
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)
menu.setStyleSheet("QMenu { padding: 4px; } QMenu::item { padding: 6px 20px; }")
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()
elif action == act_unplay:
self._statuses[idx] = "pending"
self.status_changed.emit(idx, "pending")
self._refresh()
elif action == act_played:
self._statuses[idx] = "played"
self.status_changed.emit(idx, "played")
self._refresh()
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()
# ── Render ────────────────────────────────────────────────────────────────
def _refresh(self):
self._list.clear()
played_count = sum(1 for s in self._statuses if s == "played")
self._lbl_progress.setText(f"{played_count} / {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)
if is_current:
status = "playing"
elif is_next:
status = "next"
else:
status = self._statuses[i]
icon = self.STATUS_ICON.get(status, " ")
color = self.STATUS_COLOR.get(status, "#5a6070")
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)
# Farver
if status == "playing":
item.setForeground(QColor("#e8a020"))
font = item.font()
font.setBold(True)
item.setFont(font)
elif status == "next":
item.setForeground(QColor("#3b8fd4"))
font = item.font()
font.setBold(True)
item.setFont(font)
elif status == "played":
item.setForeground(QColor("#2ecc71"))
elif status == "skipped":
item.setForeground(QColor("#e74c3c"))
else:
item.setForeground(QColor("#9aa0b0"))
self._list.addItem(item)
def set_playlist_name(self, name: str):
self._title_label.setText(f"DANSELISTE — {name.upper()}")
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,54 @@
"""
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
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
self.status_update.emit(f"Scanner: {name}...")
# Tæl filer først så vi kan vise fremgang
from local.tag_reader import is_supported
files = [f for f in path.rglob("*") if f.is_file() and is_supported(f)]
count = len(files)
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)

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

@@ -0,0 +1,293 @@
"""
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; }
/* 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: #4a5060;
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;
}
QListWidget::item:selected {
background-color: #eef0f4;
border-left: 2px solid #c07010;
}
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: #8890a0; border-color: #aab0bc; }
QLabel#track_title { color: #1a1c22; }
QLabel#track_meta { color: #4a5060; }
QSlider::groove:horizontal { background: #b0b4bc; }
QScrollBar:vertical { background: #d8dae0; }
QScrollBar::handle:vertical { background: #8890a0; }
"""
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()

View File

@@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@@ -0,0 +1,70 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv)
else
# use the path as-is
export VIRTUAL_ENV=/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(venv) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(venv) '
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null

View File

@@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = '(venv) '"$prompt"
setenv VIRTUAL_ENV_PROMPT '(venv) '
endif
alias pydoc python -m pydoc
rehash

View File

@@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT '(venv) '
end

8
linedance-app/venv/bin/mid3cp Executable file
View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from mutagen._tools.mid3cp import entry_point
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(entry_point())

View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from mutagen._tools.mid3iconv import entry_point
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(entry_point())

8
linedance-app/venv/bin/mid3v2 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from mutagen._tools.mid3v2 import entry_point
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(entry_point())

View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from mutagen._tools.moggsplit import entry_point
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(entry_point())

View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from mutagen._tools.mutagen_inspect import entry_point
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(entry_point())

View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from mutagen._tools.mutagen_pony import entry_point
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(entry_point())

8
linedance-app/venv/bin/pip Executable file
View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
linedance-app/venv/bin/pip3 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
linedance-app/venv/bin/pip3.12 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from PyQt6.lupdate.pylupdate import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1 @@
python3.12

View File

@@ -0,0 +1 @@
python3.12

View File

@@ -0,0 +1 @@
/usr/bin/python3.12

8
linedance-app/venv/bin/pyuic6 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from PyQt6.uic.pyuic import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from watchdog.watchmedo import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More