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