Files
LinedanceAfspiller/linedance-app/local/local_db.py
2026-04-25 21:28:31 +02:00

771 lines
29 KiB
Python

"""
local_db.py — Lokal SQLite database for LineDance Player v0.9
Ny arkitektur:
songs — global katalog (synkroniseret med server, server-UUID som ID)
files — lokalt fil-index (kun denne maskine)
playlist_songs — refererer til song_id + valgfri file_id
"""
import sqlite3
import logging
from contextlib import contextmanager
from pathlib import Path
logger = logging.getLogger(__name__)
DB_PATH = Path.home() / ".linedance" / "local.db"
def get_db_path() -> Path:
return DB_PATH
@contextmanager
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ── Schema ────────────────────────────────────────────────────────────────────
SCHEMA = """
-- Musik-biblioteker (mapper brugeren har tilføjet)
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Global sang-katalog (synkroniseret med server)
-- ID er server-UUID. Sange uden server-ID har et lokalt UUID.
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY,
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,
mbid TEXT UNIQUE,
acoustid TEXT,
server_synced INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Lokalt fil-index (kun denne maskine)
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
local_path TEXT NOT NULL UNIQUE,
file_missing INTEGER NOT NULL DEFAULT 0,
file_format TEXT NOT NULL DEFAULT '',
file_modified_at TEXT NOT NULL DEFAULT '',
extra_tags TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_files_song_id ON files(song_id);
CREATE INDEX IF NOT EXISTS idx_files_missing ON files(file_missing);
-- Dans-niveauer
CREATE TABLE IF NOT EXISTS dance_levels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sort_order INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT ''
);
-- Danse
-- Dans + niveau + koreograf er unik kombination
CREATE TABLE IF NOT EXISTS dances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
level_id INTEGER REFERENCES dance_levels(id),
choreographer TEXT NOT NULL DEFAULT '',
video_url TEXT NOT NULL DEFAULT '',
stepsheet_url TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
use_count INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'local',
UNIQUE(name, level_id, choreographer)
);
-- Sang-dans tags
CREATE TABLE IF NOT EXISTS song_dances (
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_id INTEGER NOT NULL REFERENCES dances(id),
dance_order INTEGER NOT NULL DEFAULT 1,
UNIQUE(song_id, dance_id)
);
-- Alternativ-dans tags
CREATE TABLE IF NOT EXISTS song_alt_dances (
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_id INTEGER NOT NULL REFERENCES dances(id),
note TEXT NOT NULL DEFAULT '',
user_rating INTEGER, -- 1-5 stjerner, NULL = ikke vurderet
source TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(song_id, dance_id)
);
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
CREATE INDEX IF NOT EXISTS idx_song_alt_dances ON song_alt_dances(song_id);
CREATE INDEX IF NOT EXISTS idx_dances_name ON dances(name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_dances_use_count ON dances(use_count DESC);
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_files_song_path ON files(song_id, file_missing);
-- Playlister
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
api_project_id TEXT UNIQUE,
is_linked INTEGER NOT NULL DEFAULT 0,
server_permission TEXT NOT NULL DEFAULT 'edit',
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Playliste-sange
CREATE TABLE IF NOT EXISTS playlist_songs (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id),
file_id TEXT REFERENCES files(id),
position INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
is_workshop INTEGER NOT NULL DEFAULT 0,
dance_override TEXT NOT NULL DEFAULT '',
alt_dance_override TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_song ON playlist_songs(song_id);
-- Event-state (gemmes løbende)
CREATE TABLE IF NOT EXISTS event_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Dans-navne til autoudfyld
CREATE TABLE IF NOT EXISTS dance_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
use_count INTEGER NOT NULL DEFAULT 1
);
"""
DEFAULT_DANCE_LEVELS = [
(10, "Absolute Beginner", "Ingen tidligere danse-erfaring kræves"),
(20, "Beginner", "Lidt tidligere erfaring"),
(30, "High Beginner", "God begynder, klar til mere"),
(40, "Low Improver", "Begyndende øvet"),
(50, "Improver", "Grundlæggende færdigheder på plads"),
(60, "High Improver", "Stærk øvet, næsten intermediate"),
(70, "Low Intermediate", "Begyndende intermediate"),
(80, "Intermediate", "Erfaren danser"),
(90, "High Intermediate", "Stærk intermediate"),
(99, "Advanced", "Fuld beherskelse af trin og teknik"),
]
def init_db():
"""Opret tabeller og seed dance_levels hvis de mangler."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with get_db() as conn:
conn.executescript(SCHEMA)
# Seed dans-niveauer
for sort_order, name, desc in DEFAULT_DANCE_LEVELS:
conn.execute(
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
(sort_order, name, desc)
)
logger.info("Database initialiseret")
# ── Biblioteker ───────────────────────────────────────────────────────────────
def add_library(path: str) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,)
)
return cur.lastrowid
def get_libraries() -> list:
with get_db() as conn:
return conn.execute(
"SELECT * FROM libraries WHERE is_active=1 ORDER BY path"
).fetchall()
def remove_library(library_id: int):
with get_db() as conn:
row = conn.execute(
"SELECT path FROM libraries WHERE id=?", (library_id,)
).fetchone()
if row:
# Marker filer fra denne mappe som missing
conn.execute(
"UPDATE files SET file_missing=1 WHERE local_path LIKE ?",
(row["path"] + "%",)
)
conn.execute("DELETE FROM libraries WHERE id=?", (library_id,))
# ── Sange ─────────────────────────────────────────────────────────────────────
def find_or_create_song(title: str, artist: str = "", album: str = "",
bpm: int = 0, duration_sec: int = 0,
mbid: str = "", acoustid: str = "",
song_id: str = None) -> str:
"""
Find eksisterende sang eller opret ny. Returnerer song_id.
Match-hierarki: server_id → mbid → acoustid → titel+artist → opret ny
"""
import uuid as _uuid
with get_db() as conn:
# Match på server-ID
if song_id:
row = conn.execute(
"SELECT id FROM songs WHERE id=?", (song_id,)
).fetchone()
if row:
# Opdater data hvis bedre info tilgængeligt
conn.execute("""
UPDATE songs SET
title = CASE WHEN title='' THEN ? ELSE title END,
artist = CASE WHEN artist='' THEN ? ELSE artist END,
bpm = CASE WHEN bpm=0 THEN ? ELSE bpm END,
mbid = CASE WHEN mbid IS NULL AND ? != '' THEN ? ELSE mbid END,
acoustid = CASE WHEN acoustid IS NULL AND ? != '' THEN ? ELSE acoustid END
WHERE id=?
""", (title, artist, bpm, mbid, mbid, acoustid, acoustid, song_id))
return song_id
# Match på MBID
if mbid:
row = conn.execute(
"SELECT id FROM songs WHERE mbid=?", (mbid,)
).fetchone()
if row:
return row["id"]
# Match på AcoustID
if acoustid:
row = conn.execute(
"SELECT id FROM songs WHERE acoustid=?", (acoustid,)
).fetchone()
if row:
if mbid:
conn.execute(
"UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL",
(mbid, row["id"])
)
return row["id"]
# Match på titel + artist
if title:
row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?",
(title, artist)
).fetchone()
if row:
if mbid:
conn.execute(
"UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL",
(mbid, row["id"])
)
return row["id"]
# Opret ny sang
new_id = song_id or str(_uuid.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist, album, bpm, duration_sec, mbid, acoustid) "
"VALUES (?,?,?,?,?,?,?,?)",
(new_id, title, artist, album, bpm, duration_sec,
mbid or None, acoustid or None)
)
return new_id
def get_song(song_id: str) -> sqlite3.Row | None:
with get_db() as conn:
return conn.execute(
"SELECT * FROM songs WHERE id=?", (song_id,)
).fetchone()
def update_song_bpm(song_id: str, bpm: int):
with get_db() as conn:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=? AND (bpm=0 OR bpm IS NULL)",
(bpm, song_id)
)
def update_song_mbid(song_id: str, mbid: str, acoustid: str = ""):
with get_db() as conn:
conn.execute(
"UPDATE songs SET mbid=?, acoustid=? WHERE id=?",
(mbid or None, acoustid or None, song_id)
)
# ── Filer ─────────────────────────────────────────────────────────────────────
def upsert_file(song_id: str, local_path: str, file_format: str = "",
file_modified_at: str = "", extra_tags: str = "{}") -> str:
"""Opret eller opdater en fil-post. Returnerer file_id."""
import uuid as _uuid
with get_db() as conn:
existing = conn.execute(
"SELECT id FROM files WHERE local_path=?", (local_path,)
).fetchone()
if existing:
conn.execute("""
UPDATE files SET
song_id=?, file_missing=0,
file_format=?, file_modified_at=?, extra_tags=?
WHERE id=?
""", (song_id, file_format, file_modified_at, extra_tags, existing["id"]))
return existing["id"]
else:
file_id = str(_uuid.uuid4())
conn.execute(
"INSERT INTO files (id, song_id, local_path, file_format, file_modified_at, extra_tags) "
"VALUES (?,?,?,?,?,?)",
(file_id, song_id, local_path, file_format, file_modified_at, extra_tags)
)
return file_id
def get_file_for_song(song_id: str) -> sqlite3.Row | None:
"""Find bedste tilgængelige fil for en sang."""
with get_db() as conn:
return conn.execute(
"SELECT * FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(song_id,)
).fetchone()
def get_file(file_id: str) -> sqlite3.Row | None:
with get_db() as conn:
return conn.execute(
"SELECT * FROM files WHERE id=?", (file_id,)
).fetchone()
def mark_file_missing(local_path: str):
with get_db() as conn:
conn.execute(
"UPDATE files SET file_missing=1 WHERE local_path=?", (local_path,)
)
def get_all_known_paths() -> set[str]:
with get_db() as conn:
rows = conn.execute("SELECT local_path FROM files").fetchall()
return {r["local_path"] for r in rows}
# ── Søgning i bibliotek ───────────────────────────────────────────────────────
def search_songs(query: str, limit: int = 200) -> list:
"""Søg i sange der har en tilgængelig fil."""
with get_db() as conn:
pattern = f"%{query}%"
return conn.execute("""
SELECT s.*, f.id as file_id, f.local_path, f.file_format, f.file_missing,
GROUP_CONCAT(d.name, ', ') as dance_names
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
WHERE s.title LIKE ? OR s.artist LIKE ? OR s.album LIKE ?
GROUP BY s.id
ORDER BY s.title
LIMIT ?
""", (pattern, pattern, pattern, limit)).fetchall()
def get_all_songs_with_files(limit: int = 5000) -> list:
"""Hent alle sange med tilgængelige filer — til biblioteksvisning."""
with get_db() as conn:
return conn.execute("""
SELECT s.*, f.id as file_id, f.local_path, f.file_format, f.file_missing,
GROUP_CONCAT(d.name ORDER BY sd.dance_order, ', ') as dance_names
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
GROUP BY s.id
ORDER BY s.title
LIMIT ?
""", (limit,)).fetchall()
# ── Playlister ────────────────────────────────────────────────────────────────
def create_playlist(name: str, description: str = "", tags: str = "") -> str:
import uuid as _uuid
pl_id = str(_uuid.uuid4())
with get_db() as conn:
conn.execute(
"INSERT INTO playlists (id, name, description, tags) VALUES (?,?,?,?)",
(pl_id, name, description, tags)
)
return pl_id
def get_playlists(tag_filter: str | None = None) -> list:
with get_db() as conn:
if tag_filter:
return conn.execute("""
SELECT p.id, p.name, p.description, p.tags, p.api_project_id,
p.is_linked, p.server_permission, p.is_deleted, p.created_at,
COUNT(ps.position) as song_count
FROM playlists p
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
WHERE p.name != '__aktiv__' AND p.is_deleted = 0
AND (p.tags LIKE ? OR p.tags LIKE ? OR p.tags LIKE ? OR p.tags = ?)
GROUP BY p.id ORDER BY p.created_at DESC
""", (f"{tag_filter},%", f"%, {tag_filter},%",
f"%, {tag_filter}", tag_filter)).fetchall()
else:
return conn.execute("""
SELECT p.id, p.name, p.description, p.tags, p.api_project_id,
p.is_linked, p.server_permission, p.is_deleted, p.created_at,
COUNT(ps.position) as song_count
FROM playlists p
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
WHERE p.name != '__aktiv__' AND p.is_deleted = 0
GROUP BY p.id ORDER BY p.created_at DESC
""").fetchall()
def delete_playlist(playlist_id: str):
"""Soft-slet — sæt is_deleted=1."""
with get_db() as conn:
conn.execute(
"UPDATE playlists SET is_deleted=1 WHERE id=?", (playlist_id,)
)
def get_playlist_with_songs(playlist_id: str) -> 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,
ps.is_workshop, ps.dance_override,
ps.song_id, ps.file_id,
s.title, s.artist, s.album, s.bpm, s.duration_sec,
f.local_path, f.file_format, f.file_missing
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
LEFT JOIN files f ON f.id = ps.file_id
WHERE ps.playlist_id = ?
ORDER BY ps.position
""", (playlist_id,)).fetchall()
return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]}
def add_song_to_playlist(playlist_id: str, song_id: str,
file_id: str | None = None,
position: int | None = None) -> str:
import uuid as _uuid
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
ps_id = str(_uuid.uuid4())
conn.execute(
"INSERT INTO playlist_songs (id, playlist_id, song_id, file_id, position) "
"VALUES (?,?,?,?,?)",
(ps_id, playlist_id, song_id, file_id, position)
)
return ps_id
# ── Dans-tags ─────────────────────────────────────────────────────────────────
def get_all_playlist_tags() -> list[str]:
return get_playlist_tags()
def get_playlist_tags() -> list[str]:
with get_db() as conn:
rows = conn.execute(
"SELECT tags FROM playlists WHERE tags != '' AND name != '__aktiv__' AND is_deleted=0"
).fetchall()
tags = set()
for row in rows:
for tag in row["tags"].split(","):
t = tag.strip().lower()
if t:
tags.add(t)
return sorted(tags)
def update_playlist_tags(playlist_id: str, tags: str):
with get_db() as conn:
conn.execute(
"UPDATE playlists SET tags=? WHERE id=?", (tags, playlist_id)
)
# ── Event-state ───────────────────────────────────────────────────────────────
def save_event_state(current_idx: int, statuses: list[str]):
import json
with get_db() as conn:
conn.execute(
"INSERT OR REPLACE INTO event_state (key,value) VALUES ('current_idx',?)",
(str(current_idx),)
)
conn.execute(
"INSERT OR REPLACE INTO event_state (key,value) VALUES ('statuses',?)",
(json.dumps(statuses),)
)
def load_event_state() -> tuple | None:
import json
with get_db() as conn:
idx_row = conn.execute(
"SELECT value FROM event_state WHERE key='current_idx'"
).fetchone()
stat_row = conn.execute(
"SELECT value FROM event_state WHERE key='statuses'"
).fetchone()
if not idx_row or not stat_row:
return None
return int(idx_row["value"]), json.loads(stat_row["value"])
# ── Dans-niveauer ─────────────────────────────────────────────────────────────
def get_dance_levels() -> list:
with get_db() as conn:
return conn.execute(
"SELECT * FROM dance_levels ORDER BY sort_order"
).fetchall()
def upsert_dance_levels(levels: list[dict]):
with get_db() as conn:
for lvl in levels:
conn.execute("""
INSERT INTO dance_levels (id, sort_order, name, description)
VALUES (:id, :sort_order, :name, :description)
ON CONFLICT(name) DO UPDATE SET
sort_order=excluded.sort_order,
description=excluded.description
""", lvl)
# ── Dans-søgning (til DancePickerDialog) ─────────────────────────────────────
def get_dance_suggestions(prefix: str = "", limit: int = 20) -> list:
"""Hent dans-forslag med niveau og koreograf til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
return conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name,
d.choreographer, d.use_count
FROM dances d
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE d.name LIKE ? COLLATE NOCASE
ORDER BY d.use_count DESC, d.name
LIMIT ?
""", (pattern, limit)).fetchall()
def get_choreographer_suggestions(prefix: str = "", limit: int = 15) -> list[str]:
"""Hent koreograf-navne til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
rows = conn.execute("""
SELECT DISTINCT choreographer FROM dances
WHERE choreographer != '' AND choreographer LIKE ? COLLATE NOCASE
ORDER BY choreographer
LIMIT ?
""", (pattern, limit)).fetchall()
return [r["choreographer"] for r in rows]
# ── Dans-søgning (til DancePickerDialog og DanceInfoDialog) ──────────────────
def get_dance_suggestions(prefix: str = "", limit: int = 20) -> list:
"""Hent dans-forslag med niveau og koreograf til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
return conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name,
d.choreographer, d.use_count
FROM dances d
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE d.name LIKE ? COLLATE NOCASE
ORDER BY d.use_count DESC, d.name
LIMIT ?
""", (pattern, limit)).fetchall()
def get_choreographer_suggestions(prefix: str = "", limit: int = 15) -> list[str]:
"""Hent koreograf-navne til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
rows = conn.execute("""
SELECT DISTINCT choreographer FROM dances
WHERE choreographer != '' AND choreographer LIKE ? COLLATE NOCASE
ORDER BY choreographer
LIMIT ?
""", (pattern, limit)).fetchall()
return [r["choreographer"] for r in rows]
def get_dances_for_song(song_id: str) -> list:
"""Hent alle danse tagget på en sang med niveau og koreograf."""
with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name, d.choreographer,
d.video_url, d.stepsheet_url, d.notes, sd.dance_order
FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE sd.song_id = ?
ORDER BY sd.dance_order
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_alt_dances_for_song(song_id: str) -> list:
"""Hent alle alternativ-danse tagget på en sang."""
with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name, d.choreographer,
d.video_url, d.stepsheet_url, sad.note
FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE sad.song_id = ?
ORDER BY d.name
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_or_create_dance(name: str, level_id: int | None, conn,
choreographer: str = "") -> int:
"""
Find eller opret dans. Returnerer dance_id.
Dans + niveau + koreograf er unik kombination.
"""
choreo = choreographer or ""
existing = conn.execute(
"SELECT id FROM dances WHERE name=? "
"AND (level_id=? OR (level_id IS NULL AND ? IS NULL)) "
"AND choreographer=?",
(name, level_id, level_id, choreo)
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO dances (name, level_id, choreographer) VALUES (?,?,?)",
(name, level_id, choreo)
)
return cur.lastrowid
def rate_alt_dance(song_id: str, dance_id: int, rating: int | None):
"""Sæt brugerens rating (1-5) på en alternativ-dans. None = fjern rating."""
with get_db() as conn:
conn.execute(
"UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?",
(rating, song_id, dance_id)
)
def get_alt_dances_for_song_with_ratings(song_id: str) -> list:
"""Hent alternativ-danse med bruger-rating og community-rating."""
with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name,
d.choreographer, sad.note, sad.user_rating,
sad.source
FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE sad.song_id = ?
ORDER BY sad.user_rating DESC NULLS LAST, d.name
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_community_alts_for_song(song_id: str) -> list:
"""Hent community alternativ-danse for en sang med ratings."""
with get_db() as conn:
# Opret tabellen hvis den ikke eksisterer
conn.execute(
"CREATE TABLE IF NOT EXISTS community_alt_dances ("
"id TEXT PRIMARY KEY, song_id TEXT NOT NULL, dance_id INTEGER NOT NULL, "
"avg_rating REAL NOT NULL DEFAULT 0, rating_count INTEGER NOT NULL DEFAULT 0, "
"my_rating INTEGER, UNIQUE(song_id, dance_id))"
)
rows = conn.execute("""
SELECT d.id, d.name, dl.name as level_name, d.choreographer,
cad.avg_rating, cad.rating_count, cad.my_rating
FROM community_alt_dances cad
JOIN dances d ON d.id = cad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE cad.song_id = ?
ORDER BY cad.avg_rating DESC
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def refresh_file_availability():
"""Tjek hurtigt om alle kendte filer stadig eksisterer — opdater file_missing.
Køres ved opstart i baggrundstråd."""
from pathlib import Path
try:
with get_db() as conn:
rows = conn.execute(
"SELECT id, local_path, file_missing FROM files"
).fetchall()
for row in rows:
try:
exists = Path(row["local_path"]).exists()
expected = 0 if exists else 1
if row["file_missing"] != expected:
conn.execute(
"UPDATE files SET file_missing=? WHERE id=?",
(expected, row["id"])
)
except Exception:
pass
logger.info("Fil-tilgængelighed opdateret")
except Exception as e:
logger.warning(f"refresh_file_availability fejl: {e}")