I gang
This commit is contained in:
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL=mysql+pymysql://linedance:20gorm66@mysql.ckvist.lan:3306/linedance
|
||||
SECRET_KEY=e0a15d5a35d1091261cbdf0fd6310492ebd23d66a6d4a8c4253ab33e2594c67a
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
3
.env.example
Normal file
3
.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
|
||||
57
README.md
Normal file
57
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.
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/database.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/security.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/security.cpython-312.pyc
Normal file
Binary file not shown.
11
app/core/config.py
Normal file
11
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
app/core/database.py
Normal file
21
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
app/core/security.py
Normal file
53
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
|
||||
33
app/main.py
Normal file
33
app/main.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.database import engine, Base
|
||||
from app.routers import auth, projects, songs, alternatives
|
||||
from app.websocket.manager import router as ws_router
|
||||
|
||||
# Opret tabeller hvis de ikke findes (til udvikling — brug Alembic i produktion)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Linedance API",
|
||||
version="0.1.0",
|
||||
description="Backend for linedance-afspiller og projektstyring",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Stram til i produktion
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(songs.router)
|
||||
app.include_router(alternatives.router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"status": "ok", "service": "Linedance API"}
|
||||
137
app/models/__init__.py
Normal file
137
app/models/__init__.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ── User ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
||||
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
||||
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
|
||||
alternatives: Mapped[list["DanceAlternative"]] = relationship("DanceAlternative", foreign_keys="DanceAlternative.created_by", back_populates="creator")
|
||||
alt_ratings: Mapped[list["DanceAlternativeRating"]] = relationship("DanceAlternativeRating", foreign_keys="DanceAlternativeRating.user_id", back_populates="user")
|
||||
|
||||
|
||||
# ── Project ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="projects")
|
||||
members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="project", order_by="ProjectSong.position", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
__tablename__ = "project_members"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="viewer") # owner | editor | viewer
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending | accepted
|
||||
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
||||
user: Mapped["User"] = relationship("User", back_populates="memberships")
|
||||
|
||||
|
||||
# ── Song ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Song(Base):
|
||||
__tablename__ = "songs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
artist: Mapped[str] = mapped_column(String(255), default="")
|
||||
local_path: Mapped[str] = mapped_column(String(512), default="") # kun relevant på PC
|
||||
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
||||
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="songs")
|
||||
dances: Mapped[list["SongDance"]] = relationship("SongDance", back_populates="song", order_by="SongDance.dance_order", cascade="all, delete-orphan")
|
||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
||||
|
||||
|
||||
class ProjectSong(Base):
|
||||
__tablename__ = "project_songs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending | playing | played | skipped
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
|
||||
song: Mapped["Song"] = relationship("Song", back_populates="project_songs")
|
||||
|
||||
|
||||
# ── Dance ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class SongDance(Base):
|
||||
__tablename__ = "song_dances"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||
dance_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
dance_order: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
song: Mapped["Song"] = relationship("Song", back_populates="dances")
|
||||
alternatives: Mapped[list["DanceAlternative"]] = relationship("DanceAlternative", foreign_keys="DanceAlternative.song_dance_id", back_populates="song_dance", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class DanceAlternative(Base):
|
||||
__tablename__ = "dance_alternatives"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
song_dance_id: Mapped[str] = mapped_column(String(36), ForeignKey("song_dances.id"), nullable=False)
|
||||
alt_song_dance_id: Mapped[str] = mapped_column(String(36), ForeignKey("song_dances.id"), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
bayesian_score: Mapped[float] = mapped_column(default=0.0) # genberegnes ved hver rating
|
||||
|
||||
song_dance: Mapped["SongDance"] = relationship("SongDance", foreign_keys=[song_dance_id], back_populates="alternatives")
|
||||
alt_song_dance: Mapped["SongDance"] = relationship("SongDance", foreign_keys=[alt_song_dance_id])
|
||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
|
||||
ratings: Mapped[list["DanceAlternativeRating"]] = relationship("DanceAlternativeRating", back_populates="alternative", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class DanceAlternativeRating(Base):
|
||||
__tablename__ = "dance_alternative_ratings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("dance_alternatives.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
score: Mapped[int] = mapped_column(Integer, nullable=False) # 1-5
|
||||
|
||||
alternative: Mapped["DanceAlternative"] = relationship("DanceAlternative", back_populates="ratings")
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||
BIN
app/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/alternatives.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/alternatives.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/auth.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/projects.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/projects.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/songs.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/songs.cpython-312.pyc
Normal file
Binary file not shown.
235
app/routers/alternatives.py
Normal file
235
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
app/routers/auth.py
Normal file
39
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}
|
||||
190
app/routers/projects.py
Normal file
190
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
app/routers/songs.py
Normal file
109
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
app/schemas/__init__.py
Normal file
115
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()
|
||||
BIN
app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/websocket/__pycache__/manager.cpython-312.pyc
Normal file
BIN
app/websocket/__pycache__/manager.cpython-312.pyc
Normal file
Binary file not shown.
78
app/websocket/manager.py
Normal file
78
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,
|
||||
})
|
||||
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.
|
||||
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()
|
||||
"""
|
||||
BIN
linedance-app/local/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
linedance-app/local/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/local/__pycache__/file_watcher.cpython-312.pyc
Normal file
BIN
linedance-app/local/__pycache__/file_watcher.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/local/__pycache__/local_db.cpython-312.pyc
Normal file
BIN
linedance-app/local/__pycache__/local_db.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/local/__pycache__/tag_reader.cpython-312.pyc
Normal file
BIN
linedance-app/local/__pycache__/tag_reader.cpython-312.pyc
Normal file
Binary file not shown.
258
linedance-app/local/file_watcher.py
Normal file
258
linedance-app/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-app/local/local_db.py
Normal file
330
linedance-app/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-app/local/tag_reader.py
Normal file
280
linedance-app/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", [])
|
||||
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
BIN
linedance-app/player/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
linedance-app/player/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/player/__pycache__/player.cpython-312.pyc
Normal file
BIN
linedance-app/player/__pycache__/player.cpython-312.pyc
Normal file
Binary file not shown.
172
linedance-app/player/player.py
Normal file
172
linedance-app/player/player.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
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
|
||||
|
||||
try:
|
||||
import vlc
|
||||
VLC_AVAILABLE = True
|
||||
except ImportError:
|
||||
VLC_AVAILABLE = False
|
||||
print("Advarsel: python-vlc ikke installeret — afspilning deaktiveret")
|
||||
|
||||
|
||||
class Player(QObject):
|
||||
position_changed = pyqtSignal(float)
|
||||
time_changed = pyqtSignal(int, int)
|
||||
levels_changed = pyqtSignal(float, float)
|
||||
song_ended = pyqtSignal()
|
||||
state_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._path: str | None = None
|
||||
self._duration: int = 0
|
||||
self._demo_mode = False
|
||||
self._demo_stop_sec = 10
|
||||
self._volume = 78
|
||||
|
||||
if VLC_AVAILABLE:
|
||||
self._instance = vlc.Instance("--no-video", "--quiet")
|
||||
self._media_player = self._instance.media_player_new()
|
||||
self._events = self._media_player.event_manager()
|
||||
self._events.event_attach(
|
||||
vlc.EventType.MediaPlayerEndReached,
|
||||
self._on_end_reached,
|
||||
)
|
||||
else:
|
||||
self._media_player = None
|
||||
|
||||
# Timer til polling af position + VU-simulation
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(80)
|
||||
self._poll_timer.timeout.connect(self._poll)
|
||||
|
||||
# ── Indlæsning ────────────────────────────────────────────────────────────
|
||||
|
||||
def load(self, path: str, duration_sec: int = 0):
|
||||
"""Indlæs en lydfil uden at starte afspilning."""
|
||||
self._path = path
|
||||
self._duration = duration_sec
|
||||
self._demo_mode = False
|
||||
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
media = self._instance.media_new(path)
|
||||
self._media_player.set_media(media)
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
# ── Transport ─────────────────────────────────────────────────────────────
|
||||
|
||||
def play(self):
|
||||
self._demo_mode = False
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.play()
|
||||
self._poll_timer.start()
|
||||
self.state_changed.emit("playing")
|
||||
|
||||
def play_demo(self, stop_at_sec: int = 10):
|
||||
"""Afspil fra start og stop automatisk ved stop_at_sec."""
|
||||
self._demo_mode = True
|
||||
self._demo_stop_sec = stop_at_sec
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.set_time(0)
|
||||
self._media_player.play()
|
||||
self._poll_timer.start()
|
||||
self.state_changed.emit("playing")
|
||||
|
||||
def pause(self):
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.pause()
|
||||
self.state_changed.emit("paused")
|
||||
|
||||
def stop(self):
|
||||
self._demo_mode = False
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.stop()
|
||||
self._poll_timer.stop()
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
return self._media_player.is_playing()
|
||||
return False
|
||||
|
||||
def set_volume(self, volume: int):
|
||||
"""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-stop
|
||||
if self._demo_mode and cur >= self._demo_stop_sec:
|
||||
self.stop()
|
||||
self._demo_mode = False
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("demo_ended")
|
||||
return
|
||||
|
||||
# VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér
|
||||
if VLC_AVAILABLE and self._media_player and self._media_player.is_playing():
|
||||
# VLC eksponerer ikke amplitude direkte — vi bruger en blød simulation
|
||||
# der er baseret på position så det ser organisk ud
|
||||
base = 0.55 + 0.3 * abs(pos - 0.5)
|
||||
l = min(1.0, base + random.gauss(0, 0.12))
|
||||
r = min(1.0, base + random.gauss(0, 0.12))
|
||||
else:
|
||||
l = r = 0.0
|
||||
|
||||
self.levels_changed.emit(max(0.0, l), max(0.0, r))
|
||||
|
||||
def _on_end_reached(self, event):
|
||||
"""Kaldes fra VLC's event-tråd — må IKKE røre Qt-objekter direkte."""
|
||||
# QTimer.singleShot er thread-safe og sender alt til main thread
|
||||
from PyQt6.QtCore import QTimer as _QTimer
|
||||
_QTimer.singleShot(0, self._handle_end_in_main_thread)
|
||||
|
||||
def _handle_end_in_main_thread(self):
|
||||
"""Kaldes i main thread — her er det sikkert at røre Qt."""
|
||||
self._poll_timer.stop()
|
||||
self.song_ended.emit()
|
||||
self.state_changed.emit("stopped")
|
||||
4
linedance-app/requirements.txt
Normal file
4
linedance-app/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
PyQt6>=6.6.0
|
||||
python-vlc>=3.0.18
|
||||
mutagen>=1.47.0
|
||||
watchdog>=4.0.0
|
||||
0
linedance-app/ui/__init__.py
Normal file
0
linedance-app/ui/__init__.py
Normal file
BIN
linedance-app/ui/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/library_panel.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/library_panel.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/main_window.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/main_window.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/themes.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/themes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc
Normal file
Binary file not shown.
221
linedance-app/ui/library_panel.py
Normal file
221
linedance-app/ui/library_panel.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||
QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar,
|
||||
QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
|
||||
from PyQt6.QtGui import QColor, QDrag
|
||||
|
||||
|
||||
class DraggableLibraryList(QListWidget):
|
||||
"""QListWidget der understøtter drag-start med sang-data som mime."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||
self.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
|
||||
def startDrag(self, supported_actions):
|
||||
item = self.currentItem()
|
||||
if not item:
|
||||
return
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not song:
|
||||
return
|
||||
|
||||
import json
|
||||
data = json.dumps(song).encode("utf-8")
|
||||
|
||||
mime = QMimeData()
|
||||
mime.setData("application/x-linedance-song", QByteArray(data))
|
||||
mime.setText(song.get("title", ""))
|
||||
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(mime)
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
|
||||
|
||||
class LibraryPanel(QWidget):
|
||||
song_selected = pyqtSignal(dict)
|
||||
add_to_playlist = pyqtSignal(dict)
|
||||
scan_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._all_songs: list[dict] = []
|
||||
self._filtered: list[dict] = []
|
||||
self._search_timer = QTimer(self)
|
||||
self._search_timer.setSingleShot(True)
|
||||
self._search_timer.setInterval(150)
|
||||
self._search_timer.timeout.connect(self._do_search)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(10, 6, 10, 6)
|
||||
lbl = QLabel("BIBLIOTEK")
|
||||
lbl.setObjectName("section_title")
|
||||
header.addWidget(lbl)
|
||||
header.addStretch()
|
||||
|
||||
self._btn_scan = QPushButton("⟳ SCAN")
|
||||
self._btn_scan.setFixedHeight(24)
|
||||
self._btn_scan.setToolTip("Scan alle biblioteksmapper for nye og ændrede filer")
|
||||
self._btn_scan.clicked.connect(self._on_scan_clicked)
|
||||
header.addWidget(self._btn_scan)
|
||||
|
||||
btn_add = QPushButton("+ MAPPE")
|
||||
btn_add.setFixedHeight(24)
|
||||
btn_add.clicked.connect(self._add_folder)
|
||||
header.addWidget(btn_add)
|
||||
layout.addLayout(header)
|
||||
|
||||
# Scan status
|
||||
self._scan_bar = QProgressBar()
|
||||
self._scan_bar.setObjectName("scan_bar")
|
||||
self._scan_bar.setTextVisible(True)
|
||||
self._scan_bar.setFormat("Scanner...")
|
||||
self._scan_bar.setFixedHeight(16)
|
||||
self._scan_bar.setRange(0, 0)
|
||||
self._scan_bar.hide()
|
||||
layout.addWidget(self._scan_bar)
|
||||
|
||||
self._scan_label = QLabel("")
|
||||
self._scan_label.setObjectName("result_count")
|
||||
self._scan_label.hide()
|
||||
layout.addWidget(self._scan_label)
|
||||
|
||||
# Søgefelt
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
|
||||
self._search.textChanged.connect(self._on_search_changed)
|
||||
layout.addWidget(self._search)
|
||||
|
||||
# Resultat-tæller + drag-hint
|
||||
hint_row = QHBoxLayout()
|
||||
hint_row.setContentsMargins(8, 2, 8, 2)
|
||||
self._count_label = QLabel("0 sange")
|
||||
self._count_label.setObjectName("result_count")
|
||||
hint_row.addWidget(self._count_label)
|
||||
hint_row.addStretch()
|
||||
drag_hint = QLabel("træk til danseliste →")
|
||||
drag_hint.setObjectName("result_count")
|
||||
hint_row.addWidget(drag_hint)
|
||||
layout.addLayout(hint_row)
|
||||
|
||||
# Liste — draggable
|
||||
self._list = DraggableLibraryList()
|
||||
self._list.setObjectName("library_list")
|
||||
self._list.itemDoubleClicked.connect(self._on_double_click)
|
||||
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||||
layout.addWidget(self._list)
|
||||
|
||||
# ── Scanning ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_scan_clicked(self):
|
||||
self.scan_requested.emit()
|
||||
|
||||
def set_scanning(self, scanning: bool, status_text: str = ""):
|
||||
if scanning:
|
||||
self._btn_scan.setEnabled(False)
|
||||
self._btn_scan.setText("⟳ SCANNER...")
|
||||
self._scan_bar.show()
|
||||
self._scan_label.setText(status_text or "Starter...")
|
||||
self._scan_label.show()
|
||||
else:
|
||||
self._btn_scan.setEnabled(True)
|
||||
self._btn_scan.setText("⟳ SCAN")
|
||||
self._scan_bar.hide()
|
||||
self._scan_label.hide()
|
||||
|
||||
def update_scan_status(self, text: str):
|
||||
self._scan_label.setText(text)
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_songs(self, songs: list[dict]):
|
||||
self._all_songs = songs
|
||||
self._do_search()
|
||||
|
||||
# ── Søgning ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_search_changed(self):
|
||||
self._search_timer.start()
|
||||
|
||||
def _do_search(self):
|
||||
q = self._search.text().strip().lower()
|
||||
self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs)
|
||||
total = len(self._all_songs)
|
||||
found = len(self._filtered)
|
||||
q_text = self._search.text().strip()
|
||||
self._count_label.setText(
|
||||
f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text
|
||||
else f"{total} sang{'e' if total != 1 else ''}"
|
||||
)
|
||||
self._render()
|
||||
|
||||
def _matches(self, song: dict, q: str) -> bool:
|
||||
return any(q in f.lower() for f in [
|
||||
song.get("title", ""), song.get("artist", ""),
|
||||
song.get("album", ""), song.get("file_format", ""),
|
||||
] + song.get("dances", []))
|
||||
|
||||
def _render(self):
|
||||
self._list.clear()
|
||||
q = self._search.text().strip().lower()
|
||||
for song in self._filtered:
|
||||
dances = song.get("dances", [])
|
||||
dance_str = " · " + " / ".join(dances) if dances else ""
|
||||
missing = song.get("file_missing", False)
|
||||
line1 = ("⚠ " if missing else "") + song.get("title", "—")
|
||||
line2 = f" {song.get('artist','—')} · {song.get('bpm',0)} BPM · {song.get('file_format','').upper()}{dance_str}"
|
||||
item = QListWidgetItem(f"{line1}\n{line2}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, song)
|
||||
if missing:
|
||||
item.setForeground(QColor("#5a6070"))
|
||||
elif q and any(q in d.lower() for d in dances):
|
||||
item.setForeground(QColor("#e8a020"))
|
||||
self._list.addItem(item)
|
||||
|
||||
# ── Handlinger ────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_double_click(self, item: QListWidgetItem):
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if song:
|
||||
self.song_selected.emit(song)
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
item = self._list.itemAt(pos)
|
||||
if not item:
|
||||
return
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not song:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
act_add = menu.addAction("Tilføj til danseliste")
|
||||
act_play = menu.addAction("Afspil")
|
||||
action = menu.exec(self._list.mapToGlobal(pos))
|
||||
if action == act_add:
|
||||
self.add_to_playlist.emit(song)
|
||||
elif action == act_play:
|
||||
self.song_selected.emit(song)
|
||||
|
||||
def _add_folder(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
mw = self.window()
|
||||
if hasattr(mw, "add_library_path"):
|
||||
mw.add_library_path(folder)
|
||||
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
|
||||
688
linedance-app/ui/main_window.py
Normal file
688
linedance-app/ui/main_window.py
Normal file
@@ -0,0 +1,688 @@
|
||||
"""
|
||||
main_window.py — Linedance afspiller hovedvindue.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QSlider, QLabel, QFrame, QSplitter,
|
||||
QSizePolicy, QMenuBar, QMenu, QStatusBar, QFileDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QAction
|
||||
|
||||
from ui.vu_meter import VUMeter
|
||||
from ui.playlist_panel import PlaylistPanel
|
||||
from ui.library_panel import LibraryPanel
|
||||
from ui.next_up_bar import NextUpBar
|
||||
from ui.themes import apply_theme
|
||||
from ui.scan_worker import ScanWorker
|
||||
from ui.login_dialog import LoginDialog
|
||||
from ui.playlist_manager import PlaylistManagerDialog
|
||||
from player.player import Player
|
||||
|
||||
|
||||
class ProgressBar(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._fraction = 0.0
|
||||
self._demo_fraction = 0.0
|
||||
self.setFixedHeight(10)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def set_fraction(self, f: float):
|
||||
self._fraction = max(0.0, min(1.0, f))
|
||||
self.update()
|
||||
|
||||
def set_demo_marker(self, f: float):
|
||||
self._demo_fraction = max(0.0, min(1.0, f))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
from PyQt6.QtGui import QPainter, QColor
|
||||
p = QPainter(self)
|
||||
w, h = self.width(), self.height()
|
||||
p.fillRect(0, 0, w, h, QColor("#2c3038"))
|
||||
fill_w = int(w * self._fraction)
|
||||
if fill_w > 0:
|
||||
p.fillRect(0, 0, fill_w, h, QColor("#e8a020"))
|
||||
if self._demo_fraction > 0:
|
||||
mx = int(w * self._demo_fraction)
|
||||
p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
|
||||
p.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
fraction = event.position().x() / self.width()
|
||||
mw = self.window()
|
||||
if hasattr(mw, "_on_seek"):
|
||||
mw._on_seek(fraction)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("LineDance Player")
|
||||
self.setMinimumSize(860, 680)
|
||||
self.resize(960, 760)
|
||||
|
||||
self._dark_theme = True
|
||||
self._player = Player(self)
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._watcher = None
|
||||
self._scan_worker = None
|
||||
self._api_url: str | None = None
|
||||
self._api_token: str | None = None
|
||||
self._api_username: str | None = None
|
||||
|
||||
self._connect_player_signals()
|
||||
self._build_menu()
|
||||
self._build_ui()
|
||||
self._build_statusbar()
|
||||
apply_theme(self._app_ref(), dark=True)
|
||||
|
||||
# Start DB og scanning ved opstart
|
||||
QTimer.singleShot(200, self._init_local_db)
|
||||
|
||||
def _app_ref(self):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
return QApplication.instance()
|
||||
|
||||
def _connect_player_signals(self):
|
||||
self._player.position_changed.connect(self._on_position)
|
||||
self._player.time_changed.connect(self._on_time)
|
||||
self._player.levels_changed.connect(self._on_levels)
|
||||
self._player.song_ended.connect(self._on_song_ended)
|
||||
self._player.state_changed.connect(self._on_state_changed)
|
||||
|
||||
# ── Menu ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_menu(self):
|
||||
menubar = self.menuBar()
|
||||
|
||||
# Filer
|
||||
file_menu = menubar.addMenu("Filer")
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
self._act_go_online = QAction("Gå online...", self)
|
||||
self._act_go_online.setShortcut("Ctrl+L")
|
||||
self._act_go_online.setToolTip("Log ind og synkroniser med server")
|
||||
self._act_go_online.triggered.connect(self._go_online)
|
||||
file_menu.addAction(self._act_go_online)
|
||||
|
||||
self._act_go_offline = QAction("Gå offline", self)
|
||||
self._act_go_offline.setToolTip("Log ud og arbejd lokalt")
|
||||
self._act_go_offline.triggered.connect(self._go_offline)
|
||||
self._act_go_offline.setEnabled(False) # kun aktiv når man er online
|
||||
file_menu.addAction(self._act_go_offline)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
act_add_folder = QAction("Tilføj musikmappe...", self)
|
||||
act_add_folder.setShortcut("Ctrl+O")
|
||||
act_add_folder.triggered.connect(self._menu_add_folder)
|
||||
file_menu.addAction(act_add_folder)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
act_scan = QAction("Scan biblioteker", self)
|
||||
act_scan.setShortcut("Ctrl+R")
|
||||
act_scan.setToolTip("Gennemgå alle biblioteksmapper for nye og ændrede filer")
|
||||
act_scan.triggered.connect(self.start_scan)
|
||||
file_menu.addAction(act_scan)
|
||||
self._act_scan = act_scan
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
act_quit = QAction("Afslut", self)
|
||||
act_quit.setShortcut("Ctrl+Q")
|
||||
act_quit.triggered.connect(self.close)
|
||||
file_menu.addAction(act_quit)
|
||||
|
||||
# Danseliste
|
||||
pl_menu = menubar.addMenu("Danseliste")
|
||||
|
||||
act_new_pl = QAction("Ny tom liste", self)
|
||||
act_new_pl.setShortcut("Ctrl+N")
|
||||
act_new_pl.triggered.connect(self._new_playlist)
|
||||
pl_menu.addAction(act_new_pl)
|
||||
|
||||
act_manage = QAction("Gem / Indlæs / Importer...", self)
|
||||
act_manage.setShortcut("Ctrl+M")
|
||||
act_manage.triggered.connect(self._open_playlist_manager)
|
||||
pl_menu.addAction(act_manage)
|
||||
|
||||
# Visning
|
||||
view_menu = menubar.addMenu("Visning")
|
||||
|
||||
act_theme = QAction("Skift tema (lyst/mørkt)", self)
|
||||
act_theme.setShortcut("Ctrl+T")
|
||||
act_theme.triggered.connect(self._toggle_theme)
|
||||
view_menu.addAction(act_theme)
|
||||
|
||||
# ── Statuslinje ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_statusbar(self):
|
||||
self._statusbar = QStatusBar()
|
||||
self.setStatusBar(self._statusbar)
|
||||
self._statusbar.showMessage("Klar")
|
||||
|
||||
def _set_status(self, text: str, timeout_ms: int = 0):
|
||||
"""Vis besked i statuslinjen. timeout_ms=0 = permanent."""
|
||||
self._statusbar.showMessage(text, timeout_ms)
|
||||
|
||||
# ── UI byggeri ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QWidget()
|
||||
root.setObjectName("root")
|
||||
self.setCentralWidget(root)
|
||||
main_layout = QVBoxLayout(root)
|
||||
main_layout.setContentsMargins(10, 6, 10, 10)
|
||||
main_layout.setSpacing(4)
|
||||
|
||||
main_layout.addWidget(self._build_topbar())
|
||||
main_layout.addWidget(self._build_now_playing())
|
||||
main_layout.addWidget(self._build_progress())
|
||||
main_layout.addWidget(self._build_next_up())
|
||||
main_layout.addWidget(self._build_transport())
|
||||
main_layout.addWidget(self._build_panels(), stretch=1)
|
||||
|
||||
def _build_topbar(self) -> QFrame:
|
||||
bar = QFrame()
|
||||
bar.setObjectName("topbar")
|
||||
layout = QHBoxLayout(bar)
|
||||
layout.setContentsMargins(12, 6, 12, 6)
|
||||
|
||||
logo = QLabel("LINE<span style='color:#9aa0b0;font-weight:400'>DANCE</span> PLAYER")
|
||||
logo.setObjectName("logo")
|
||||
logo.setTextFormat(Qt.TextFormat.RichText)
|
||||
layout.addWidget(logo)
|
||||
layout.addStretch()
|
||||
|
||||
self._conn_label = QLabel("● OFFLINE")
|
||||
self._conn_label.setObjectName("conn_label")
|
||||
layout.addWidget(self._conn_label)
|
||||
|
||||
self._theme_btn = QPushButton("☀ LYS TEMA")
|
||||
self._theme_btn.setFixedHeight(26)
|
||||
self._theme_btn.clicked.connect(self._toggle_theme)
|
||||
layout.addWidget(self._theme_btn)
|
||||
|
||||
return bar
|
||||
|
||||
def _build_now_playing(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("now_playing_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
track_frame = QFrame()
|
||||
track_frame.setObjectName("track_display")
|
||||
track_layout = QVBoxLayout(track_frame)
|
||||
track_layout.setContentsMargins(10, 8, 10, 8)
|
||||
track_layout.setSpacing(3)
|
||||
|
||||
self._lbl_title = QLabel("—")
|
||||
self._lbl_title.setObjectName("track_title")
|
||||
track_layout.addWidget(self._lbl_title)
|
||||
|
||||
self._lbl_meta = QLabel("—")
|
||||
self._lbl_meta.setObjectName("track_meta")
|
||||
track_layout.addWidget(self._lbl_meta)
|
||||
|
||||
self._lbl_dances = QLabel("")
|
||||
self._lbl_dances.setObjectName("track_meta")
|
||||
self._lbl_dances.setWordWrap(True)
|
||||
track_layout.addWidget(self._lbl_dances)
|
||||
|
||||
layout.addWidget(track_frame, stretch=1)
|
||||
|
||||
self._vu = VUMeter()
|
||||
layout.addWidget(self._vu)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_progress(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("progress_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 6, 12, 6)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._lbl_cur = QLabel("0:00")
|
||||
self._lbl_cur.setObjectName("track_meta")
|
||||
self._lbl_cur.setFixedWidth(36)
|
||||
layout.addWidget(self._lbl_cur)
|
||||
|
||||
self._progress = ProgressBar(self)
|
||||
self._progress.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
layout.addWidget(self._progress, stretch=1)
|
||||
|
||||
self._lbl_tot = QLabel("0:00")
|
||||
self._lbl_tot.setObjectName("track_meta")
|
||||
self._lbl_tot.setFixedWidth(36)
|
||||
self._lbl_tot.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
layout.addWidget(self._lbl_tot)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_next_up(self) -> NextUpBar:
|
||||
self._next_up = NextUpBar()
|
||||
self._next_up.play_next_clicked.connect(self._play_next)
|
||||
return self._next_up
|
||||
|
||||
def _build_transport(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("transport_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(14, 10, 14, 10)
|
||||
layout.setSpacing(8)
|
||||
|
||||
def btn(text, name=None, size=52, checkable=False):
|
||||
b = QPushButton(text)
|
||||
if name:
|
||||
b.setObjectName(name)
|
||||
b.setFixedSize(size, size)
|
||||
if checkable:
|
||||
b.setCheckable(True)
|
||||
return b
|
||||
|
||||
self._btn_prev = btn("⏮", size=52)
|
||||
self._btn_play = btn("▶", "btn_play", size=72)
|
||||
self._btn_stop = btn("⏹", "btn_stop", size=52)
|
||||
self._btn_next = btn("⏭", size=52)
|
||||
self._btn_demo = btn("▶\n10 SEK", "btn_demo", size=64, checkable=True)
|
||||
|
||||
self._btn_prev.clicked.connect(self._prev_song)
|
||||
self._btn_play.clicked.connect(self._toggle_play)
|
||||
self._btn_stop.clicked.connect(self._stop)
|
||||
self._btn_next.clicked.connect(self._next_song)
|
||||
self._btn_demo.clicked.connect(self._toggle_demo)
|
||||
|
||||
layout.addWidget(self._btn_prev)
|
||||
layout.addWidget(self._btn_play)
|
||||
layout.addWidget(self._btn_stop)
|
||||
layout.addWidget(self._btn_next)
|
||||
|
||||
sep1 = QFrame()
|
||||
sep1.setFrameShape(QFrame.Shape.VLine)
|
||||
sep1.setFixedWidth(1)
|
||||
layout.addWidget(sep1)
|
||||
|
||||
layout.addWidget(self._btn_demo)
|
||||
layout.addStretch()
|
||||
|
||||
lbl_vol = QLabel("VOL")
|
||||
lbl_vol.setObjectName("vol_label")
|
||||
layout.addWidget(lbl_vol)
|
||||
|
||||
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._vol_slider.setRange(0, 100)
|
||||
self._vol_slider.setValue(78)
|
||||
self._vol_slider.setFixedWidth(100)
|
||||
self._vol_slider.valueChanged.connect(self._on_volume)
|
||||
layout.addWidget(self._vol_slider)
|
||||
|
||||
self._lbl_vol = QLabel("78")
|
||||
self._lbl_vol.setObjectName("vol_val")
|
||||
layout.addWidget(self._lbl_vol)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_panels(self) -> QSplitter:
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
self._playlist_panel = PlaylistPanel()
|
||||
self._playlist_panel.song_selected.connect(self._load_song_by_idx)
|
||||
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
|
||||
|
||||
self._library_panel = LibraryPanel()
|
||||
self._library_panel.song_selected.connect(self._on_library_song_selected)
|
||||
self._library_panel.add_to_playlist.connect(self._add_song_to_playlist)
|
||||
self._library_panel.scan_requested.connect(self.start_scan)
|
||||
|
||||
splitter.addWidget(self._playlist_panel)
|
||||
splitter.addWidget(self._library_panel)
|
||||
splitter.setSizes([480, 480])
|
||||
|
||||
return splitter
|
||||
|
||||
# ── Lokal DB + scanning ───────────────────────────────────────────────────
|
||||
|
||||
def _init_local_db(self):
|
||||
try:
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from local.local_db import init_db
|
||||
from local.file_watcher import get_watcher
|
||||
|
||||
init_db()
|
||||
|
||||
def on_file_change(event_type, path, song_id):
|
||||
QTimer.singleShot(500, self._reload_library)
|
||||
|
||||
self._watcher = get_watcher(on_change=on_file_change)
|
||||
self._watcher.start()
|
||||
|
||||
# Indlæs hvad vi allerede kender fra SQLite
|
||||
self._reload_library()
|
||||
|
||||
# Kør automatisk scanning ved opstart
|
||||
self._set_status("Starter scanning af biblioteker...")
|
||||
QTimer.singleShot(100, self.start_scan)
|
||||
|
||||
except Exception as e:
|
||||
self._set_status(f"DB fejl: {e}")
|
||||
print(f"DB init fejl: {e}")
|
||||
|
||||
def start_scan(self):
|
||||
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
return # Scanning kører allerede
|
||||
|
||||
if not self._watcher:
|
||||
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
|
||||
return
|
||||
|
||||
self._library_panel.set_scanning(True, "Forbereder scanning...")
|
||||
self._act_scan.setEnabled(False)
|
||||
|
||||
self._scan_worker = ScanWorker(self._watcher, parent=self)
|
||||
self._scan_worker.status_update.connect(self._on_scan_status)
|
||||
self._scan_worker.scan_done.connect(self._on_scan_done)
|
||||
self._scan_worker.start()
|
||||
|
||||
def _on_scan_status(self, text: str):
|
||||
self._set_status(text)
|
||||
self._library_panel.update_scan_status(text)
|
||||
|
||||
def _on_scan_done(self, count: int):
|
||||
self._library_panel.set_scanning(False)
|
||||
self._act_scan.setEnabled(True)
|
||||
msg = f"Scanning færdig — {count} filer gennemgået"
|
||||
self._set_status(msg, timeout_ms=5000)
|
||||
# Genindlæs biblioteket
|
||||
QTimer.singleShot(200, self._reload_library)
|
||||
|
||||
def _reload_library(self):
|
||||
try:
|
||||
from local.local_db import search_songs, get_db
|
||||
songs_raw = search_songs("", limit=5000)
|
||||
songs = []
|
||||
for row in songs_raw:
|
||||
with get_db() as conn:
|
||||
dances = conn.execute(
|
||||
"SELECT dance_name FROM song_dances "
|
||||
"WHERE song_id=? ORDER BY dance_order",
|
||||
(row["id"],)
|
||||
).fetchall()
|
||||
songs.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"artist": row["artist"],
|
||||
"album": row["album"],
|
||||
"bpm": row["bpm"],
|
||||
"duration_sec": row["duration_sec"],
|
||||
"local_path": row["local_path"],
|
||||
"file_format": row["file_format"],
|
||||
"file_missing": bool(row["file_missing"]),
|
||||
"dances": [d["dance_name"] for d in dances],
|
||||
})
|
||||
self._library_panel.load_songs(songs)
|
||||
count = len(songs)
|
||||
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
|
||||
except Exception as e:
|
||||
print(f"Bibliotek reload fejl: {e}")
|
||||
|
||||
def add_library_path(self, path: str):
|
||||
try:
|
||||
self._watcher.add_library(path)
|
||||
self._set_status(f"Tilføjet: {path} — scanner...")
|
||||
except Exception as e:
|
||||
self._set_status(f"Fejl: {e}")
|
||||
|
||||
def _go_online(self):
|
||||
dialog = LoginDialog(self)
|
||||
if dialog.exec():
|
||||
url, username, token = dialog.get_credentials()
|
||||
self._api_url = url
|
||||
self._api_token = token
|
||||
self._api_username = username
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Online som {username}", 5000)
|
||||
|
||||
def _go_offline(self):
|
||||
self._api_url = self._api_token = self._api_username = None
|
||||
self._set_online_state(False)
|
||||
self._set_status("Offline — arbejder lokalt", 3000)
|
||||
|
||||
def _set_online_state(self, online: bool):
|
||||
self._act_go_online.setEnabled(not online)
|
||||
self._act_go_offline.setEnabled(online)
|
||||
if online:
|
||||
name = self._api_username or "?"
|
||||
self._conn_label.setText(f"● ONLINE ({name})")
|
||||
self._conn_label.setStyleSheet("color: #2ecc71;")
|
||||
else:
|
||||
self._conn_label.setText("● OFFLINE")
|
||||
self._conn_label.setStyleSheet("color: #5a6070;")
|
||||
|
||||
def _new_playlist(self):
|
||||
self._stop()
|
||||
self._playlist_panel.load_songs([])
|
||||
self._playlist_panel.set_playlist_name("Ny liste")
|
||||
self._set_status("Ny danseliste oprettet", 2000)
|
||||
|
||||
def _open_playlist_manager(self):
|
||||
dialog = PlaylistManagerDialog(
|
||||
current_songs=self._playlist_panel.get_songs(),
|
||||
parent=self,
|
||||
)
|
||||
dialog.playlist_loaded.connect(self._on_playlist_loaded)
|
||||
dialog.exec()
|
||||
|
||||
def _on_playlist_loaded(self, name: str, songs: list[dict]):
|
||||
self._stop()
|
||||
self._playlist_panel.load_songs(songs)
|
||||
self._playlist_panel.set_playlist_name(name)
|
||||
self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000)
|
||||
|
||||
def _on_song_dropped(self, song: dict):
|
||||
self._set_status(f"Tilføjet: {song.get('title','')}", 2000)
|
||||
|
||||
def _menu_add_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
self.add_library_path(folder)
|
||||
|
||||
# ── Afspilning ────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_song(self, song: dict):
|
||||
self._current_song = song
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._next_up.hide_bar()
|
||||
|
||||
dur = song.get("duration_sec", 0)
|
||||
self._player.load(song.get("local_path", ""), dur)
|
||||
|
||||
self._lbl_title.setText(song.get("title", "—"))
|
||||
bpm = song.get("bpm", 0)
|
||||
fmt_dur = f"{dur//60}:{dur%60:02d}"
|
||||
self._lbl_meta.setText(f"{song.get('artist','')} · {bpm} BPM · {fmt_dur}")
|
||||
|
||||
dances = song.get("dances", [])
|
||||
self._lbl_dances.setText(
|
||||
" · ".join(f"[{d}]" for d in dances) if dances else "ingen danse tagget"
|
||||
)
|
||||
|
||||
if dur > 0:
|
||||
self._progress.set_demo_marker(min(10 / dur, 1.0))
|
||||
|
||||
self._set_status(f"Indlæst: {song.get('title','—')}", 3000)
|
||||
|
||||
def _load_song_by_idx(self, idx: int):
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if not song:
|
||||
return
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._playlist_panel.set_current(idx)
|
||||
|
||||
def _toggle_play(self):
|
||||
if self._song_ended:
|
||||
self._play_next()
|
||||
return
|
||||
if self._demo_active:
|
||||
self._player.stop()
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
return
|
||||
if self._player.is_playing():
|
||||
self._player.pause()
|
||||
else:
|
||||
self._player.play()
|
||||
|
||||
def _stop(self):
|
||||
self._player.stop()
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._next_up.hide_bar()
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
def _toggle_demo(self):
|
||||
if self._demo_active:
|
||||
self._player.stop()
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
else:
|
||||
self._demo_active = True
|
||||
self._btn_demo.setChecked(True)
|
||||
self._player.play_demo(stop_at_sec=10)
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _prev_song(self):
|
||||
if self._current_idx > 0:
|
||||
self._stop()
|
||||
self._load_song_by_idx(self._current_idx - 1)
|
||||
|
||||
def _next_song(self):
|
||||
if self._current_idx < self._playlist_panel.count() - 1:
|
||||
self._stop()
|
||||
self._playlist_panel.mark_played(self._current_idx)
|
||||
self._load_song_by_idx(self._current_idx + 1)
|
||||
|
||||
def _play_next(self):
|
||||
ni = self._current_idx + 1
|
||||
if ni < self._playlist_panel.count():
|
||||
self._song_ended = False
|
||||
self._next_up.hide_bar()
|
||||
self._load_song_by_idx(ni)
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _on_library_song_selected(self, song: dict):
|
||||
self._load_song(song)
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _add_song_to_playlist(self, song: dict):
|
||||
songs = [self._playlist_panel.get_song(i)
|
||||
for i in range(self._playlist_panel.count())]
|
||||
songs = [s for s in songs if s]
|
||||
songs.append(song)
|
||||
self._playlist_panel.load_songs(songs)
|
||||
self._set_status(f"Tilføjet til danseliste: {song.get('title','')}", 2000)
|
||||
|
||||
# ── Player signals ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_position(self, fraction: float):
|
||||
self._progress.set_fraction(fraction)
|
||||
|
||||
def _on_time(self, cur: int, tot: int):
|
||||
self._lbl_cur.setText(f"{cur//60}:{cur%60:02d}")
|
||||
self._lbl_tot.setText(f"{tot//60}:{tot%60:02d}")
|
||||
|
||||
def _on_levels(self, left: float, right: float):
|
||||
self._vu.set_levels(left, right)
|
||||
|
||||
def _on_song_ended(self):
|
||||
self._song_ended = True
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
# Markér den afspillede sang
|
||||
self._playlist_panel.mark_played(self._current_idx)
|
||||
|
||||
# Fremhæv næste sang i listen — men afspil den IKKE
|
||||
ni = self._current_idx + 1
|
||||
next_song = self._playlist_panel.get_song(ni)
|
||||
if next_song:
|
||||
# set_current med song_ended=True markerer næste som "next" (blå)
|
||||
# uden at ændre _current_idx i main_window
|
||||
self._playlist_panel.set_current(self._current_idx, song_ended=True)
|
||||
self._next_up.show_next(
|
||||
next_song.get("title", ""),
|
||||
next_song.get("artist", ""),
|
||||
next_song.get("dances", []),
|
||||
)
|
||||
else:
|
||||
self._lbl_title.setText("— Danseliste afsluttet —")
|
||||
self._set_status("Danselisten er afsluttet")
|
||||
|
||||
def _on_state_changed(self, state: str):
|
||||
if state == "playing":
|
||||
self._btn_play.setText("⏸")
|
||||
elif state in ("paused", "stopped"):
|
||||
self._btn_play.setText("▶")
|
||||
if state == "stopped" and not self._song_ended:
|
||||
self._vu.reset()
|
||||
elif state == "demo_ended":
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
def _on_seek(self, fraction: float):
|
||||
self._player.set_position(fraction)
|
||||
|
||||
def _on_volume(self, value: int):
|
||||
self._lbl_vol.setText(str(value))
|
||||
self._player.set_volume(value)
|
||||
|
||||
# ── Tema ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_theme(self):
|
||||
self._dark_theme = not self._dark_theme
|
||||
apply_theme(self._app_ref(), dark=self._dark_theme)
|
||||
self._theme_btn.setText(
|
||||
"● MØRKT TEMA" if not self._dark_theme else "☀ LYS TEMA"
|
||||
)
|
||||
self._vu.set_dark(self._dark_theme)
|
||||
|
||||
# ── Luk ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._player.stop()
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
self._scan_worker.quit()
|
||||
self._scan_worker.wait(2000)
|
||||
try:
|
||||
if self._watcher:
|
||||
self._watcher.stop()
|
||||
except Exception:
|
||||
pass
|
||||
event.accept()
|
||||
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}")
|
||||
278
linedance-app/ui/playlist_panel.py
Normal file
278
linedance-app/ui/playlist_panel.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
playlist_panel.py — Danseliste med event-overblik, drag-and-drop og højreklik.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QMimeData
|
||||
from PyQt6.QtGui import QColor, QFont, QDragEnterEvent, QDropEvent
|
||||
|
||||
|
||||
class PlaylistPanel(QWidget):
|
||||
song_selected = pyqtSignal(int) # dobbeltklik → indlæs sang
|
||||
status_changed = pyqtSignal(int, str) # (indeks, ny_status)
|
||||
song_dropped = pyqtSignal(dict) # sang droppet fra bibliotek
|
||||
|
||||
STATUS_ICON = {
|
||||
"pending": " ",
|
||||
"playing": " ▶ ",
|
||||
"played": " ✓ ",
|
||||
"skipped": " — ",
|
||||
"next": " ▷ ",
|
||||
}
|
||||
STATUS_COLOR = {
|
||||
"pending": "#5a6070",
|
||||
"playing": "#e8a020",
|
||||
"played": "#2ecc71",
|
||||
"skipped": "#e74c3c",
|
||||
"next": "#3b8fd4",
|
||||
}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._songs: list[dict] = []
|
||||
self._statuses: list[str] = []
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._build_ui()
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(10, 6, 10, 6)
|
||||
self._title_label = QLabel("DANSELISTE")
|
||||
self._title_label.setObjectName("section_title")
|
||||
header.addWidget(self._title_label)
|
||||
header.addStretch()
|
||||
layout.addLayout(header)
|
||||
|
||||
# Event-kontrol-linje
|
||||
ctrl = QHBoxLayout()
|
||||
ctrl.setContentsMargins(8, 4, 8, 4)
|
||||
ctrl.setSpacing(6)
|
||||
|
||||
self._btn_start = QPushButton("▶ START EVENT")
|
||||
self._btn_start.setObjectName("btn_start_event")
|
||||
self._btn_start.setFixedHeight(28)
|
||||
self._btn_start.setToolTip("Nulstil alle statusser og start eventet fra top")
|
||||
self._btn_start.clicked.connect(self._start_event)
|
||||
ctrl.addWidget(self._btn_start)
|
||||
|
||||
ctrl.addStretch()
|
||||
|
||||
self._lbl_progress = QLabel("0 / 0")
|
||||
self._lbl_progress.setObjectName("result_count")
|
||||
ctrl.addWidget(self._lbl_progress)
|
||||
|
||||
layout.addLayout(ctrl)
|
||||
|
||||
# Kolonneheader
|
||||
col_header = QHBoxLayout()
|
||||
col_header.setContentsMargins(10, 2, 10, 2)
|
||||
for text, stretch in [("#", 0), ("Titel / Dans", 1), ("Status", 0)]:
|
||||
lbl = QLabel(text)
|
||||
lbl.setObjectName("result_count")
|
||||
if stretch:
|
||||
col_header.addWidget(lbl, stretch=1)
|
||||
else:
|
||||
lbl.setFixedWidth(30 if text == "#" else 50)
|
||||
col_header.addWidget(lbl)
|
||||
layout.addLayout(col_header)
|
||||
|
||||
# Liste
|
||||
self._list = QListWidget()
|
||||
self._list.setObjectName("playlist_list")
|
||||
self._list.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
|
||||
self._list.setAcceptDrops(True)
|
||||
self._list.itemDoubleClicked.connect(self._on_double_click)
|
||||
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||||
layout.addWidget(self._list)
|
||||
|
||||
# ── Drag & drop ───────────────────────────────────────────────────────────
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.mimeData().hasFormat("application/x-linedance-song"):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
mime = event.mimeData()
|
||||
if mime.hasFormat("application/x-linedance-song"):
|
||||
import json
|
||||
data = mime.data("application/x-linedance-song").data()
|
||||
song = json.loads(data.decode("utf-8"))
|
||||
self._append_song(song)
|
||||
self.song_dropped.emit(song)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _append_song(self, song: dict):
|
||||
self._songs.append(song)
|
||||
self._statuses.append("pending")
|
||||
self._refresh()
|
||||
|
||||
# ── Data ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_songs(self, songs: list[dict], reset_statuses: bool = True):
|
||||
self._songs = list(songs)
|
||||
if reset_statuses:
|
||||
self._statuses = ["pending"] * len(songs)
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._refresh()
|
||||
|
||||
def set_current(self, idx: int, song_ended: bool = False):
|
||||
self._current_idx = idx
|
||||
self._song_ended = song_ended
|
||||
if 0 <= idx < len(self._statuses) and not song_ended:
|
||||
self._statuses[idx] = "playing"
|
||||
self._refresh()
|
||||
self._scroll_to(idx)
|
||||
|
||||
def mark_played(self, idx: int):
|
||||
if 0 <= idx < len(self._statuses):
|
||||
self._statuses[idx] = "played"
|
||||
self._refresh()
|
||||
|
||||
def get_song(self, idx: int) -> dict | None:
|
||||
return self._songs[idx] if 0 <= idx < len(self._songs) else None
|
||||
|
||||
def get_songs(self) -> list[dict]:
|
||||
return list(self._songs)
|
||||
|
||||
def get_statuses(self) -> list[str]:
|
||||
return list(self._statuses)
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._songs)
|
||||
|
||||
# ── Event-styring ─────────────────────────────────────────────────────────
|
||||
|
||||
def _start_event(self):
|
||||
if not self._songs:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, "Start event",
|
||||
"Dette nulstiller alle statusser i danselisten.\nFortsæt?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self._statuses = ["pending"] * len(self._songs)
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._refresh()
|
||||
|
||||
# ── Højreklik-menu ────────────────────────────────────────────────────────
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
item = self._list.itemAt(pos)
|
||||
if not item:
|
||||
return
|
||||
idx = item.data(Qt.ItemDataRole.UserRole)
|
||||
if idx is None:
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("QMenu { padding: 4px; } QMenu::item { padding: 6px 20px; }")
|
||||
|
||||
act_play = menu.addAction("▶ Afspil denne")
|
||||
menu.addSeparator()
|
||||
act_skip = menu.addAction("— Spring over")
|
||||
act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
|
||||
act_played = menu.addAction("✓ Sæt til afspillet")
|
||||
menu.addSeparator()
|
||||
act_remove = menu.addAction("✕ Fjern fra liste")
|
||||
|
||||
action = menu.exec(self._list.mapToGlobal(pos))
|
||||
|
||||
if action == act_play:
|
||||
self.song_selected.emit(idx)
|
||||
elif action == act_skip:
|
||||
self._statuses[idx] = "skipped"
|
||||
self.status_changed.emit(idx, "skipped")
|
||||
self._refresh()
|
||||
elif action == act_unplay:
|
||||
self._statuses[idx] = "pending"
|
||||
self.status_changed.emit(idx, "pending")
|
||||
self._refresh()
|
||||
elif action == act_played:
|
||||
self._statuses[idx] = "played"
|
||||
self.status_changed.emit(idx, "played")
|
||||
self._refresh()
|
||||
elif action == act_remove:
|
||||
self._songs.pop(idx)
|
||||
self._statuses.pop(idx)
|
||||
if self._current_idx >= idx:
|
||||
self._current_idx = max(-1, self._current_idx - 1)
|
||||
self._refresh()
|
||||
|
||||
# ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _refresh(self):
|
||||
self._list.clear()
|
||||
played_count = sum(1 for s in self._statuses if s == "played")
|
||||
self._lbl_progress.setText(f"{played_count} / {len(self._songs)} afspillet")
|
||||
|
||||
for i, song in enumerate(self._songs):
|
||||
is_current = (i == self._current_idx and not self._song_ended)
|
||||
is_next = (self._song_ended and i == self._current_idx + 1)
|
||||
|
||||
if is_current:
|
||||
status = "playing"
|
||||
elif is_next:
|
||||
status = "next"
|
||||
else:
|
||||
status = self._statuses[i]
|
||||
|
||||
icon = self.STATUS_ICON.get(status, " ")
|
||||
color = self.STATUS_COLOR.get(status, "#5a6070")
|
||||
|
||||
dances = " / ".join(song.get("dances", [])) or "ingen dans tagget"
|
||||
text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}"
|
||||
|
||||
item = QListWidgetItem(f"{icon} {text}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, i)
|
||||
|
||||
# Farver
|
||||
if status == "playing":
|
||||
item.setForeground(QColor("#e8a020"))
|
||||
font = item.font()
|
||||
font.setBold(True)
|
||||
item.setFont(font)
|
||||
elif status == "next":
|
||||
item.setForeground(QColor("#3b8fd4"))
|
||||
font = item.font()
|
||||
font.setBold(True)
|
||||
item.setFont(font)
|
||||
elif status == "played":
|
||||
item.setForeground(QColor("#2ecc71"))
|
||||
elif status == "skipped":
|
||||
item.setForeground(QColor("#e74c3c"))
|
||||
else:
|
||||
item.setForeground(QColor("#9aa0b0"))
|
||||
|
||||
self._list.addItem(item)
|
||||
|
||||
def set_playlist_name(self, name: str):
|
||||
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
||||
|
||||
def _scroll_to(self, idx: int):
|
||||
if 0 <= idx < self._list.count():
|
||||
self._list.scrollToItem(
|
||||
self._list.item(idx),
|
||||
QListWidget.ScrollHint.PositionAtCenter,
|
||||
)
|
||||
|
||||
def _on_double_click(self, item: QListWidgetItem):
|
||||
idx = item.data(Qt.ItemDataRole.UserRole)
|
||||
if idx is not None:
|
||||
self.song_selected.emit(idx)
|
||||
54
linedance-app/ui/scan_worker.py
Normal file
54
linedance-app/ui/scan_worker.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
|
||||
så GUI ikke fryser.
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
class ScanWorker(QThread):
|
||||
"""
|
||||
Kører _full_scan_all() i en baggrundstråd.
|
||||
Sender status-opdateringer undervejs.
|
||||
"""
|
||||
status_update = pyqtSignal(str) # løbende statusbeskeder
|
||||
scan_done = pyqtSignal(int) # antal behandlede filer
|
||||
|
||||
def __init__(self, watcher, parent=None):
|
||||
super().__init__(parent)
|
||||
self._watcher = watcher
|
||||
self._total = 0
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from local.local_db import get_libraries
|
||||
libraries = get_libraries(active_only=True)
|
||||
|
||||
if not libraries:
|
||||
self.status_update.emit("Ingen biblioteker konfigureret")
|
||||
self.scan_done.emit(0)
|
||||
return
|
||||
|
||||
total_processed = 0
|
||||
for lib in libraries:
|
||||
from pathlib import Path
|
||||
path = Path(lib["path"])
|
||||
name = path.name
|
||||
self.status_update.emit(f"Scanner: {name}...")
|
||||
|
||||
# Tæl filer først så vi kan vise fremgang
|
||||
from local.tag_reader import is_supported
|
||||
files = [f for f in path.rglob("*") if f.is_file() and is_supported(f)]
|
||||
count = len(files)
|
||||
self.status_update.emit(f"Scanner: {name} ({count} filer)...")
|
||||
|
||||
# Kør scanning
|
||||
self._watcher._full_scan_library(lib["id"], str(path))
|
||||
total_processed += count
|
||||
|
||||
self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået")
|
||||
self.scan_done.emit(total_processed)
|
||||
|
||||
except Exception as e:
|
||||
self.status_update.emit(f"Scan fejl: {e}")
|
||||
self.scan_done.emit(0)
|
||||
293
linedance-app/ui/themes.py
Normal file
293
linedance-app/ui/themes.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
themes.py — Lyst og mørkt tema til PyQt6.
|
||||
"""
|
||||
|
||||
DARK = """
|
||||
QWidget {
|
||||
background-color: #1a1c1f;
|
||||
color: #e8eaf0;
|
||||
font-family: 'Barlow', 'Segoe UI', sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
QMainWindow, #root {
|
||||
background-color: #111214;
|
||||
}
|
||||
|
||||
/* Knapper */
|
||||
QPushButton {
|
||||
background-color: #30343c;
|
||||
color: #9aa0b0;
|
||||
border: 1px solid #4a5060;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #454a56;
|
||||
color: #e8eaf0;
|
||||
border-color: #e8a020;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #22252a;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
border-color: #c47a10;
|
||||
}
|
||||
QPushButton#btn_play {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
border-color: #c47a10;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton#btn_play:hover {
|
||||
background-color: #c47a10;
|
||||
}
|
||||
QPushButton#btn_stop {
|
||||
color: #e74c3c;
|
||||
}
|
||||
QPushButton#btn_stop:hover {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
QPushButton#btn_demo {
|
||||
color: #3b8fd4;
|
||||
border-color: #3b8fd4;
|
||||
font-size: 11px;
|
||||
}
|
||||
QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
|
||||
background-color: #3b8fd4;
|
||||
color: #111214;
|
||||
border-color: #3b8fd4;
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
QSlider::groove:horizontal {
|
||||
height: 4px;
|
||||
background: #2c3038;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #e8a020;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #e8a020;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -4px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Lister */
|
||||
QListWidget {
|
||||
background-color: #1a1c1f;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #22252a;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #2c3038;
|
||||
color: #e8eaf0;
|
||||
border-left: 2px solid #e8a020;
|
||||
}
|
||||
QListWidget::item:hover {
|
||||
background-color: #22252a;
|
||||
}
|
||||
|
||||
/* Søgefelt */
|
||||
QLineEdit {
|
||||
background-color: #111214;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 3px;
|
||||
padding: 5px 8px;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #e8a020;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
QLabel#track_title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #e8eaf0;
|
||||
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
|
||||
}
|
||||
QLabel#track_meta {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
QLabel#section_title {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #5a6070;
|
||||
letter-spacing: 2px;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 6px 10px;
|
||||
background-color: #22252a;
|
||||
border-bottom: 1px solid #3a3e46;
|
||||
}
|
||||
QLabel#next_up_label {
|
||||
color: #e8a020;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
QLabel#next_up_title {
|
||||
font-size: 17px;
|
||||
font-weight: bold;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
QLabel#next_up_sub {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
QLabel#vol_label {
|
||||
font-size: 10px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
QLabel#vol_val {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-width: 28px;
|
||||
}
|
||||
QLabel#result_count {
|
||||
font-size: 10px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
/* Frames / paneler */
|
||||
QFrame#panel {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QFrame#now_playing_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
QFrame#track_display {
|
||||
background-color: #111214;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
}
|
||||
QFrame#transport_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
QFrame#next_up_frame {
|
||||
background-color: #22252a;
|
||||
border: 1px solid #e8a020;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
QFrame#progress_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
QScrollBar:vertical {
|
||||
background: #1a1c1f;
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #4a5060;
|
||||
border-radius: 3px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
|
||||
/* Topbar */
|
||||
QFrame#topbar {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QLabel#logo {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
color: #e8a020;
|
||||
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
|
||||
}
|
||||
QLabel#conn_label {
|
||||
font-size: 11px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
"""
|
||||
|
||||
LIGHT = DARK + """
|
||||
QWidget {
|
||||
background-color: #d8dae0;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QMainWindow, #root {
|
||||
background-color: #c8cad0;
|
||||
}
|
||||
QPushButton {
|
||||
background-color: #b0b4bc;
|
||||
color: #4a5060;
|
||||
border-color: #8890a0;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #c8ccd4;
|
||||
color: #1a1c22;
|
||||
border-color: #c07010;
|
||||
}
|
||||
QPushButton#btn_play {
|
||||
background-color: #c07010;
|
||||
color: #fff;
|
||||
border-color: #a05808;
|
||||
}
|
||||
QListWidget {
|
||||
background-color: #d8dae0;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #eef0f4;
|
||||
border-left: 2px solid #c07010;
|
||||
}
|
||||
QLineEdit {
|
||||
background-color: #c8cad0;
|
||||
border-color: #aab0bc;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QLineEdit:focus { border-color: #c07010; }
|
||||
QFrame#panel, QFrame#now_playing_frame,
|
||||
QFrame#transport_frame, QFrame#progress_frame {
|
||||
background-color: #d8dae0;
|
||||
border-color: #aab0bc;
|
||||
}
|
||||
QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; }
|
||||
QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; }
|
||||
QLabel#section_title { background-color: #e4e6ec; color: #8890a0; border-color: #aab0bc; }
|
||||
QLabel#track_title { color: #1a1c22; }
|
||||
QLabel#track_meta { color: #4a5060; }
|
||||
QSlider::groove:horizontal { background: #b0b4bc; }
|
||||
QScrollBar:vertical { background: #d8dae0; }
|
||||
QScrollBar::handle:vertical { background: #8890a0; }
|
||||
"""
|
||||
|
||||
|
||||
def apply_theme(app, dark: bool = True):
|
||||
app.setStyleSheet(DARK if dark else LIGHT)
|
||||
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()
|
||||
247
linedance-app/venv/bin/Activate.ps1
Normal file
247
linedance-app/venv/bin/Activate.ps1
Normal file
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
70
linedance-app/venv/bin/activate
Normal file
70
linedance-app/venv/bin/activate
Normal file
@@ -0,0 +1,70 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# You cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||
export VIRTUAL_ENV=$(cygpath /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv)
|
||||
else
|
||||
# use the path as-is
|
||||
export VIRTUAL_ENV=/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv
|
||||
fi
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1='(venv) '"${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT='(venv) '
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
27
linedance-app/venv/bin/activate.csh
Normal file
27
linedance-app/venv/bin/activate.csh
Normal file
@@ -0,0 +1,27 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = '(venv) '"$prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT '(venv) '
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
69
linedance-app/venv/bin/activate.fish
Normal file
69
linedance-app/venv/bin/activate.fish
Normal file
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/). You cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT '(venv) '
|
||||
end
|
||||
8
linedance-app/venv/bin/mid3cp
Executable file
8
linedance-app/venv/bin/mid3cp
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from mutagen._tools.mid3cp import entry_point
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(entry_point())
|
||||
8
linedance-app/venv/bin/mid3iconv
Executable file
8
linedance-app/venv/bin/mid3iconv
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from mutagen._tools.mid3iconv import entry_point
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(entry_point())
|
||||
8
linedance-app/venv/bin/mid3v2
Executable file
8
linedance-app/venv/bin/mid3v2
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from mutagen._tools.mid3v2 import entry_point
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(entry_point())
|
||||
8
linedance-app/venv/bin/moggsplit
Executable file
8
linedance-app/venv/bin/moggsplit
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from mutagen._tools.moggsplit import entry_point
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(entry_point())
|
||||
8
linedance-app/venv/bin/mutagen-inspect
Executable file
8
linedance-app/venv/bin/mutagen-inspect
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from mutagen._tools.mutagen_inspect import entry_point
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(entry_point())
|
||||
8
linedance-app/venv/bin/mutagen-pony
Executable file
8
linedance-app/venv/bin/mutagen-pony
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from mutagen._tools.mutagen_pony import entry_point
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(entry_point())
|
||||
8
linedance-app/venv/bin/pip
Executable file
8
linedance-app/venv/bin/pip
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
linedance-app/venv/bin/pip3
Executable file
8
linedance-app/venv/bin/pip3
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
linedance-app/venv/bin/pip3.12
Executable file
8
linedance-app/venv/bin/pip3.12
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
linedance-app/venv/bin/pylupdate6
Executable file
8
linedance-app/venv/bin/pylupdate6
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from PyQt6.lupdate.pylupdate import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
1
linedance-app/venv/bin/python
Symbolic link
1
linedance-app/venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
python3.12
|
||||
1
linedance-app/venv/bin/python3
Symbolic link
1
linedance-app/venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
python3.12
|
||||
1
linedance-app/venv/bin/python3.12
Symbolic link
1
linedance-app/venv/bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/bin/python3.12
|
||||
8
linedance-app/venv/bin/pyuic6
Executable file
8
linedance-app/venv/bin/pyuic6
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from PyQt6.uic.pyuic import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
linedance-app/venv/bin/watchmedo
Executable file
8
linedance-app/venv/bin/watchmedo
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/carsten/Dokumenter/GitClone/LinedanceAfspiller/linedance-app/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from watchdog.watchmedo import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Binary file not shown.
Binary file not shown.
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6Core.so.6
Executable file
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6Core.so.6
Executable file
Binary file not shown.
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6DBus.so.6
Executable file
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6DBus.so.6
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6Gui.so.6
Executable file
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6Gui.so.6
Executable file
Binary file not shown.
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6Help.so.6
Executable file
BIN
linedance-app/venv/lib/python3.12/site-packages/PyQt6/Qt6/lib/libQt6Help.so.6
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user