Compare commits

..

13 Commits

Author SHA1 Message Date
marc
443c1b1391 Canviar el preview de la pàgina de tema 2026-01-05 10:40:43 +01:00
marc
4e7a6b18d6 Afegir control visual de les lletres per a partitures 2026-01-05 10:19:25 +01:00
marc
c0624d1e56 Edició de llistes (afegir i esborrar llistes) 2025-12-21 17:30:19 +01:00
marc
fdcda1b566 Nova pàgina de llistes 2025-12-21 17:09:59 +01:00
marc
ac79785cf0 Afegir partitures de llistes (cançoners) 2025-12-21 15:40:21 +01:00
marc
089e61fb0b Canvi de nom a les URLs playlist -> llista 2025-12-20 23:48:54 +01:00
marc
56ab91bd42 Pàgines llista de reproducció 2025-12-20 23:29:11 +01:00
marc
5428d49e89 Crea i esborra playlists quan es creen i esborren sessions 2025-12-20 21:20:58 +01:00
marc
43706af7c4 Afegir funcions especials de lilypond 2025-12-20 20:31:54 +01:00
marc
c1ec268823 Afegir metadades per quan es comparteixen enllaços 2025-11-02 14:22:22 +01:00
marc
2fbdbbf290 Desacoblar playlists de sessions i afegir slow jams 2025-11-01 19:55:24 +01:00
marc
23337f8ab3 Afegir cartells a les jams (i esquelet per a slow jams i notes) 2025-11-01 12:47:04 +01:00
marc
fbfedf4dac Fix set editor 2025-10-30 22:03:01 +01:00
98 changed files with 2539 additions and 641 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.direnv .direnv
**/**.pyc **/**.pyc
certs

24
AGENTS.md Normal file
View File

@@ -0,0 +1,24 @@
# Development Commands
## Build/Lint/Test
- **Run development server**: `JWT_SECRET=12345 uvicorn folkugat_web.main:app --reload --reload-include "*.html" --host 0.0.0.0`
- **Build for production**: `./build.sh`
## Code Style Guidelines
### Python
- **Type hints**: Use strict typing with `from typing import` and custom types from `folkugat_web.typing`
- **Imports**: Standard library first, third-party, then local imports. Use absolute imports for local modules.
- **Naming**: snake_case for variables/functions, PascalCase for classes, UPPER_CASE for constants
- **Error handling**: Use Result[ResultT, ErrorT] pattern from utils for service functions
- **Async**: Use async/await for I/O operations, FastAPI endpoints are async
### Frontend
- **CSS**: Use Tailwind CSS classes with custom brown/beige theme
- **Templates**: Jinja2 templates in folkugat_web/assets/templates/
- **Static files**: Organized in folkugat_web/assets/static/
### Architecture
- **Structure**: api/routes/, config/, dal/, fragments/, model/, services/
- **Database**: SQLite with custom DAL layer in dal/sql/
- **Authentication**: JWT-based with cookie storage

6
flake.lock generated
View File

@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1753432016, "lastModified": 1766125104,
"narHash": "sha256-cnL5WWn/xkZoyH/03NNUS7QgW5vI7D1i74g48qplCvg=", "narHash": "sha256-l/YGrEpLromL4viUo5GmFH3K5M1j0Mb9O+LiaeCPWEM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "6027c30c8e9810896b92429f0092f624f7b1aace", "rev": "7d853e518814cca2a657b72eeba67ae20ebf7059",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -60,7 +60,10 @@
lilypond-with-fonts lilypond-with-fonts
# Project tools # Project tools
sqlite sqlite
lazysql
opencode opencode
# Local https
mkcert
]; ];
shellHook = '' shellHook = ''

View File

@@ -1,14 +1,16 @@
from folkugat_web.api.router import get_router from folkugat_web.api.router import get_router
from . import auth, index, sessio, sessions, tema, temes from . import auth, index, llista, llistes, sessio, sessions, tema, temes
router = get_router() router = get_router()
router.include_router(auth.router)
router.include_router(index.router)
router.include_router(llista.router)
router.include_router(llistes.router)
router.include_router(sessio.router) router.include_router(sessio.router)
router.include_router(sessions.router) router.include_router(sessions.router)
router.include_router(tema.router) router.include_router(tema.router)
router.include_router(temes.router) router.include_router(temes.router)
router.include_router(auth.router)
router.include_router(index.router)
__all__ = ["router"] __all__ = ["router"]

View File

@@ -13,6 +13,7 @@ def index(request: Request, logged_in: auth.LoggedIn):
{ {
"request": request, "request": request,
"page_title": "Folkugat", "page_title": "Folkugat",
"page_description": "Sessions de folk a Sant Cugat",
"content": "/api/content/sessions", "content": "/api/content/sessions",
"logged_in": logged_in, "logged_in": logged_in,
"animate": True, "animate": True,

View File

@@ -0,0 +1,9 @@
from folkugat_web.api.router import get_router
from . import playlist, set_page
router = get_router()
router.include_router(set_page.router)
router.include_router(playlist.router)
__all__ = ["router"]

View File

@@ -0,0 +1,269 @@
from typing import Annotated
from fastapi import Form, HTTPException, Request
from folkugat_web.api.router import get_router
from folkugat_web.fragments import playlist
from folkugat_web.services import auth
from folkugat_web.services import playlists as playlists_service
from folkugat_web.services.temes import write as temes_service
from folkugat_web.templates import templates
router = get_router()
@router.get("/llista/{playlist_id}")
def page(
request: Request,
logged_in: auth.LoggedIn,
playlist_id: int,
):
playlist = playlists_service.get_playlist(playlist_id=playlist_id)
if not playlist:
raise HTTPException(status_code=404, detail="Could not find playlist")
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat - Setlist",
"page_description": playlist.name or "Llista de temes",
"page_card": None,
"content": f"/api/content/llista/{playlist_id}",
"logged_in": logged_in,
}
)
@router.get("/api/content/llista/{playlist_id}")
async def contingut(
request: Request,
logged_in: auth.LoggedIn,
playlist_id: int,
):
return await playlist.pagina(request, playlist_id, logged_in)
@router.get("/api/llista/{playlist_id}/name")
def get_name(
request: Request,
logged_in: auth.LoggedIn,
playlist_id: int,
):
return playlist.name(request=request, playlist_id=playlist_id, logged_in=logged_in)
@router.get("/api/llista/{playlist_id}/editor/name")
def name_editor(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
):
return playlist.name_editor(request=request, playlist_id=playlist_id, logged_in=logged_in)
@router.put("/api/llista/{playlist_id}/name")
def set_name(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
name: Annotated[str, Form()],
):
_ = playlists_service.update_name(playlist_id=playlist_id, name=name)
return playlist.name(request=request, playlist_id=playlist_id, logged_in=logged_in)
@router.post("/api/llista/{playlist_id}/set")
def add_set(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
):
return playlist.add_set(
request=request,
playlist_id=playlist_id,
logged_in=logged_in,
)
@router.get("/api/llista/{playlist_id}/set/{set_id}")
def get_set(
request: Request,
logged_in: auth.LoggedIn,
playlist_id: int,
set_id: int,
):
return playlist.get_set(
request=request,
playlist_id=playlist_id,
set_id=set_id,
logged_in=logged_in,
)
@router.delete("/api/llista/{playlist_id}/set/{set_id}")
def delete_set(
_: auth.RequireLogin,
playlist_id: int,
set_id: int,
):
return playlist.delete_set(
playlist_id=playlist_id,
set_id=set_id,
)
@router.post("/api/llista/{playlist_id}/set/{set_id}")
def add_tema(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
set_id: int,
):
return playlist.add_tema(
request=request,
playlist_id=playlist_id,
set_id=set_id,
logged_in=logged_in,
)
@router.get("/api/llista/{playlist_id}/set/{set_id}/tema/{entry_id}")
def get_tema(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
set_id: int,
entry_id: int,
):
return playlist.get_tema(
request=request,
playlist_id=playlist_id,
set_id=set_id,
entry_id=entry_id,
logged_in=logged_in,
)
@router.get("/api/llista/{playlist_id}/set/{set_id}/tema/{entry_id}/editor")
def get_tema_editor(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
set_id: int,
entry_id: int,
):
return playlist.get_tema_editor(
request=request,
playlist_id=playlist_id,
set_id=set_id,
entry_id=entry_id,
logged_in=logged_in,
)
@router.delete("/api/llista/{playlist_id}/set/{set_id}/tema/{entry_id}")
def delete_tema(
_: auth.RequireLogin,
playlist_id: int,
set_id: int,
entry_id: int,
):
return playlist.delete_tema(
playlist_id=playlist_id,
set_id=set_id,
entry_id=entry_id,
)
@router.get("/api/llista/{playlist_id}/set/{set_id}/tema/{entry_id}/busca")
def busca_tema(
request: Request,
_: auth.RequireLogin,
playlist_id: int,
set_id: int,
entry_id: int,
query: str,
):
return playlist.busca_tema(
request=request,
playlist_id=playlist_id,
set_id=set_id,
entry_id=entry_id,
query=query,
)
@router.put("/api/llista/{playlist_id}/set/{set_id}/tema/{entry_id}")
def set_tema(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
set_id: int,
entry_id: int,
tema_id: Annotated[int, Form()],
):
return playlist.set_tema(
request=request,
logged_in=logged_in,
playlist_id=playlist_id,
set_id=set_id,
entry_id=entry_id,
tema_id=tema_id,
)
@router.put("/api/llista/{playlist_id}/set/{set_id}/tema/{entry_id}/unknown")
def set_tema_unknown(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
set_id: int,
entry_id: int,
):
return playlist.set_tema(
request=request,
logged_in=logged_in,
playlist_id=playlist_id,
set_id=set_id,
entry_id=entry_id,
tema_id=None,
)
@router.post("/api/llista/{playlist_id}/set/{set_id}/tema/{entry_id}")
def set_tema_new(
request: Request,
logged_in: auth.RequireLogin,
playlist_id: int,
set_id: int,
entry_id: int,
title: Annotated[str, Form()],
):
new_tema = temes_service.create_tema(title=title)
return playlist.set_tema(
request=request,
logged_in=logged_in,
playlist_id=playlist_id,
set_id=set_id,
entry_id=entry_id,
tema_id=new_tema.id,
)
@router.put("/api/llista/{playlist_id}/visible")
def set_visible(request: Request, logged_in: auth.RequireLogin, playlist_id: int):
new_playlist = playlists_service.set_visibility(playlist_id=playlist_id, hidden=False)
return playlist.visibility(
request=request,
logged_in=logged_in,
playlist=new_playlist,
)
@router.put("/api/llista/{playlist_id}/invisible")
def set_invisible(request: Request, logged_in: auth.RequireLogin, playlist_id: int):
new_playlist = playlists_service.set_visibility(playlist_id=playlist_id, hidden=True)
return playlist.visibility(
request=request,
logged_in=logged_in,
playlist=new_playlist,
)

View File

@@ -0,0 +1,55 @@
from fastapi import HTTPException, Request
from folkugat_web.api.router import get_router
from folkugat_web.fragments import set_page
from folkugat_web.model import playlists
from folkugat_web.services import auth
from folkugat_web.services import playlists as playlists_service
from folkugat_web.templates import templates
router = get_router()
def set_description(set_: playlists.Set) -> str:
tema_names = [tis.tema.title if tis.tema else "Desconegut" for tis in set_.temes]
return " i ".join(filter(bool, [
", ".join(tema_names[:-1]),
tema_names[-1]
]))
@router.get("/llista/{playlist_id}/set/{set_id}")
def page(
request: Request,
logged_in: auth.LoggedIn,
playlist_id: int,
set_id: int,
):
set_ = playlists_service.get_set(playlist_id=playlist_id, set_id=set_id)
if not set_:
raise HTTPException(status_code=404, detail="Set not found")
set_ = playlists_service.add_temes_to_set(set_)
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat - Set",
"page_description": set_description(set_=set_),
"content": f"/api/content/llista/{playlist_id}/set/{set_id}",
"logged_in": logged_in,
}
)
@router.get("/api/content/llista/{playlist_id}/set/{set_id}")
async def contingut(
request: Request,
logged_in: auth.LoggedIn,
playlist_id: int,
set_id: int,
):
return await set_page.pagina(
request=request,
playlist_id=playlist_id,
set_id=set_id,
logged_in=logged_in,
)

View File

@@ -0,0 +1,5 @@
from folkugat_web.api.router import get_router
from . import index
router = get_router()
router.include_router(index.router)

View File

@@ -0,0 +1,36 @@
from fastapi import Request
from folkugat_web.api.router import get_router
from folkugat_web.fragments import llistes
from folkugat_web.services import auth
from folkugat_web.templates import templates
router = get_router()
@router.get("/llistes")
def page(request: Request, logged_in: auth.LoggedIn):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat",
"content": "/api/content/llistes",
"logged_in": logged_in,
"animate": False,
}
)
@router.get("/api/content/llistes")
def content(request: Request, logged_in: auth.LoggedIn):
return llistes.llistes_pagina(request, logged_in)
@router.post("/api/llistes/editor/")
def insert_row(request: Request, _: auth.RequireLogin):
return llistes.llistes_editor_insert_row(request)
@router.delete("/api/llistes/{playlist_id}")
def delete_playlist(playlist_id: int, _: auth.RequireLogin):
return llistes.llistes_editor_delete_row(playlist_id)

View File

@@ -1,10 +1,11 @@
from folkugat_web.api.router import get_router from folkugat_web.api.router import get_router
from . import index, live, set_page from . import cartell, index, live, notes
router = get_router() router = get_router()
router.include_router(cartell.router)
router.include_router(index.router) router.include_router(index.router)
router.include_router(live.router) router.include_router(live.router)
router.include_router(set_page.router) router.include_router(notes.router)
__all__ = ["router"] __all__ = ["router"]

View File

@@ -0,0 +1,71 @@
import dataclasses
from typing import Annotated
from fastapi import HTTPException, Request, UploadFile
from fastapi.params import File
from folkugat_web.api.router import get_router
from folkugat_web.fragments.sessio import cartell
from folkugat_web.services import auth
from folkugat_web.services import files as files_service
from folkugat_web.services import sessions as sessions_service
router = get_router()
@router.get("/api/sessio/{session_id}/cartell")
def get_cartell(
request: Request,
logged_in: auth.LoggedIn,
session_id: int,
):
return cartell.cartell(request, session_id, logged_in)
@router.get("/api/sessio/{session_id}/cartell/editor")
def get_cartell_editor(
request: Request,
_: auth.RequireLogin,
session_id: int,
):
return cartell.cartell_editor(request, session_id)
@router.put("/api/sessio/{session_id}/cartell")
async def set_cartell(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
upload_file: Annotated[UploadFile, File()],
):
session = sessions_service.get_session(session_id=session_id)
if not session:
raise HTTPException(status_code=404, detail="Could not find session")
url = await files_service.store_session_cartell(
session_id=session_id,
upload_file=upload_file,
)
new_session = dataclasses.replace(
session,
cartell_url=url,
)
sessions_service.set_session(new_session)
return cartell.cartell(request, session_id, logged_in)
@router.delete("/api/sessio/{session_id}/cartell")
async def delete_cartell(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
):
session = sessions_service.get_session(session_id=session_id)
if not session:
raise HTTPException(status_code=404, detail="Could not find session")
if session.cartell_url:
new_session = dataclasses.replace(
session,
cartell_url=None,
)
sessions_service.set_session(new_session)
files_service.clean_orphan_files()
return cartell.cartell(request, session_id, logged_in)

View File

@@ -1,26 +1,47 @@
from typing import Annotated from fastapi import HTTPException, Request
from fastapi import Form, Request
from folkugat_web.api.router import get_router from folkugat_web.api.router import get_router
from folkugat_web.fragments import live, sessio from folkugat_web.fragments import live
from folkugat_web.fragments.sessio import page as sessio
from folkugat_web.model.sessions import Session
from folkugat_web.services import auth from folkugat_web.services import auth
from folkugat_web.services.temes import write as temes_service from folkugat_web.services import sessions as sessions_service
from folkugat_web.templates import templates from folkugat_web.templates import templates
router = get_router() router = get_router()
def session_description(session: Session) -> str:
date_names = sessions_service.get_date_names(session.date)
date_str = f"{date_names.day_name} {date_names.day} {date_names.month_name} de {date_names.year}"
location_str = f"A {session.venue.name}" if session.venue else ""
time_str = f"de {session.start_time.strftime('%H:%M')} a {session.end_time.strftime('%H:%M')}"
location_time_str = " ".join([location_str, time_str])
if location_time_str:
location_time_str = location_time_str[0].upper() + location_time_str[1:]
notes_str = session.notes or ""
return "\n".join(filter(bool, [
date_str,
location_time_str,
notes_str,
]))
@router.get("/sessio/{session_id}") @router.get("/sessio/{session_id}")
def page( def page(
request: Request, request: Request,
logged_in: auth.LoggedIn, logged_in: auth.LoggedIn,
session_id: int, session_id: int,
): ):
session = sessions_service.get_session(session_id=session_id)
if not session:
raise HTTPException(status_code=404, detail="Could not find session")
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
"request": request, "request": request,
"page_title": "Folkugat", "page_title": "Folkugat - Sessió",
"page_description": session_description(session),
"page_card": session.cartell_url,
"content": f"/api/content/sessio/{session_id}", "content": f"/api/content/sessio/{session_id}",
"logged_in": logged_in, "logged_in": logged_in,
} }
@@ -52,150 +73,3 @@ def stop_live(
session_id: int, session_id: int,
): ):
return live.stop_live_session(request=request, session_id=session_id) return live.stop_live_session(request=request, session_id=session_id)
@router.post("/api/sessio/{session_id}/set")
def add_set(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
):
return sessio.add_set(request=request, session_id=session_id, logged_in=logged_in)
@router.get("/api/sessio/{session_id}/set/{set_id}")
def get_set(
request: Request,
logged_in: auth.LoggedIn,
session_id: int,
set_id: int,
):
return sessio.get_set(request=request, session_id=session_id, set_id=set_id, logged_in=logged_in)
@router.delete("/api/sessio/{session_id}/set/{set_id}")
def delete_set(
_: auth.RequireLogin,
session_id: int,
set_id: int,
):
return sessio.delete_set(session_id=session_id, set_id=set_id)
@router.post("/api/sessio/{session_id}/set/{set_id}")
def add_tema(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
set_id: int,
):
return sessio.add_tema(request=request, session_id=session_id, set_id=set_id, logged_in=logged_in)
@router.get("/api/sessio/{session_id}/set/{set_id}/tema/{entry_id}")
def get_tema(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
set_id: int,
entry_id: int,
):
return sessio.get_tema(
request=request, session_id=session_id, set_id=set_id, entry_id=entry_id, logged_in=logged_in)
@router.get("/api/sessio/{session_id}/set/{set_id}/tema/{entry_id}/editor")
def get_tema_editor(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
set_id: int,
entry_id: int,
):
return sessio.get_tema_editor(
request=request, session_id=session_id, set_id=set_id, entry_id=entry_id, logged_in=logged_in)
@router.delete("/api/sessio/{session_id}/set/{set_id}/tema/{entry_id}")
def delete_tema(
_: auth.RequireLogin,
session_id: int,
set_id: int,
entry_id: int,
):
return sessio.delete_tema(session_id=session_id, set_id=set_id, entry_id=entry_id)
@router.get("/api/sessio/{session_id}/set/{set_id}/tema/{entry_id}/busca")
def busca_tema(
request: Request,
_: auth.RequireLogin,
session_id: int,
set_id: int,
entry_id: int,
query: str,
):
return sessio.busca_tema(
request=request,
session_id=session_id,
set_id=set_id,
entry_id=entry_id,
query=query,
)
@router.put("/api/sessio/{session_id}/set/{set_id}/tema/{entry_id}")
def set_tema(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
set_id: int,
entry_id: int,
tema_id: Annotated[int, Form()],
):
return sessio.set_tema(
request=request,
logged_in=logged_in,
session_id=session_id,
set_id=set_id,
entry_id=entry_id,
tema_id=tema_id,
)
@router.put("/api/sessio/{session_id}/set/{set_id}/tema/{entry_id}/unknown")
def set_tema_unknown(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
set_id: int,
entry_id: int,
):
return sessio.set_tema(
request=request,
logged_in=logged_in,
session_id=session_id,
set_id=set_id,
entry_id=entry_id,
tema_id=None,
)
@router.post("/api/sessio/{session_id}/set/{set_id}/tema/{entry_id}")
def set_tema_new(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
set_id: int,
entry_id: int,
title: Annotated[str, Form()],
):
new_tema = temes_service.create_tema(title=title)
return sessio.set_tema(
request=request,
logged_in=logged_in,
session_id=session_id,
set_id=set_id,
entry_id=entry_id,
tema_id=new_tema.id,
)

View File

@@ -0,0 +1,47 @@
import dataclasses
from typing import Annotated
from fastapi import HTTPException, Request
from fastapi.params import Form
from folkugat_web.api.router import get_router
from folkugat_web.fragments.sessio import notes
from folkugat_web.services import auth
from folkugat_web.services import sessions as sessions_service
router = get_router()
@router.get("/api/sessio/{session_id}/notes")
def get_notes(
request: Request,
logged_in: auth.LoggedIn,
session_id: int,
):
return notes.notes(request, session_id, logged_in)
@router.get("/api/sessio/{session_id}/notes/editor")
def get_notes_editor(
request: Request,
_: auth.RequireLogin,
session_id: int,
):
return notes.notes_editor(request, session_id)
@router.put("/api/sessio/{session_id}/notes")
async def set_notes(
request: Request,
logged_in: auth.RequireLogin,
session_id: int,
content: Annotated[str, Form()],
):
session = sessions_service.get_session(session_id=session_id)
if not session:
raise HTTPException(status_code=404, detail="Could not find session")
new_session = dataclasses.replace(
session,
notes=content.strip(),
)
sessions_service.set_session(new_session)
return notes.notes(request, session_id, logged_in)

View File

@@ -1,35 +0,0 @@
from fastapi import Request
from folkugat_web.api.router import get_router
from folkugat_web.fragments import set_page
from folkugat_web.services import auth
from folkugat_web.templates import templates
router = get_router()
@router.get("/sessio/{session_id}/set/{set_id}")
def page(
request: Request,
logged_in: auth.LoggedIn,
session_id: int,
set_id: int,
):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat",
"content": f"/api/content/sessio/{session_id}/set/{set_id}",
"logged_in": logged_in,
}
)
@router.get("/api/content/sessio/{session_id}/set/{set_id}")
async def contingut(
request: Request,
logged_in: auth.LoggedIn,
session_id: int,
set_id: int,
):
return await set_page.pagina(request, session_id, set_id, logged_in)

View File

@@ -21,13 +21,32 @@ from folkugat_web.utils import FnChain
router = get_router() router = get_router()
def _build_page_description(tema: Tema) -> str:
info_lines = []
if composer := tema.composer():
info_lines.append(f"Autor: {composer}")
if origin := tema.origin():
info_lines.append(f"Orígen: {origin}")
return "\n".join(info_lines)
@router.get("/tema/{tema_id}") @router.get("/tema/{tema_id}")
def page(request: Request, logged_in: auth.LoggedIn, tema_id: int): def page(request: Request, logged_in: auth.LoggedIn, tema_id: int):
tema = temes_q.get_tema_by_id(tema_id)
if not tema:
raise HTTPException(status_code=404, detail="Could not find tune")
tema = (
FnChain.transform(tema) |
scores_service.add_scores_to_tema |
properties_service.add_properties_to_tema
).result()
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
"request": request, "request": request,
"page_title": "Folkugat", "page_title": f"Folkugat - {tema.title}",
"page_description": _build_page_description(tema),
"page_card": "",
"content": f"/api/tema/{tema_id}", "content": f"/api/tema/{tema_id}",
"logged_in": logged_in, "logged_in": logged_in,
} }

View File

@@ -34,7 +34,7 @@ async def set_link(
upload_file: Annotated[UploadFile | None, File()] = None, upload_file: Annotated[UploadFile | None, File()] = None,
): ):
if upload_file: if upload_file:
url = await files_service.store_file(tema_id=tema_id, upload_file=upload_file) url = await files_service.store_tema_file(tema_id=tema_id, upload_file=upload_file)
link_type = links_service.guess_link_type(url or '') link_type = links_service.guess_link_type(url or '')
new_link = model.Link( new_link = model.Link(
@@ -65,7 +65,7 @@ def create_link(
@router.delete("/api/tema/{tema_id}/link/{link_id}") @router.delete("/api/tema/{tema_id}/link/{link_id}")
def delete_link( def delete_link(
_logged_in: auth.RequireLogin, _: auth.RequireLogin,
tema_id: int, tema_id: int,
link_id: int, link_id: int,
): ):

View File

@@ -33,11 +33,25 @@ def set_lyric(
lyric_id: int, lyric_id: int,
title: Annotated[str, Form()], title: Annotated[str, Form()],
content: Annotated[str, Form()], content: Annotated[str, Form()],
max_columns: Annotated[str | None, Form()] = None,
): ):
lyric = lyrics_service.get_lyric_by_id(lyric_id=lyric_id, tema_id=tema_id) lyric = lyrics_service.get_lyric_by_id(lyric_id=lyric_id, tema_id=tema_id)
if not lyric: if not lyric:
raise HTTPException(status_code=404, detail="Could not find lyric!") raise HTTPException(status_code=404, detail="Could not find lyric!")
new_lyric = lyrics_service.update_lyric(lyric=lyric, title=title, content=content)
# Parse max_columns from string to int if provided
max_columns_int = None
if max_columns is not None and max_columns.strip():
try:
max_columns_int = int(max_columns.strip())
except ValueError:
raise HTTPException(status_code=400, detail="max_columns must be a valid integer")
try:
new_lyric = lyrics_service.update_lyric(lyric=lyric, title=title, content=content, max_columns=max_columns_int)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return lyrics_fragments.lyric(request=request, logged_in=logged_in, lyric=new_lyric) return lyrics_fragments.lyric(request=request, logged_in=logged_in, lyric=new_lyric)

View File

@@ -29,11 +29,16 @@ def page(
order_params=order_params, order_params=order_params,
) )
content_url = f"/api/content/temes?{temes_params}" content_url = f"/api/content/temes?{temes_params}"
if query:
additional_description = f" - Resultats per a '{query}'"
else:
additional_description = ""
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
"request": request, "request": request,
"page_title": "Folkugat", "page_title": "Folkugat - Temes",
"page_description": f"Buscador de temes{additional_description}",
"content": content_url, "content": content_url,
"logged_in": logged_in, "logged_in": logged_in,
"animate": False, "animate": False,

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -1,8 +1,10 @@
<div class="h-4/5 min-h-[400px] flex flex-col items-center justify-center"> <div class="h-4/5 min-h-[400px] flex flex-col items-center justify-center">
<a href="/">
<img src="{{ url_for(request, 'static', path='img/folkugat.svg') }}" <img src="{{ url_for(request, 'static', path='img/folkugat.svg') }}"
class="{% if animate %} opacity-0 animate-fade-in-one {% endif %} m-3" class="{% if animate %} opacity-0 animate-fade-in-one {% endif %} m-3"
width="100" width="100"
alt="Folkugat"/> alt="Folkugat"/>
<h1 class="text-3xl sm:text-8xl text-beige m-3 {% if animate %} opacity-0 animate-fade-in-one {% endif %}">{{ page_title }}</h1> </a>
<h1 class="text-3xl sm:text-8xl text-beige m-3 {% if animate %} opacity-0 animate-fade-in-one {% endif %}">Folkugat</h1>
<h2 class="text-center sm:text-3xl m-6 {% if animate %} opacity-0 animate-fade-in-two {% endif %}">Sessions de folk a Sant Cugat</h2> <h2 class="text-center sm:text-3xl m-6 {% if animate %} opacity-0 animate-fade-in-two {% endif %}">Sessions de folk a Sant Cugat</h2>
</div> </div>

View File

@@ -0,0 +1,26 @@
<div class="flex flex-row flex-wrap justify-center"
id="llista-name">
<form>
<input name="name"
placeholder="Nom de la playlist"
value="{{ playlist.name if playlist.name else '' }}"
class="border border-beige focus:outline-none
rounded text-3xl
bg-brown p-2 m-0"
/>
<button title="Desa"
class="text-beige text-3xl mx-1"
hx-put="/api/llista/{{ playlist.id }}/name"
hx-target="#llista-name"
hx-swap="outerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta"
class="text-beige text-3xl mx-1"
hx-get="/api/llista/{{ playlist.id }}/name"
hx-target="#llista-name"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</form>
</div>

View File

@@ -0,0 +1,20 @@
<div class="flex flex-row flex-wrap justify-center"
id="llista-name">
<h3 class="text-3xl text-center p-4">
{% if playlist.name %}
{{ playlist.name }}
{% else %}
Llista de temes
{% endif %}
</h3>
{% if logged_in %}
<button title="Canvia el nom"
class="text-beige text-2xl mx-2"
hx-get="/api/llista/{{ playlist.id }}/editor/name"
hx-target="#llista-name"
hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
{% include "fragments/llista/visibility.html" %}
{% endif %}
</div>

View File

@@ -0,0 +1,14 @@
{% include "fragments/menu.html" %}
<div class="flex justify-center">
<div class="m-12 grow max-w-4xl">
<div id="playlist-name">
{% include "fragments/llista/name.html" %}
</div>
<div class="text-left">
{% set playlist_id = playlist.id %}
{% set playlist = playlist %}
{% include "fragments/llista/playlist.html" %}
</div>
{% include "fragments/llista/score.html" %}
</div>
</div>

View File

@@ -1,14 +1,13 @@
<ul id="playlist-{{ session.id }}" class=""> <ul id="llista-{{ playlist_id }}" class="">
{% for set_entry in playlist.sets %} {% for set_entry in playlist.sets %}
{% set set_id = set_entry.id %} {% set set_id = set_entry.id %}
{% include "fragments/sessio/set_entry.html" %} {% include "fragments/llista/set_entry.html" %} {% endfor %}
{% endfor %}
</ul> </ul>
{% if logged_in %} {% if logged_in %}
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<button class="text-beige mt-2" <button class="text-beige mt-2"
hx-post="/api/sessio/{{ session.id }}/set" hx-post="/api/llista/{{ playlist_id }}/set"
hx-target="#playlist-{{ session.id }}" hx-target="#llista-{{ playlist_id }}"
hx-swap="beforeend transition:true"> hx-swap="beforeend transition:true">
<i class="fa fa-plus" aria-hidden="true"></i> <i class="fa fa-plus" aria-hidden="true"></i>
Afegeix set Afegeix set

View File

@@ -0,0 +1,9 @@
{% if playlist.playlist_score is not none and playlist.playlist_score.pdf_url is not none %}
<div class="flex flex-col items-center mt-4 mb-6">
<div class="bg-beige border rounded border-beige m-2 p-2">
<a href="{{ playlist.playlist_score.pdf_url }}" target="_blank" class="text-white">
Obre en PDF
</a>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,2 @@
{% include "fragments/menu.html" %}
{% include "fragments/llista/set/set_page.html" %}

View File

@@ -13,14 +13,14 @@
{% for tema_in_set in set.temes %} {% for tema_in_set in set.temes %}
{% if tema_in_set.tema is not none %} {% if tema_in_set.tema is not none %}
{% set tema = tema_in_set.tema %} {% set tema = tema_in_set.tema %}
{% include "fragments/sessio/set/tema_title.html" %} {% include "fragments/llista/set/tema_title.html" %}
{% else %} {% else %}
<h3 class="text-center text-3xl p-4"> <i>Desconegut</i> </h3> <h3 class="text-center text-3xl p-4"> <i>Desconegut</i> </h3>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="mx-12"> <div class="mx-12">
<hr class="h-px mt-1 mb-3 bg-beige border-0"> <hr class="h-px mt-1 mb-3 bg-beige border-0">
{% include "fragments/sessio/set/set_score.html"%} {% include "fragments/llista/set/set_score.html"%}
</div> </div>
{% else %} {% else %}
{% for tema_in_set in set.temes %} {% for tema_in_set in set.temes %}
@@ -31,7 +31,7 @@
{% endif %} {% endif %}
{% if tema_in_set.tema is not none %} {% if tema_in_set.tema is not none %}
{% set tema = tema_in_set.tema %} {% set tema = tema_in_set.tema %}
{% include "fragments/sessio/set/tema.html"%} {% include "fragments/llista/set/tema.html"%}
{% else %} {% else %}
<h3 class="text-center text-3xl p-4"> <h3 class="text-center text-3xl p-4">
<i>Desconegut</i> <i>Desconegut</i>

View File

@@ -1,4 +1,4 @@
{% include "fragments/sessio/set/tema_title.html" %} {% include "fragments/llista/set/tema_title.html" %}
<div class="mx-12 text-left"> <div class="mx-12 text-left">
{% if tema.main_score() is not none %} {% if tema.main_score() is not none %}

View File

@@ -0,0 +1,61 @@
<li class="flex flex-row grow items-center
m-4 rounded-lg bg-white
px-2 py-1 my-1"
id="set-entry-{{ set_id }}"
hx-get="/api/llista/{{ playlist_id }}/set/{{ set_id }}"
hx-target="#set-entry-{{ set_id }}"
hx-swap="outerHTML"
hx-trigger="reload-set-{{ set_id }}">
{% if set_entry.temes | length > 1 or not set_entry.temes[0].tema %}
{% set set_url = "/llista/%d/set/%d" | format(playlist_id, set_id) %}
{% else %}
{% set set_url = "/tema/%d" | format(set_entry.temes[0].tema_id) %}
{% endif %}
{% if logged_in %}
<div class="flex flex-row grow items-center">
{% else %}
<a href="{{ set_url }}"
class="flex flex-row grow items-center cursor-pointer"
title="Mostra els temes">
{% endif %}
<div class="flex-1"></div>
<div class="flex flex-col
items-start
py-2 mx-2
w-full max-w-[655px]">
{% if logged_in %}
<button class="text-beige w-full"
hx-delete="/api/llista/{{ playlist_id }}/set/{{ set_id }}"
hx-target="#set-entry-{{ set_id }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
Esborra el set
</button>
{% endif %}
<ol id="set-entry-{{ set_id }}-list"
class="flex flex-col items-start w-full">
{% for tema_entry in set_entry.temes %}
{% if new_entry %}
{% include "fragments/llista/tema_editor.html" %}
{% else %}
{% include "fragments/llista/tema_entry.html" %}
{% endif %}
{% endfor %}
</ol>
{% if logged_in %}
<button class="text-beige mt-2 w-full"
hx-post="/api/llista/{{ playlist_id }}/set/{{ set_id }}"
hx-target="#set-entry-{{ set_id }}-list"
hx-swap="beforeend transition:true">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix un tema
</button>
{% endif %}
</div>
<div class="flex-1 text-beige">
{% if logged_in %}<a href="{{ set_url }}" class="text-beige">{% endif %}
<i class="fa fa-chevron-right" aria-hidden="true"></i>
{% if logged_in %}</a>{% endif %}
</div>
{% if logged_in %}</div>{% else %}</a>{% endif %}
</li>

View File

@@ -12,13 +12,13 @@
class="border border-beige focus:outline-none class="border border-beige focus:outline-none
rounded text-center text-black rounded text-center text-black
p-1 m-1" p-1 m-1"
hx-get="/api/sessio/{{ session_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}/busca" hx-get="/api/llista/{{ playlist_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}/busca"
hx-trigger="revealed, keyup delay:500ms changed" hx-trigger="revealed, keyup delay:500ms changed"
hx-target="#tune-entry-{{ tema_entry.id }}-search-results" hx-target="#tune-entry-{{ tema_entry.id }}-search-results"
hx-swap="outerHTML"/> hx-swap="outerHTML"/>
<button title="Descarta els canvis" <button title="Descarta els canvis"
class="text-beige mx-1" class="text-beige mx-1"
hx-get="/api/sessio/{{ session_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}" hx-get="/api/llista/{{ playlist_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}"
hx-target="#tune-entry-{{ tema_entry.id }}" hx-target="#tune-entry-{{ tema_entry.id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i> <i class="fa fa-times" aria-hidden="true"></i>

View File

@@ -15,14 +15,14 @@
<div class="flex-none flex flex-row shrink-0"> <div class="flex-none flex flex-row shrink-0">
<button title="Edita el tema" <button title="Edita el tema"
class="text-beige mx-1" class="text-beige mx-1"
hx-get="/api/sessio/{{ session_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}/editor" hx-get="/api/llista/{{ playlist_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}/editor"
hx-target="#tune-entry-{{ tema_entry.id }}" hx-target="#tune-entry-{{ tema_entry.id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i> <i class="fa fa-pencil" aria-hidden="true"></i>
</button> </button>
<button title="Esborra el tema" <button title="Esborra el tema"
class="text-beige mx-1" class="text-beige mx-1"
hx-delete="/api/sessio/{{ session_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}" hx-delete="/api/llista/{{ playlist_id }}/set/{{ set_id }}/tema/{{ tema_entry.id }}"
hx-target="#tune-entry-{{ tema_entry.id }}" hx-target="#tune-entry-{{ tema_entry.id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i> <i class="fa fa-times" aria-hidden="true"></i>

View File

@@ -4,7 +4,7 @@
<li> <li>
<button class="bg-beige text-brown rounded <button class="bg-beige text-brown rounded
m-1 px-2" m-1 px-2"
hx-put="/api/sessio/{{ session_id }}/set/{{ set_id }}/tema/{{ entry_id }}" hx-put="/api/llista/{{ playlist_id }}/set/{{ set_id }}/tema/{{ entry_id }}"
hx-vals='{"tema_id": "{{ tema.id }}"}' hx-vals='{"tema_id": "{{ tema.id }}"}'
hx-target="#tune-entry-{{ entry_id }}" hx-target="#tune-entry-{{ entry_id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
@@ -15,7 +15,7 @@
<li> <li>
<button class="border border-beige text-beige rounded <button class="border border-beige text-beige rounded
m-1 px-2" m-1 px-2"
hx-put="/api/sessio/{{ session_id }}/set/{{ set_id }}/tema/{{ entry_id }}/unknown" hx-put="/api/llista/{{ playlist_id }}/set/{{ set_id }}/tema/{{ entry_id }}/unknown"
hx-target="#tune-entry-{{ entry_id }}" hx-target="#tune-entry-{{ entry_id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="fa fa-question" aria-hidden="true"></i> <i class="fa fa-question" aria-hidden="true"></i>
@@ -25,7 +25,7 @@
<li> <li>
<button class="border border-beige text-beige rounded <button class="border border-beige text-beige rounded
m-1 px-2" m-1 px-2"
hx-post="/api/sessio/{{ session_id }}/set/{{ set_id }}/tema/{{ entry_id }}" hx-post="/api/llista/{{ playlist_id }}/set/{{ set_id }}/tema/{{ entry_id }}"
hx-vals='{"title": "{{ query }}"}' hx-vals='{"title": "{{ query }}"}'
hx-target="#tune-entry-{{ entry_id }}" hx-target="#tune-entry-{{ entry_id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">

View File

@@ -0,0 +1,15 @@
{% if not playlist.hidden %}
<button title="Llista visible (amaga-la)"
class="text-beige text-2xl mx-2"
hx-put="/api/llista/{{ playlist.id }}/invisible"
hx-swap="outerHTML">
<i class="fa fa-eye" aria-hidden="true"></i>
</button>
{% else %}
<button title="Llista invisible (mostra-la)"
class="text-beige text-2xl mx-2"
hx-put="/api/llista/{{ playlist.id }}/visible"
hx-swap="outerHTML">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
</button>
{% endif %}

View File

@@ -0,0 +1,26 @@
{% include "fragments/menu.html" %}
<div class="p-12 text-center">
<h3 class="text-3xl text-beige p-4">Llistes de Repertori</h3>
{% if logged_in %}
<button title="Afegeix una llista"
class="text-beige m-2"
hx-post="/api/llistes/editor/"
hx-target="#{{ playlist_list_id }}"
hx-swap="afterbegin transition:true">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix una llista
</button>
{% endif %}
<div id="{{ playlist_list_id }}"
class="flex flex-col items-center">
{% if playlists %}
{% for playlist in playlists %}
{% include "fragments/llistes/playlist_entry.html" %}
{% endfor %}
{% else %}
<div class="text-center py-8">
<p class="text-beige/70">No hi ha llistes disponibles.</p>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,32 @@
<div class="
border rounded border-beige
p-2 m-2 w-full max-w-xl
relative"
id="playlist-entry-{{ playlist.id }}">
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="text-xl text-white">
<a href="/llista/{{ playlist.id }}">
{% if playlist.name %}
{{ playlist.name }}
{% else %}
Llista de temes
{% endif %}
</a>
</p>
</div>
{% if logged_in %}
<div class="ml-auto">
{% include "fragments/llistes/playlist_visibility.html" %}
<button title="Esborra la llista"
class="text-beige mx-1"
hx-delete="/api/llistes/{{ playlist.id }}"
hx-target="#playlist-entry-{{ playlist.id }}"
hx-swap="outerHTML swap:0.5s">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,15 @@
{% if not playlist.hidden %}
<button title="Llista visible (amaga-la)"
class="text-beige mx-2"
hx-put="/api/llista/{{ playlist.id }}/invisible"
hx-swap="outerHTML">
<i class="fa fa-eye" aria-hidden="true"></i>
</button>
{% else %}
<button title="Llista invisible (mostra-la)"
class="text-beige mx-2"
hx-put="/api/llista/{{ playlist.id }}/visible"
hx-swap="outerHTML">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
</button>
{% endif %}

View File

@@ -27,6 +27,19 @@
</button> </button>
</li> </li>
{% if menu_selected_id == Pages.Llistes %}
<li class="p-2 text-beige">
<button>
{% else %}
<li class="p-2">
<button hx-get="/api/content/llistes"
hx-replace-url="/llistes"
hx-target="#content">
{% endif %}
Llistes
</button>
</li>
</ul> </ul>
<hr class="h-px bg-beige border-0"> <hr class="h-px bg-beige border-0">
<div hx-get="/api/sessions/live" <div hx-get="/api/sessions/live"

View File

@@ -0,0 +1,36 @@
<div id="cartell"
class="flex flex-col items-center">
{% if session.cartell_url %}
<div class="max-w-[655px] w-full
flex flex-col items-center">
{% if logged_in %}
<div class="my-2 w-full text-beige text-right">
<button title="Modifica el cartell"
class="mx-1"
hx-get="/api/sessio/{{ session_id }}/cartell/editor"
hx-target="#cartell"
hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
<button title="Esborra el cartell"
class="mx-1"
hx-delete="/api/sessio/{{ session_id }}/cartell"
hx-target="#cartell"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</div>
{% endif %}
<img class="w-full h-auto"
src="{{ session.cartell_url }}"/>
</div>
{% elif logged_in %}
<button class="text-beige mt-2"
hx-get="/api/sessio/{{ session_id }}/cartell/editor"
hx-target="#cartell"
hx-swap="outerHTML">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix cartell
</button>
{% endif %}
</div>

View File

@@ -0,0 +1,23 @@
<form id="cartell-editor"
class="flex flex-row gap-2"
hx-encoding="multipart/form-data">
<input type='file'
class="border border-beige focus:outline-none
rounded grow
bg-brown p-1 my-1"
name='upload_file'/>
<button title="Desa els canvis"
class="mx-1 text-beige"
hx-put="/api/sessio/{{ session_id }}/cartell"
hx-target="#cartell-editor"
hx-swap="outerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta els canvis"
class="mx-1 text-beige"
hx-get="/api/sessio/{{ session_id }}/cartell"
hx-target="#cartell-editor"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</form>

View File

@@ -0,0 +1,13 @@
<h4 class="py-4 text-xl text-beige">
Notes
{% if logged_in %}
<button title="Edita les notes"
class="mx-1"
hx-get="/api/sessio/{{ session_id }}/notes/editor"
hx-target="#notes"
hx-swap="innerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
{% endif %}
</h4>
{{ notes | safe }}

View File

@@ -0,0 +1,28 @@
<h4 class="py-4 text-xl text-beige">Notes</h4>
<form id="notes-form"
class="w-full">
<h5 class="text-sm text-beige text-right">
<button title="Desa els canvis"
class="mx-1"
hx-put="/api/sessio/{{session_id}}/notes"
hx-target="#notes"
hx-swap="innerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta els canvis"
class="mx-1"
hx-get="/api/sessio/{{session_id}}/notes"
hx-target="#notes"
hx-swap="innerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
<textarea name="content"
placeholder="Notes"
rows="{{ max(notes.count('\n') + 1, 5) }}"
class="border border-beige focus:outline-none
w-full text-left
rounded
bg-brown p-2 m-0">{{notes}}</textarea>
</form>

View File

@@ -12,6 +12,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</h3> </h3>
{% include "fragments/sessio/cartell.html" %}
<div class="text-left"> <div class="text-left">
<h4 class="pt-4 text-xl text-beige">Horari i lloc</h4> <h4 class="pt-4 text-xl text-beige">Horari i lloc</h4>
De {{ session.start_time.strftime("%H:%M") }} De {{ session.start_time.strftime("%H:%M") }}
@@ -29,10 +30,35 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% if logged_in or playlist.sets %} {% if logged_in or session.notes %}
<div id="notes"
class="text-left">
{% set notes = session.notes if session.notes else "" %}
{% include "fragments/sessio/notes.html" %}
</div>
{% endif %}
{% if logged_in or (session.slowjam and session.slowjam.sets) %}
<div class="text-left"> <div class="text-left">
<h4 class="py-4 text-xl text-beige mt-2">Temes tocats</h4> <h4 class="py-4 text-xl text-beige mt-2">
{% include "fragments/sessio/playlist.html" %} <a href="/llista/{{ session.slowjam.id }}" class="text-beige">
Slow Jam
</a>
</h4>
{% set playlist_id = session.slowjam.id %}
{% set playlist = session.slowjam %}
{% include "fragments/llista/playlist.html" %}
</div>
{% endif %}
{% if logged_in or (session.setlist and session.setlist.sets) %}
<div class="text-left">
<h4 class="py-4 text-xl text-beige mt-2">
<a href="/llista/{{ session.setlist.id }}" class="text-beige">
Temes tocats
</a>
</h4>
{% set playlist_id = session.setlist.id %}
{% set playlist = session.setlist %}
{% include "fragments/llista/playlist.html" %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,2 +0,0 @@
{% include "fragments/menu.html" %}
{% include "fragments/sessio/set/set_page.html" %}

View File

@@ -1,55 +0,0 @@
<li class="flex flex-row grow items-center
m-4 rounded-lg bg-white
px-2 py-1 my-1"
id="set-entry-{{ set_id }}"
hx-get="/api/sessio/{{ session_id }}/set/{{ set_id }}"
hx-target="#set-entry-{{ set_id }}"
hx-swap="outerHTML"
hx-trigger="reload-set-{{ set_id }}">
{% if set_entry.temes | length > 1 or not set_entry.temes[0] %}
{% set set_url = "/sessio/%d/set/%d" | format(session_id, set_id) %}
{% else %}
{% set set_url = "/tema/%d" | format(set_entry.temes[0].tema_id) %}
{% endif %}
<a href="{{ set_url }}"
class="flex flex-row grow items-center cursor-pointer"
title="Mostra els temes">
<div class="flex-1"></div>
<div class="flex flex-col
items-start
py-2 mx-2
w-full max-w-[655px]">
{% if logged_in %}
<button class="text-beige w-full"
hx-delete="/api/sessio/{{ session_id }}/set/{{ set_id }}"
hx-target="#set-entry-{{ set_id }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
Esborra el set
</button>
{% endif %}
<ol id="set-entry-{{ set_id }}-list"
class="flex flex-col items-start w-full">
{% for tema_entry in set_entry.temes %}
{% if new_entry %}
{% include "fragments/sessio/tema_editor.html" %}
{% else %}
{% include "fragments/sessio/tema_entry.html" %}
{% endif %}
{% endfor %}
</ol>
{% if logged_in %}
<button class="text-beige mt-2 w-full"
hx-post="/api/sessio/{{ session_id }}/set/{{ set_id }}"
hx-target="#set-entry-{{ set_id }}-list"
hx-swap="beforeend transition:true">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix un tema
</button>
{% endif %}
</div>
<div class="flex-1 text-beige">
<i class="fa fa-chevron-right" aria-hidden="true"></i>
</div>
</a>
</li>

View File

@@ -4,7 +4,7 @@
class="border border-beige focus:outline-none class="border border-beige focus:outline-none
rounded grow rounded grow
bg-brown p-1 my-1" bg-brown p-1 my-1"
name='upload_file'> name='upload_file'/>
<button title="Afegeix un enllaç" <button title="Afegeix un enllaç"
class="border border-beige rounded px-2 py-1 my-1" class="border border-beige rounded px-2 py-1 my-1"
hx-get="/api/tema/{{ link.tema_id }}/editor/link/{{ link.id }}/url" hx-get="/api/tema/{{ link.tema_id }}/editor/link/{{ link.id }}/url"

View File

@@ -7,6 +7,16 @@
rounded rounded
bg-brown px-2 " bg-brown px-2 "
/> />
<input name="max_columns"
type="number"
min="1"
max="10"
placeholder="3 (default)"
value="{{ lyric.max_columns or '' }}"
class="border border-beige focus:outline-none
rounded
bg-brown px-2 ml-2 w-16 "
/>
<button title="Desa els canvis" <button title="Desa els canvis"
class="mx-1" class="mx-1"
hx-put="/api/tema/{{ lyric.tema_id }}/lyric/{{ lyric.id }}" hx-put="/api/tema/{{ lyric.tema_id }}/lyric/{{ lyric.id }}"

View File

@@ -1,6 +1,9 @@
<div id="tema-lyric-{{ lyric.id }}"> <div id="tema-lyric-{{ lyric.id }}">
<h5 class="text-sm text-beige text-right"> <h5 class="text-sm text-beige text-right">
{{ lyric.title }} {{ lyric.title }}
{% if logged_in and lyric.max_columns %}
<span class="text-xs ml-2">{{ lyric.max_columns }} columnes</span>
{% endif %}
{% if logged_in %} {% if logged_in %}
<button title="Modifica la lletra" <button title="Modifica la lletra"
class="mx-1" class="mx-1"
@@ -20,6 +23,6 @@
</h5> </h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0"> <hr class="h-px mt-1 mb-3 bg-beige border-0">
<div class="text-center"> <div class="text-center">
{{ lyric.content.replace('\n', '<br>') | safe }} {{ (lyric.content.replace('~', '')).replace('\n', '<br>') | safe }}
</div> </div>
</div> </div>

View File

@@ -4,6 +4,16 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<!-- Essential Open Graph (Facebook, WhatsApp, etc.) -->
<meta property="og:title" content="{{ page_title }}">
<meta property="og:description" content="{{ page_description }}">
<meta property="og:image" content="{{ page_card or url_for(request, 'static', path='img/folkugat_card.jpg') }}">
<!-- Twitter Card (optional but recommended) -->
<meta name="twitter:title" content="{{ page_title }}">
<meta name="twitter:description" content="{{ page_description }}">
<meta name="twitter:image" content="{{ page_card or url_for(request, 'static', path='img/folkugat_card.jpg') }}">
<title>{{ page_title }}</title> <title>{{ page_title }}</title>
<meta name="description" content="{{ page_description }}"/> <meta name="description" content="{{ page_description }}"/>

View File

@@ -0,0 +1,84 @@
% -------------------------------------------------------------------------
% Extra custom functions
% -------------------------------------------------------------------------
% Alternative chords
#(define (pitches-bass-inversion-context-list music context)
"Creates a list containing relevant pitches, bass, inversion and context to
serve as argument for `ignatzek-chord-names', derived from @var{music}
"
(let* (;; work on a copy of `music', warranting not to change it
(m-add (ly:music-deep-copy music))
(ev-notes-orig (event-chord-notes m-add))
;; if chord is an inversion some pitches are below root, in this case
;; 'octavation-property is set.
;; reset pitch of them
;; TODO this is an ugly hack, find better method
(ev-notes
(map
(lambda (m)
(if (and (not (null? (ly:music-property m 'octavation)))
(= (ly:music-property m 'octavation) -1))
(begin
(ly:music-set-property! m 'pitch
(ly:pitch-transpose
(ly:music-property m 'pitch)
(ly:make-pitch 1 0 0)))
m)
m))
ev-notes-orig))
;; sort the ptches ascending as expected by `ignatzek-chord-names'
(ev-pitches
(sort
(map (lambda (m) (ly:music-property m 'pitch)) ev-notes)
ly:pitch<?))
;; get bass
(bass-note
(filter
(lambda (m)
(not (null? (ly:music-property m 'bass))))
ev-notes))
(bass-pitch
(if (not (null? bass-note))
(ly:music-property (car bass-note) 'pitch)
'()))
;; get inversion
(inversion-note
(filter
(lambda (m)
(not (null? (ly:music-property m 'inversion))))
ev-notes))
(inversion-pitch
(if (not (null? inversion-note))
(ly:music-property (car inversion-note) 'pitch)
'()))
;; TODO why is this needed?
(in-pitches
(if (null? inversion-pitch)
(delq bass-pitch ev-pitches)
ev-pitches)))
(list in-pitches bass-pitch inversion-pitch context)))
altChords =
#(define-music-function (m1 m2)(ly:music? ly:music?)
"Return the default ChordName of @var{m1}, with an added parenthesized ChordName
derived from @var{m2}"
#{
\applyOutput ChordNames.ChordName
#(lambda (g ctx p)
(let ((main-text (ly:grob-property g 'text))
(alt-text
(apply
ignatzek-chord-names
(pitches-bass-inversion-context-list m2 ctx))))
(ly:grob-set-property! g 'text
#{
\markup
{ $main-text \hspace #0.4 \fontsize #-3 \parenthesize $alt-text }
#})))
$m1
#})
% -------------------------------------------------------------------------

View File

@@ -0,0 +1,49 @@
{{ score_beginning }}
\version "2.24.4"
{% include "lilypond/lib.ly" %}
\book {
\paper {
top-margin = 10
left-margin = 15
right-margin = 15
ragged-bottom = ##f
scoreTitleMarkup = \markup {
\center-column {
\fontsize #3 \bold \fromproperty #'header:piece
\fill-line {
\null
\right-column {
\fromproperty #'header:composer
}
}
}
}
}
\header {
title = \markup { "{{ playlist.title | safe }}" }
tagline = "Partitura generada amb LilyPond"
copyright = "Folkugat"
}
{% for set in playlist.sets %}
{% if set.tunes|length > 1 %}
\markup {
\vspace #2
\fill-line {
\center-column {
\fontsize #2 \bold "{{ set.title | safe }}"
}
}
}
\noPageBreak
{% endif %}
{% for tune in set.tunes %}
{% include "lilypond/tune_in_set.ly" %}
{% endfor %}
{% endfor %}
}

View File

@@ -1,4 +1,5 @@
\version "2.24.4" \version "2.24.4"
{% include "lilypond/lib.ly" %}
{% if tune.score_source is not none %} {% if tune.score_source is not none %}
\score { \score {
\language "english" \language "english"

View File

@@ -7,13 +7,25 @@
% ----------------------------------------------------- % -----------------------------------------------------
% A % A
\repeat volta 2 {
\alternative {
\volta 1 { }
\volta 2 { }
}
}
% B % B
\repeat volta 2 {
\alternative {
\volta 1 { }
\volta 2 { }
}
}
} }
} }
\new Staff { \new Staff {
\relative { \relative c' {
% ----------------------------------------------------- % -----------------------------------------------------
% Compàs % Compàs
% ----------------------------------------------------- % -----------------------------------------------------

View File

@@ -1,4 +1,7 @@
\version "2.24.4" \version "2.24.4"
{% include "lilypond/lib.ly" %}
\paper { \paper {
top-margin = 10 top-margin = 10
left-margin = 15 left-margin = 15
@@ -35,7 +38,7 @@
\hspace #1 \hspace #1
\center-column { \center-column {
{% for line in paragraph.lines %} {% for line in paragraph.lines %}
\line { {{ line | safe }} } {% if line %} \line { {{ line | safe }} } {% else %} \vspace #1 {% endif %}
{% endfor %} {% endfor %}
} }
{% endfor %} {% endfor %}

View File

@@ -9,6 +9,7 @@
} }
} }
} }
\noPageBreak
{% if tune.score_source is not none %} {% if tune.score_source is not none %}
\score { \score {
\language "english" \language "english"

View File

@@ -1,5 +1,8 @@
{{ score_beginning }} {{ score_beginning }}
\version "2.24.4" \version "2.24.4"
{% include "lilypond/lib.ly" %}
\book { \book {
\paper { \paper {
top-margin = 10 top-margin = 10

View File

@@ -12,13 +12,16 @@ logger.info(f"Using DB_DIR: {DB_DIR}")
DB_FILES_DIR = DB_DIR / "fitxer" DB_FILES_DIR = DB_DIR / "fitxer"
DB_FILES_TEMA_DIR = DB_FILES_DIR / "tema" DB_FILES_TEMA_DIR = DB_FILES_DIR / "tema"
DB_FILES_SESSION_DIR = DB_FILES_DIR / "sessio"
DB_FILES_SET_DIR = DB_FILES_DIR / "set" DB_FILES_SET_DIR = DB_FILES_DIR / "set"
DB_FILES_PLAYLIST_DIR = DB_FILES_DIR / "llistes"
DB_FILES_TMP_DIR = DB_FILES_DIR / "tmp" DB_FILES_TMP_DIR = DB_FILES_DIR / "tmp"
for path in [ for path in [
DB_FILES_DIR, DB_FILES_DIR,
DB_FILES_TEMA_DIR, DB_FILES_TEMA_DIR,
DB_FILES_SET_DIR, DB_FILES_SET_DIR,
DB_FILES_PLAYLIST_DIR,
DB_FILES_SET_DIR, DB_FILES_SET_DIR,
]: ]:
path.mkdir(exist_ok=True) path.mkdir(exist_ok=True)

View File

@@ -2,12 +2,13 @@ from typing import TypedDict
from folkugat_web.model import playlists as model from folkugat_web.model import playlists as model
PlaylistRowTuple = tuple[int, int, int, int | None] PlaylistRowTuple = tuple[int, str | None, int]
PlaylistEntryRowTuple = tuple[int, int, int, int | None]
class PlaylistRowDict(TypedDict): class PlaylistRowDict(TypedDict):
id: int | None id: int | None
session_id: int playlist_id: int
set_id: int set_id: int
tema_id: int | None tema_id: int | None
@@ -15,16 +16,25 @@ class PlaylistRowDict(TypedDict):
def playlist_entry_to_row(tema_in_set: model.PlaylistEntry) -> PlaylistRowDict: def playlist_entry_to_row(tema_in_set: model.PlaylistEntry) -> PlaylistRowDict:
return { return {
'id': tema_in_set.id, 'id': tema_in_set.id,
'session_id': tema_in_set.session_id, 'playlist_id': tema_in_set.playlist_id,
'set_id': tema_in_set.set_id, 'set_id': tema_in_set.set_id,
'tema_id': tema_in_set.tema_id, 'tema_id': tema_in_set.tema_id,
} }
def row_to_playlist_entry(row: PlaylistRowTuple) -> model.PlaylistEntry: def row_to_playlist_entry(row: PlaylistEntryRowTuple) -> model.PlaylistEntry:
return model.PlaylistEntry( return model.PlaylistEntry(
id=row[0], id=row[0],
session_id=row[1], playlist_id=row[1],
set_id=row[2], set_id=row[2],
tema_id=row[3], tema_id=row[3],
) )
def row_to_playlist(row: PlaylistRowTuple) -> model.Playlist:
return model.Playlist(
id=row[0],
name=row[1],
sets=[], # Empty for list view
hidden=bool(row[2]),
)

View File

@@ -4,15 +4,30 @@ from folkugat_web.dal.sql import Connection, get_connection
def create_db(con: Connection | None = None): def create_db(con: Connection | None = None):
with get_connection(con) as con: with get_connection(con) as con:
create_playlists_table(con) create_playlists_table(con)
create_playlist_entries_table(con)
def create_playlists_table(con: Connection): def create_playlists_table(con: Connection):
query = """ query = """
CREATE TABLE IF NOT EXISTS playlists ( CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL, name TEXT,
set_id INTEGER NOT NULL, hidden INTEGER NOT NULL DEFAULT 1
tema_id INTEGER )
"""
cur = con.cursor()
_ = cur.execute(query)
def create_playlist_entries_table(con: Connection):
query = """
CREATE TABLE IF NOT EXISTS playlist_entries (
id INTEGER PRIMARY KEY,
playlist_id INTEGER NOT NULL,
set_id INTEGER NOT NULL,
tema_id INTEGER,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (tema_id) REFERENCES temes(id) ON DELETE CASCADE
) )
""" """
cur = con.cursor() cur = con.cursor()

View File

@@ -1,11 +1,9 @@
from collections.abc import Iterable, Iterator from collections.abc import Iterator
from typing import TypedDict from typing import TypedDict
from folkugat_web.dal.sql import Connection, get_connection from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.dal.sql.sessions import conversion as sessions_conversion from folkugat_web.dal.sql.sessions import conversion as sessions_conversion
from folkugat_web.dal.sql.temes.conversion import row_to_tema
from folkugat_web.model import playlists as model from folkugat_web.model import playlists as model
from folkugat_web.model import temes as temes_model
from folkugat_web.model.sessions import Session from folkugat_web.model.sessions import Session
from folkugat_web.utils import groupby from folkugat_web.utils import groupby
@@ -15,13 +13,13 @@ from . import conversion
class QueryData(TypedDict, total=False): class QueryData(TypedDict, total=False):
id: int id: int
set_id: int set_id: int
session_id: int playlist_id: int
def _filter_clause( def _filter_clause(
entry_id: int | None = None, entry_id: int | None = None,
set_id: int | None = None, set_id: int | None = None,
session_id: int | None = None, playlist_id: int | None = None,
) -> tuple[str, QueryData]: ) -> tuple[str, QueryData]:
filter_clauses: list[str] = [] filter_clauses: list[str] = []
query_data: QueryData = {} query_data: QueryData = {}
@@ -32,9 +30,9 @@ def _filter_clause(
if set_id is not None: if set_id is not None:
filter_clauses.append("set_id = :set_id") filter_clauses.append("set_id = :set_id")
query_data["set_id"] = set_id query_data["set_id"] = set_id
if session_id is not None: if playlist_id is not None:
filter_clauses.append("session_id = :session_id") filter_clauses.append("playlist_id = :playlist_id")
query_data["session_id"] = session_id query_data["playlist_id"] = playlist_id
return " AND ".join(filter_clauses), query_data return " AND ".join(filter_clauses), query_data
@@ -42,14 +40,14 @@ def _filter_clause(
def get_playlist_entries( def get_playlist_entries(
entry_id: int | None = None, entry_id: int | None = None,
set_id: int | None = None, set_id: int | None = None,
session_id: int | None = None, playlist_id: int | None = None,
con: Connection | None = None, con: Connection | None = None,
) -> Iterator[model.PlaylistEntry]: ) -> Iterator[model.PlaylistEntry]:
filter_clause, data = _filter_clause(entry_id=entry_id, set_id=set_id, session_id=session_id) filter_clause, data = _filter_clause(entry_id=entry_id, set_id=set_id, playlist_id=playlist_id)
query = f""" query = f"""
SELECT SELECT
id, session_id, set_id, tema_id id, playlist_id, set_id, tema_id
FROM playlists FROM playlist_entries
WHERE {filter_clause} WHERE {filter_clause}
ORDER BY id ASC ORDER BY id ASC
""" """
@@ -59,55 +57,54 @@ def get_playlist_entries(
return map(conversion.row_to_playlist_entry, cur.fetchall()) return map(conversion.row_to_playlist_entry, cur.fetchall())
GetTuneSessionsRow = tuple[int, int, str, str, str, str | None, str | None, bool] def get_playlist_name(playlist_id: int, con: Connection | None = None) -> str | None:
def get_tune_sessions(tema_ids: list[int], con: Connection | None = None) -> dict[int, list[Session]]:
placeholders = ", ".join(["?" for _ in tema_ids])
query = f"""
SELECT p.tema_id, s.id, s.date, s.start_time, s.end_time, s.venue_name, s.venue_url, s.is_live
FROM playlists p JOIN sessions s ON p.session_id = s.id
WHERE p.tema_id IN ({placeholders})
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, tema_ids)
result_rows: Iterable[GetTuneSessionsRow] = cur.fetchall()
return dict(groupby(
result_rows,
key_fn=lambda row: row[0],
group_fn=lambda rows: list(sessions_conversion.row_to_session(row[1:]) for row in rows)
))
CommonlyPlayedTuneRow = tuple[int, str, str, str, str, int, int]
def get_commonly_played_tunes(
tema_id: int,
con: Connection | None = None,
) -> list[temes_model.CommonlyPlayedTema]:
query = """ query = """
SELECT SELECT name
id, title, alternatives, creation_date, modification_date, hidden, count
FROM (
SELECT tema_id, count(*) count FROM playlists p JOIN (
SELECT session_id, set_id
FROM playlists FROM playlists
WHERE tema_id = ? WHERE id = :playlist_id
) s
ON p.session_id == s.session_id AND p.set_id == s.set_id
WHERE tema_id != ?
GROUP BY tema_id
) common JOIN temes t ON common.tema_id == t.id
""" """
data = dict(playlist_id=playlist_id)
with get_connection(con) as con: with get_connection(con) as con:
cur = con.cursor() cur = con.cursor()
_ = cur.execute(query, [tema_id, tema_id]) _ = cur.execute(query, data)
result_rows: Iterable[CommonlyPlayedTuneRow] = cur.fetchall() row = cur.fetchone()
return [ return row[0] if row else None
temes_model.CommonlyPlayedTema(
tema=row_to_tema(row[:6]),
count=row[6], def get_all_playlists(logged_in: bool = False, con: Connection | None = None) -> Iterator[model.Playlist]:
) for row in result_rows if logged_in:
] # Show all playlists for logged in users, except session-associated ones
query = """
SELECT id, name, hidden
FROM playlists
WHERE id NOT IN (
SELECT DISTINCT playlist_id
FROM session_playlists
)
ORDER BY id DESC
"""
else:
# Show only visible playlists for non-logged in users, except session-associated ones
query = """
SELECT id, name, hidden
FROM playlists
WHERE hidden = 0 AND id NOT IN (
SELECT DISTINCT playlist_id
FROM session_playlists
)
ORDER BY id DESC
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query)
rows = cur.fetchall()
for row in rows:
# Convert to Playlist model without sets for list view
yield model.Playlist(
id=row[0],
name=row[1],
sets=[], # Empty sets list for now
hidden=bool(row[2])
)

View File

@@ -4,27 +4,66 @@ from folkugat_web.model import playlists as model
from . import conversion from . import conversion
def insert_playlist_entry(pl_entry: model.PlaylistEntry, con: Connection | None = None) -> model.PlaylistEntry: def create_playlist(
name: str | None = None,
con: Connection | None = None,
) -> int:
query = """ query = """
INSERT INTO playlists INSERT INTO playlists
(id, session_id, set_id, tema_id) (name)
VALUES VALUES
(:id, :session_id, :set_id, :tema_id) (:name)
RETURNING *
"""
data = dict(name=name)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
row: conversion.PlaylistRowTuple = cur.fetchone()
return row[0]
def delete_playlist(
playlist_id: int,
con: Connection | None = None,
) -> None:
query = """
DELETE FROM playlists
WHERE id = :id
"""
data = dict(id=playlist_id)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
def insert_playlist_entry(
pl_entry: model.PlaylistEntry,
con: Connection | None = None,
) -> model.PlaylistEntry:
query = """
INSERT INTO playlist_entries
(id, playlist_id, set_id, tema_id)
VALUES
(:id, :playlist_id, :set_id, :tema_id)
RETURNING * RETURNING *
""" """
data = conversion.playlist_entry_to_row(pl_entry) data = conversion.playlist_entry_to_row(pl_entry)
with get_connection(con) as con: with get_connection(con) as con:
cur = con.cursor() cur = con.cursor()
_ = cur.execute(query, data) _ = cur.execute(query, data)
row: conversion.PlaylistRowTuple = cur.fetchone() row: conversion.PlaylistEntryRowTuple = cur.fetchone()
return conversion.row_to_playlist_entry(row) return conversion.row_to_playlist_entry(row)
def update_playlist_entry(entry: model.PlaylistEntry, con: Connection | None = None): def update_playlist_entry(
entry: model.PlaylistEntry,
con: Connection | None = None,
):
query = """ query = """
UPDATE playlists UPDATE playlist_entries
SET SET
id = :id, session_id = :session_id, set_id = :set_id, tema_id = :tema_id id = :id, playlist_id = :playlist_id, set_id = :set_id, tema_id = :tema_id
WHERE WHERE
id = :id id = :id
""" """
@@ -35,9 +74,12 @@ def update_playlist_entry(entry: model.PlaylistEntry, con: Connection | None = N
return return
def delete_playlist_entry(entry_id: int, con: Connection | None = None): def delete_playlist_entry(
entry_id: int,
con: Connection | None = None,
):
query = """ query = """
DELETE FROM playlists DELETE FROM playlist_entries
WHERE id = :id WHERE id = :id
""" """
data = dict(id=entry_id) data = dict(id=entry_id)
@@ -47,12 +89,50 @@ def delete_playlist_entry(entry_id: int, con: Connection | None = None):
return return
def delete_playlist_set(session_id: int, set_id: int, con: Connection | None = None): def delete_playlist_set(
playlist_id: int,
set_id: int,
con: Connection | None = None,
):
query = """ query = """
DELETE FROM playlists DELETE FROM playlist_entries
WHERE session_id = :session_id AND set_id = :set_id WHERE playlist_id = :playlist_id AND set_id = :set_id
""" """
data = dict(session_id=session_id, set_id=set_id) data = dict(playlist_id=playlist_id, set_id=set_id)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
return
def update_playlist_name(
playlist_id: int,
name: str | None,
con: Connection | None = None,
):
query = """
UPDATE playlists SET
name = :name
WHERE id = :id
"""
data = dict(id=playlist_id, name=name)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
return
def update_playlist_visibility(
playlist_id: int,
hidden: bool,
con: Connection | None = None,
):
query = """
UPDATE playlists SET
hidden = :hidden
WHERE id = :id
"""
data = dict(id=playlist_id, hidden=1 if hidden else 0)
with get_connection(con) as con: with get_connection(con) as con:
cur = con.cursor() cur = con.cursor()
_ = cur.execute(query, data) _ = cur.execute(query, data)

View File

@@ -3,7 +3,17 @@ from typing import TypedDict
from folkugat_web.model import sessions as model from folkugat_web.model import sessions as model
SessionRowTuple = tuple[int, str, str, str, str | None, str | None, bool] SessionRowTuple = tuple[
int, # id
str, # date
str, # start_time
str, # end_time
str | None, # venue_name
str | None, # venue_url
str | None, # notes
str | None, # cartell_url
bool, # is_live
]
class SessionRowDict(TypedDict): class SessionRowDict(TypedDict):
@@ -13,6 +23,8 @@ class SessionRowDict(TypedDict):
end_time: str end_time: str
venue_name: str | None venue_name: str | None
venue_url: str | None venue_url: str | None
notes: str | None
cartell_url: str | None
is_live: bool is_live: bool
@@ -24,6 +36,8 @@ def session_to_row(sessio: model.Session) -> SessionRowDict:
'end_time': sessio.end_time.isoformat(), 'end_time': sessio.end_time.isoformat(),
'venue_name': sessio.venue.name, 'venue_name': sessio.venue.name,
'venue_url': sessio.venue.url, 'venue_url': sessio.venue.url,
'notes': sessio.notes,
'cartell_url': sessio.cartell_url,
'is_live': sessio.is_live, 'is_live': sessio.is_live,
} }
@@ -38,5 +52,7 @@ def row_to_session(row: SessionRowTuple) -> model.Session:
name=row[4], name=row[4],
url=row[5], url=row[5],
), ),
is_live=row[6], notes=row[6],
cartell_url=row[7],
is_live=row[8],
) )

View File

@@ -4,6 +4,7 @@ from folkugat_web.dal.sql import Connection, get_connection
def create_db(con: Connection | None = None): def create_db(con: Connection | None = None):
with get_connection(con) as con: with get_connection(con) as con:
create_sessions_table(con) create_sessions_table(con)
create_session_playlists_table(con)
def create_sessions_table(con: Connection): def create_sessions_table(con: Connection):
@@ -15,8 +16,25 @@ def create_sessions_table(con: Connection):
end_time TEXT NOT NULL, end_time TEXT NOT NULL,
venue_name TEXT, venue_name TEXT,
venue_url TEXT, venue_url TEXT,
notes TEXT,
cartell_url TEXT,
is_live BOOLEAN DEFAULT false is_live BOOLEAN DEFAULT false
) )
""" """
cur = con.cursor() cur = con.cursor()
_ = cur.execute(query) _ = cur.execute(query)
def create_session_playlists_table(con: Connection):
query = """
CREATE TABLE IF NOT EXISTS session_playlists (
session_id INTEGER,
playlist_type TEXT,
playlist_id INTEGER NOT NULL,
PRIMARY KEY (session_id, playlist_type),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)
"""
cur = con.cursor()
_ = cur.execute(query)

View File

@@ -0,0 +1,138 @@
from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.dal.sql.temes.conversion import row_to_tema
from folkugat_web.model import sessions as sessions_model
from folkugat_web.model import temes as temes_model
from folkugat_web.model.playlists import PlaylistType
from folkugat_web.utils import groupby
from . import conversion
def get_playlist_id(
session_id: int,
playlist_type: PlaylistType,
con: Connection | None = None,
) -> int | None:
query = f"""
SELECT playlist_id
FROM session_playlists
WHERE
session_id = :session_id AND
playlist_type = :playlist_type
"""
data = dict(
session_id=session_id,
playlist_type=playlist_type.value,
)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
if row := cur.fetchone():
return row[0]
return None
def insert_playlist(
session_id: int,
playlist_type: PlaylistType,
playlist_id: int,
con: Connection | None = None,
):
query = f"""
INSERT INTO session_playlists
(session_id, playlist_type, playlist_id)
VALUES
(:session_id, :playlist_type, :playlist_id)
"""
data = dict(
session_id=session_id,
playlist_type=playlist_type.value,
playlist_id=playlist_id,
)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
def delete_playlist(
session_id: int,
playlist_type: PlaylistType,
con: Connection | None = None,
):
query = f"""
DELETE FROM session_playlists
WHERE
session_id = :session_id AND
playlist_type = :playlist_type
"""
data = dict(
session_id=session_id,
playlist_type=playlist_type.value,
)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
GetTuneSessionsRow = tuple[int, int, str, str, str, str | None, str | None, str | None, str | None, bool]
def get_tune_sessions(
tema_ids: list[int],
con: Connection | None = None,
) -> dict[int, list[sessions_model.Session]]:
placeholders = ", ".join(["?" for _ in tema_ids])
query = f"""
SELECT
p.tema_id, s.id, s.date, s.start_time, s.end_time, s.venue_name,
s.venue_url, s.notes, s.cartell_url, s.is_live
FROM playlist_entries p
JOIN session_playlists sp ON p.playlist_id = sp.playlist_id
JOIN sessions s ON sp.session_id = s.id
WHERE
p.tema_id IN ({placeholders}) AND
sp.playlist_type = "{PlaylistType.SESSION_SETLIST.value}"
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, tema_ids)
result_rows: list[GetTuneSessionsRow] = cur.fetchall()
return dict(groupby(
result_rows,
key_fn=lambda row: row[0],
group_fn=lambda rows: list(conversion.row_to_session(row[1:]) for row in rows)
))
CommonlyPlayedTuneRow = tuple[int, str, str, str, str, int, int]
def get_commonly_played_tunes(
tema_id: int,
con: Connection | None = None,
) -> list[temes_model.CommonlyPlayedTema]:
query = f"""
SELECT
id, title, alternatives, creation_date, modification_date, hidden, count
FROM (
SELECT tema_id, count(*) count FROM playlist_entries p JOIN (
SELECT pe.playlist_id, pe.set_id
FROM playlist_entries pe JOIN session_playlists sp USING (playlist_id)
WHERE tema_id = :tema_id AND playlist_type = :playlist_type
) s
ON p.playlist_id == s.playlist_id AND p.set_id == s.set_id
WHERE tema_id != :tema_id
GROUP BY tema_id
) common JOIN temes t ON common.tema_id == t.id
"""
data = dict(tema_id=tema_id, playlist_type=PlaylistType.SESSION_SETLIST.value)
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
result_rows: list[CommonlyPlayedTuneRow] = cur.fetchall()
return [
temes_model.CommonlyPlayedTema(
tema=row_to_tema(row[:6]),
count=row[6],
) for row in result_rows
]

View File

@@ -82,7 +82,7 @@ def get_sessions(session_id: int | None = None,
clauses_str = " ".join(clauses) clauses_str = " ".join(clauses)
query = f""" query = f"""
SELECT id, date, start_time, end_time, venue_name, venue_url, is_live SELECT id, date, start_time, end_time, venue_name, venue_url, notes, cartell_url, is_live
FROM sessions FROM sessions
{clauses_str} {clauses_str}
""" """

View File

@@ -7,9 +7,9 @@ from . import conversion
def insert_session(session: model.Session, con: Connection | None = None): def insert_session(session: model.Session, con: Connection | None = None):
query = """ query = """
INSERT INTO sessions INSERT INTO sessions
(id, date, start_time, end_time, venue_name, venue_url, is_live) (id, date, start_time, end_time, venue_name, venue_url, notes, cartell_url, is_live)
VALUES VALUES
(:id, :date, :start_time, :end_time, :venue_name, :venue_url, :is_live) (:id, :date, :start_time, :end_time, :venue_name, :venue_url, :notes, :cartell_url, :is_live)
RETURNING * RETURNING *
""" """
data = conversion.session_to_row(session) data = conversion.session_to_row(session)
@@ -24,7 +24,8 @@ def update_session(session: model.Session, con: Connection | None = None):
query = """ query = """
UPDATE sessions SET UPDATE sessions SET
date = :date, start_time = :start_time, end_time = :end_time, date = :date, start_time = :start_time, end_time = :end_time,
venue_name = :venue_name, venue_url = :venue_url, is_live = :is_live venue_name = :venue_name, venue_url = :venue_url,
notes = :notes, cartell_url = :cartell_url, is_live = :is_live
WHERE id = :id WHERE id = :id
""" """
data = conversion.session_to_row(session) data = conversion.session_to_row(session)

View File

@@ -4,7 +4,7 @@ from typing import TypedDict
from folkugat_web.dal.sql import Connection, get_connection from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
LyricRowTuple = tuple[int, int, str, str] LyricRowTuple = tuple[int, int, str, str, int | None]
class LyricRowDict(TypedDict): class LyricRowDict(TypedDict):
@@ -12,6 +12,7 @@ class LyricRowDict(TypedDict):
tema_id: int tema_id: int
title: str title: str
content: str content: str
max_columns: int | None
def lyric_to_row(lyric: model.Lyrics) -> LyricRowDict: def lyric_to_row(lyric: model.Lyrics) -> LyricRowDict:
@@ -20,6 +21,7 @@ def lyric_to_row(lyric: model.Lyrics) -> LyricRowDict:
"tema_id": lyric.tema_id, "tema_id": lyric.tema_id,
"title": lyric.title, "title": lyric.title,
"content": lyric.content, "content": lyric.content,
"max_columns": lyric.max_columns,
} }
@@ -29,6 +31,7 @@ def row_to_lyric(row: LyricRowTuple) -> model.Lyrics:
tema_id=row[1], tema_id=row[1],
title=row[2], title=row[2],
content=row[3], content=row[3],
max_columns=row[4],
) )
@@ -63,7 +66,7 @@ def get_lyrics(lyric_id: int | None = None, tema_id: int | None = None, con: Con
query = f""" query = f"""
SELECT SELECT
id, tema_id, title, content id, tema_id, title, content, max_columns
FROM tema_lyrics FROM tema_lyrics
{filter_clause} {filter_clause}
""" """
@@ -77,9 +80,9 @@ def insert_lyric(lyric: model.Lyrics, con: Connection | None = None) -> model.Ly
data = lyric_to_row(lyric) data = lyric_to_row(lyric)
query = f""" query = f"""
INSERT INTO tema_lyrics INSERT INTO tema_lyrics
(id, tema_id, title, content) (id, tema_id, title, content, max_columns)
VALUES VALUES
(:id, :tema_id, :title, :content) (:id, :tema_id, :title, :content, :max_columns)
RETURNING * RETURNING *
""" """
with get_connection(con) as con: with get_connection(con) as con:
@@ -95,6 +98,7 @@ def create_lyric(tema_id: int, title: str | None = None, con: Connection | None
tema_id=tema_id, tema_id=tema_id,
title=title or "", title=title or "",
content="", content="",
max_columns=None,
) )
return insert_lyric(new_lyric, con=con) return insert_lyric(new_lyric, con=con)
@@ -104,7 +108,7 @@ def update_lyric(lyric: model.Lyrics, con: Connection | None = None):
query = """ query = """
UPDATE tema_lyrics UPDATE tema_lyrics
SET SET
tema_id = :tema_id, title = :title, content = :content tema_id = :tema_id, title = :title, content = :content, max_columns = :max_columns
WHERE WHERE
id = :id id = :id
""" """

View File

@@ -0,0 +1 @@
from folkugat_web.fragments import llistes

View File

@@ -14,7 +14,7 @@ def sessio_en_directe(request: Request):
raise RuntimeError("Got a session without id!") raise RuntimeError("Got a session without id!")
current_set = None current_set = None
if playlist := playlists_service.get_playlist(session_id=session.id): if playlist := service.get_session_setlist(session_id=session.id):
if playlist.sets: if playlist.sets:
current_set = playlists_service.add_temes_to_set(playlist.sets[-1]) current_set = playlists_service.add_temes_to_set(playlist.sets[-1])

View File

@@ -0,0 +1,42 @@
from fastapi import Request
from folkugat_web.model import playlists as playlists_model
from folkugat_web.model.pagines import Pages
from folkugat_web.services import playlists as playlists_service
from folkugat_web.templates import templates
def llistes_pagina(request: Request, logged_in: bool):
playlists = playlists_service.get_all_playlists(logged_in=logged_in)
return templates.TemplateResponse(
"fragments/llistes/pagina.html",
{
"request": request,
"logged_in": logged_in,
"playlists": playlists,
"Pages": Pages,
"menu_selected_id": Pages.Llistes,
"playlist_list_id": "playlist-list",
}
)
def llistes_editor_insert_row(request: Request):
new_playlist = playlists_service.create_playlist(name=playlists_model.DEFAULT_PLAYLIST_NAME)
return llistes_editor_row(request, new_playlist)
def llistes_editor_row(request: Request, playlist: playlists_model.Playlist):
return templates.TemplateResponse(
"fragments/llistes/playlist_entry.html",
{
"request": request,
"playlist": playlist,
"logged_in": True,
}
)
def llistes_editor_delete_row(playlist_id: int):
playlists_service.delete_playlist(playlist_id)
from fastapi.responses import HTMLResponse
return HTMLResponse()

View File

@@ -0,0 +1,237 @@
from fastapi import Request
from fastapi.responses import HTMLResponse
from folkugat_web.model.pagines import Pages
from folkugat_web.model.playlists import PlaylistType
from folkugat_web.services import playlists as playlists_service
from folkugat_web.services import sessions as sessions_service
from folkugat_web.services.temes import query as query_service
from folkugat_web.services.temes import search as search_service
from folkugat_web.templates import templates
def add_set(
request: Request,
playlist_id: int,
logged_in: bool,
):
new_set = playlists_service.add_set(playlist_id=playlist_id)
return templates.TemplateResponse(
"fragments/llista/set_entry.html",
{
"request": request,
"logged_in": logged_in,
"new_entry": True,
"playlist_id": playlist_id,
"set_id": new_set.id,
"set_entry": new_set,
}
)
def get_set(request: Request, playlist_id: int, set_id: int, logged_in: bool):
set_entry = playlists_service.get_set(playlist_id=playlist_id, set_id=set_id)
if set_entry:
return templates.TemplateResponse(
"fragments/llista/set_entry.html",
{
"request": request,
"logged_in": logged_in,
"new_entry": True,
"playlist_id": playlist_id,
"set_id": set_id,
"set_entry": set_entry,
}
)
else:
return HTMLResponse()
def delete_set(playlist_id: int, set_id: int):
playlists_service.delete_set(playlist_id=playlist_id, set_id=set_id)
return HTMLResponse()
def add_tema(request: Request, playlist_id: int, set_id: int, logged_in: bool):
new_tema = playlists_service.add_tema(playlist_id=playlist_id, set_id=set_id)
new_tema = playlists_service.add_tema_to_tema_in_set(new_tema)
return templates.TemplateResponse(
"fragments/llista/tema_editor.html",
{
"request": request,
"logged_in": logged_in,
"playlist_id": playlist_id,
"set_id": set_id,
"tema_entry": new_tema,
}
)
def get_tema(request: Request, playlist_id: int, set_id: int, entry_id: int, logged_in: bool):
tema_entry = playlists_service.get_tema(entry_id=entry_id)
tema_entry = playlists_service.add_tema_to_tema_in_set(tema_entry)
return templates.TemplateResponse(
"fragments/llista/tema_entry.html",
{
"request": request,
"logged_in": logged_in,
"playlist_id": playlist_id,
"set_id": set_id,
"tema_entry": tema_entry,
}
)
def get_tema_editor(request: Request, playlist_id: int, set_id: int, entry_id: int, logged_in: bool):
tema_entry = playlists_service.get_tema(entry_id=entry_id)
tema_entry = playlists_service.add_tema_to_tema_in_set(tema_entry)
return templates.TemplateResponse(
"fragments/llista/tema_editor.html",
{
"request": request,
"logged_in": logged_in,
"playlist_id": playlist_id,
"set_id": set_id,
"tema_entry": tema_entry,
}
)
def delete_tema(playlist_id: int, set_id: int, entry_id: int):
playlists_service.delete_tema(entry_id=entry_id)
if not playlists_service.get_set(playlist_id=playlist_id, set_id=set_id):
headers = {
"HX-Trigger": f"reload-set-{set_id}"
}
else:
headers = {}
return HTMLResponse(headers=headers)
def busca_tema(
request: Request,
playlist_id: int,
set_id: int,
entry_id: int,
query: str,
):
n_results = 4
suggestions = []
if not query:
# If there is no query, suggest tunes commonly played together
set_entry = playlists_service.get_set(
playlist_id=playlist_id,
set_id=set_id,
)
if set_entry:
tema_ids = {tema_in_set.tema_id
for tema_in_set in set_entry.temes
if tema_in_set and tema_in_set.tema_id is not None}
commonly_played_tema_ids = {
cpt.tema.id
for tema_id in tema_ids
for cpt in sessions_service.get_commonly_played_temes(tema_id)
if cpt.tema.id is not None
} - tema_ids
suggestions = query_service.get_temes_by_ids(
tema_ids=list(commonly_played_tema_ids)
)
if len(suggestions) >= n_results:
suggestions = suggestions[:n_results]
elif not suggestions:
suggestions = search_service.busca_temes(
query=query,
properties=[],
hidden=True,
limit=n_results,
offset=0,
)
return templates.TemplateResponse(
"fragments/llista/tema_results.html",
{
"request": request,
"playlist_id": playlist_id,
"set_id": set_id,
"entry_id": entry_id,
"results": suggestions,
"query": query,
}
)
def set_tema(request: Request, logged_in: bool, playlist_id: int, set_id: int, entry_id: int, tema_id: int | None):
playlists_service.set_tema(playlist_id=playlist_id, set_id=set_id, entry_id=entry_id, tema_id=tema_id)
tema_entry = playlists_service.get_tema(entry_id=entry_id)
tema_entry = playlists_service.add_tema_to_tema_in_set(tema_entry)
return templates.TemplateResponse(
"fragments/llista/tema_entry.html",
{
"request": request,
"logged_in": logged_in,
"playlist_id": playlist_id,
"set_id": set_id,
"tema_entry": tema_entry,
}
)
async def pagina(request: Request, playlist_id: int, logged_in: bool):
playlist = playlists_service.get_playlist(playlist_id=playlist_id)
if not playlist:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Could not find playlist")
playlist = playlists_service.add_temes_to_playlist(playlist)
playlist = await playlists_service.add_playlist_score_to_playlist(playlist)
return templates.TemplateResponse(
"fragments/llista/pagina.html",
{
"request": request,
"logged_in": logged_in,
"playlist_id": playlist_id,
"playlist": playlist,
"Pages": Pages,
"PlaylistType": PlaylistType
}
)
def name(request: Request, playlist_id: int, logged_in: bool):
playlist = playlists_service.get_playlist(playlist_id=playlist_id)
if not playlist:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Could not find playlist")
return templates.TemplateResponse(
"fragments/llista/name.html",
{
"request": request,
"logged_in": logged_in,
"playlist_id": playlist_id,
"playlist": playlist,
}
)
def name_editor(request: Request, playlist_id: int, logged_in: bool):
playlist = playlists_service.get_playlist(playlist_id=playlist_id)
if not playlist:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Could not find playlist")
return templates.TemplateResponse(
"fragments/llista/editor/name.html",
{
"request": request,
"logged_in": logged_in,
"playlist_id": playlist_id,
"playlist": playlist,
}
)
def visibility(request: Request, logged_in: bool, playlist):
return templates.TemplateResponse(
"fragments/llista/visibility.html",
{
"request": request,
"logged_in": logged_in,
"playlist": playlist,
}
)

View File

@@ -1,187 +0,0 @@
from fastapi import Request
from fastapi.responses import HTMLResponse
from folkugat_web.model.pagines import Pages
from folkugat_web.services import playlists as playlists_service
from folkugat_web.services import sessions as sessions_service
from folkugat_web.services.temes import query as query_service
from folkugat_web.services.temes import search as search_service
from folkugat_web.templates import templates
def pagina(request: Request, session_id: int, logged_in: bool):
session = sessions_service.get_session(session_id=session_id)
playlist = playlists_service.get_playlist(session_id=session_id)
playlist = playlists_service.add_temes_to_playlist(playlist)
return templates.TemplateResponse(
"fragments/sessio/pagina.html",
{
"request": request,
"logged_in": logged_in,
"Pages": Pages,
"session_id": session_id,
"session": session,
"playlist": playlist,
"date_names": sessions_service.get_date_names,
}
)
def add_set(request: Request, session_id: int, logged_in: bool):
new_set = playlists_service.add_set(session_id=session_id)
return templates.TemplateResponse(
"fragments/sessio/set_entry.html",
{
"request": request,
"logged_in": logged_in,
"new_entry": True,
"session_id": session_id,
"set_id": new_set.id,
"set_entry": new_set,
}
)
def get_set(request: Request, session_id: int, set_id: int, logged_in: bool):
set_entry = playlists_service.get_set(session_id=session_id, set_id=set_id)
if set_entry:
return templates.TemplateResponse(
"fragments/sessio/set_entry.html",
{
"request": request,
"logged_in": logged_in,
"new_entry": True,
"session_id": session_id,
"set_id": set_id,
"set_entry": set_entry,
}
)
else:
return HTMLResponse()
def delete_set(session_id: int, set_id: int):
playlists_service.delete_set(session_id=session_id, set_id=set_id)
return HTMLResponse()
def add_tema(request: Request, session_id: int, set_id: int, logged_in: bool):
new_tema = playlists_service.add_tema(session_id=session_id, set_id=set_id)
playlists_service.add_tema_to_tema_in_set(new_tema)
return templates.TemplateResponse(
"fragments/sessio/tema_editor.html",
{
"request": request,
"logged_in": logged_in,
"session_id": session_id,
"set_id": set_id,
"tema_entry": new_tema,
}
)
def get_tema(request: Request, session_id: int, set_id: int, entry_id: int, logged_in: bool):
tema_entry = playlists_service.get_tema(entry_id=entry_id)
playlists_service.add_tema_to_tema_in_set(tema_entry)
return templates.TemplateResponse(
"fragments/sessio/tema_entry.html",
{
"request": request,
"logged_in": logged_in,
"session_id": session_id,
"set_id": set_id,
"tema_entry": tema_entry,
}
)
def get_tema_editor(request: Request, session_id: int, set_id: int, entry_id: int, logged_in: bool):
tema_entry = playlists_service.get_tema(entry_id=entry_id)
playlists_service.add_tema_to_tema_in_set(tema_entry)
return templates.TemplateResponse(
"fragments/sessio/tema_editor.html",
{
"request": request,
"logged_in": logged_in,
"session_id": session_id,
"set_id": set_id,
"tema_entry": tema_entry,
}
)
def delete_tema(session_id: int, set_id: int, entry_id: int):
playlists_service.delete_tema(entry_id=entry_id)
if not playlists_service.get_set(session_id=session_id, set_id=set_id):
headers = {
"HX-Trigger": f"reload-set-{set_id}"
}
else:
headers = {}
return HTMLResponse(headers=headers)
def busca_tema(
request: Request,
session_id: int,
set_id: int,
entry_id: int,
query: str,
):
n_results = 4
suggestions = []
if not query:
# If there is no query, suggest tunes commonly played together
set_entry = playlists_service.get_set(
session_id=session_id,
set_id=set_id,
)
if set_entry:
tema_ids = {tema_in_set.tema_id
for tema_in_set in set_entry.temes
if tema_in_set and tema_in_set.tema_id is not None}
commonly_played_tema_ids = {
cpt.tema.id
for tema_id in tema_ids
for cpt in playlists_service.get_commonly_played_temes(tema_id)
if cpt.tema.id is not None
} - tema_ids
suggestions = query_service.get_temes_by_ids(
tema_ids=list(commonly_played_tema_ids)
)
if len(suggestions) >= n_results:
suggestions = suggestions[:n_results]
elif not suggestions:
suggestions = search_service.busca_temes(
query=query,
properties=[],
hidden=True,
limit=n_results,
offset=0,
)
return templates.TemplateResponse(
"fragments/sessio/tema_results.html",
{
"request": request,
"session_id": session_id,
"set_id": set_id,
"entry_id": entry_id,
"results": suggestions,
"query": query,
}
)
def set_tema(request: Request, logged_in: bool, session_id: int, set_id: int, entry_id: int, tema_id: int | None):
playlists_service.set_tema(session_id=session_id, set_id=set_id, entry_id=entry_id, tema_id=tema_id)
tema_entry = playlists_service.get_tema(entry_id=entry_id)
playlists_service.add_tema_to_tema_in_set(tema_entry)
return templates.TemplateResponse(
"fragments/sessio/tema_entry.html",
{
"request": request,
"logged_in": logged_in,
"session_id": session_id,
"set_id": set_id,
"tema_entry": tema_entry,
}
)

View File

@@ -0,0 +1,26 @@
from fastapi import Request
from folkugat_web.services import sessions as sessions_service
from folkugat_web.templates import templates
def cartell(request: Request, session_id: int, logged_in: bool):
session = sessions_service.get_session(session_id=session_id)
return templates.TemplateResponse(
"fragments/sessio/cartell.html",
{
"request": request,
"logged_in": logged_in,
"session_id": session_id,
"session": session,
}
)
def cartell_editor(request: Request, session_id: int):
return templates.TemplateResponse(
"fragments/sessio/cartell_editor.html",
{
"request": request,
"session_id": session_id,
}
)

View File

@@ -0,0 +1,30 @@
from fastapi import Request
from folkugat_web.services import sessions as sessions_service
from folkugat_web.templates import templates
def notes(request: Request, session_id: int, logged_in: bool):
session = sessions_service.get_session(session_id=session_id)
return templates.TemplateResponse(
"fragments/sessio/notes.html",
{
"request": request,
"logged_in": logged_in,
"session_id": session_id,
"session": session,
"notes": session.notes if session and session.notes else "",
}
)
def notes_editor(request: Request, session_id: int):
session = sessions_service.get_session(session_id=session_id)
return templates.TemplateResponse(
"fragments/sessio/notes_editor.html",
{
"request": request,
"session_id": session_id,
"notes": session.notes if session and session.notes else "",
"max": max,
}
)

View File

@@ -0,0 +1,22 @@
from fastapi import HTTPException, Request
from folkugat_web.model.pagines import Pages
from folkugat_web.services import sessions as sessions_service
from folkugat_web.templates import templates
def pagina(request: Request, session_id: int, logged_in: bool):
session = sessions_service.get_session(session_id=session_id)
if not session:
raise HTTPException(status_code=404, detail="Could not find session")
session = sessions_service.add_playlists_to_session(session=session)
return templates.TemplateResponse(
"fragments/sessio/pagina.html",
{
"request": request,
"logged_in": logged_in,
"Pages": Pages,
"session_id": session_id,
"session": session,
"date_names": sessions_service.get_date_names,
}
)

View File

@@ -6,22 +6,20 @@ from folkugat_web.services import sessions as sessions_service
from folkugat_web.templates import templates from folkugat_web.templates import templates
async def pagina(request: Request, session_id: int, set_id: int, logged_in: bool): async def pagina(request: Request, playlist_id: int, set_id: int, logged_in: bool):
session = sessions_service.get_session(session_id=session_id) set_ = playlists_service.get_set(playlist_id=playlist_id, set_id=set_id)
set_ = playlists_service.get_set(session_id=session_id, set_id=set_id)
if not set_: if not set_:
raise HTTPException(status_code=404, detail="Set not found") raise HTTPException(status_code=404, detail="Set not found")
set_ = playlists_service.add_temes_to_set(set_) set_ = playlists_service.add_temes_to_set(set_)
set_ = await playlists_service.add_set_score_to_set(set_) set_ = await playlists_service.add_set_score_to_set(set_)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/sessio/set/pagina.html", "fragments/llista/set/pagina.html",
{ {
"request": request, "request": request,
"logged_in": logged_in, "logged_in": logged_in,
"Pages": Pages, "Pages": Pages,
"session_id": session_id,
"session": session,
"set": set_, "set": set_,
"session": None,
"date_names": sessions_service.get_date_names, "date_names": sessions_service.get_date_names,
"LinkType": LinkType, "LinkType": LinkType,
"ContentType": ContentType, "ContentType": ContentType,
@@ -33,12 +31,12 @@ async def live(request: Request, logged_in: bool):
session = sessions_service.get_live_session() session = sessions_service.get_live_session()
set_ = None set_ = None
if session and session.id: if session and session.id:
playlist = playlists_service.get_playlist(session_id=session.id) playlist = sessions_service.get_session_setlist(session_id=session.id)
if playlist.sets: if playlist and playlist.sets:
set_ = playlists_service.add_temes_to_set(playlist.sets[-1]) set_ = playlists_service.add_temes_to_set(playlist.sets[-1])
set_ = await playlists_service.add_set_score_to_set(set_) set_ = await playlists_service.add_set_score_to_set(set_)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/sessio/set/pagina.html", "fragments/llista/set/pagina.html",
{ {
"request": request, "request": request,
"logged_in": logged_in, "logged_in": logged_in,
@@ -56,12 +54,12 @@ async def live_set(request: Request, logged_in: bool):
session = sessions_service.get_live_session() session = sessions_service.get_live_session()
set_ = None set_ = None
if session and session.id: if session and session.id:
playlist = playlists_service.get_playlist(session_id=session.id) playlist = sessions_service.get_session_setlist(session_id=session.id)
if playlist.sets: if playlist and playlist.sets:
set_ = playlists_service.add_temes_to_set(playlist.sets[-1]) set_ = playlists_service.add_temes_to_set(playlist.sets[-1])
set_ = await playlists_service.add_set_score_to_set(set_) set_ = await playlists_service.add_set_score_to_set(set_)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/sessio/set/set_page.html", "fragments/llista/set/set_page.html",
{ {
"request": request, "request": request,
"logged_in": logged_in, "logged_in": logged_in,

View File

@@ -32,13 +32,16 @@ class LyricsText:
@classmethod @classmethod
def from_lyrics(cls, lyrics: temes.Lyrics, max_cols: int = 3) -> Self: def from_lyrics(cls, lyrics: temes.Lyrics, max_cols: int = 3) -> Self:
# Use stored max_columns if available, otherwise use the provided max_cols
effective_max_cols = lyrics.max_columns if lyrics.max_columns is not None else max_cols
# Remove ~ characters before processing
paragraphs = [ paragraphs = [
LyricsParagraph(lines=par_str.splitlines()) LyricsParagraph(lines=[line.replace("~", "") for line in par_str.splitlines()])
for par_str in lyrics.content.split("\n\n") if par_str for par_str in lyrics.content.split("\n\n") if par_str
] ]
return cls(lines=[ return cls(lines=[
LyricsLine(paragraphs=list(line_pars)) LyricsLine(paragraphs=list(line_pars))
for line_pars in batched(paragraphs, max_cols) for line_pars in batched(paragraphs, effective_max_cols)
]) ])
def hash(self) -> bytes: def hash(self) -> bytes:
@@ -89,3 +92,15 @@ class LilypondSet:
self.title.encode(), self.title.encode(),
*(tune.hash() for tune in self.tunes), *(tune.hash() for tune in self.tunes),
) )
@dataclasses.dataclass
class LilypondPlaylist:
title: str
sets: list[LilypondSet]
def hash(self) -> bytes:
return get_hash(
self.title.encode(),
*(lilypond_set.hash() for lilypond_set in self.sets),
)

View File

@@ -4,3 +4,4 @@ import enum
class Pages(enum.IntEnum): class Pages(enum.IntEnum):
Sessions = 0 Sessions = 0
Temes = 1 Temes = 1
Llistes = 2

View File

@@ -1,15 +1,23 @@
import dataclasses import dataclasses
import enum
from collections.abc import Iterator from collections.abc import Iterator
from typing import Self from typing import Self
from folkugat_web.model.temes import Tema from folkugat_web.model.temes import Tema
from folkugat_web.utils import groupby from folkugat_web.utils import groupby
DEFAULT_PLAYLIST_NAME = "Llista de temes"
class PlaylistType(enum.Enum):
SESSION_SETLIST = "session_setlist"
SESSION_SLOWJAM = "session_slowjam"
@dataclasses.dataclass @dataclasses.dataclass
class PlaylistEntry: class PlaylistEntry:
id: int | None id: int | None
session_id: int playlist_id: int
set_id: int set_id: int
tema_id: int | None tema_id: int | None
@@ -20,17 +28,21 @@ class TemaInSet:
tema_id: int | None tema_id: int | None
tema: Tema | None tema: Tema | None
def to_playlist_entry(self, session_id: int, set_id: int) -> PlaylistEntry: def to_playlist_entry(self, playlist_id: int, set_id: int) -> PlaylistEntry:
return PlaylistEntry( return PlaylistEntry(
id=self.id, id=self.id,
session_id=session_id, playlist_id=playlist_id,
set_id=set_id, set_id=set_id,
tema_id=self.tema_id, tema_id=self.tema_id,
) )
@classmethod @classmethod
def from_playlist_entry(cls, entry: PlaylistEntry) -> Self: def from_playlist_entry(cls, entry: PlaylistEntry) -> Self:
return cls(id=entry.id, tema_id=entry.tema_id, tema=None) return cls(
id=entry.id,
tema_id=entry.tema_id,
tema=None,
)
@dataclasses.dataclass @dataclasses.dataclass
@@ -39,16 +51,22 @@ class SetScore:
pdf_url: str | None pdf_url: str | None
@dataclasses.dataclass
class PlaylistScore:
img_url: str | None
pdf_url: str | None
@dataclasses.dataclass @dataclasses.dataclass
class Set: class Set:
id: int id: int
temes: list[TemaInSet] temes: list[TemaInSet]
score: SetScore | None score: SetScore | None
def to_playlist_entries(self, session_id: int) -> Iterator[PlaylistEntry]: def to_playlist_entries(self, playlist_id: int) -> Iterator[PlaylistEntry]:
for tema_in_set in self.temes: for tema_in_set in self.temes:
yield tema_in_set.to_playlist_entry( yield tema_in_set.to_playlist_entry(
session_id=session_id, playlist_id=playlist_id,
set_id=self.id, set_id=self.id,
) )
@@ -58,26 +76,33 @@ class Set:
raise ValueError("All PlaylistEntries must have the same session_id") raise ValueError("All PlaylistEntries must have the same session_id")
return cls( return cls(
id=set_id, id=set_id,
temes=[TemaInSet.from_playlist_entry(entry) for entry in sorted(entries, key=lambda e: e.id or 0)], temes=[
TemaInSet.from_playlist_entry(entry)
for entry in sorted(entries, key=lambda e: e.id or 0)
],
score=None, score=None,
) )
@dataclasses.dataclass @dataclasses.dataclass
class Playlist: class Playlist:
session_id: int id: int
name: str | None
sets: list[Set] sets: list[Set]
hidden: bool = True
playlist_score: PlaylistScore | None = None
def to_playlist_entries(self) -> Iterator[PlaylistEntry]: def to_playlist_entries(self) -> Iterator[PlaylistEntry]:
for set_entry in self.sets: for set_entry in self.sets:
yield from set_entry.to_playlist_entries(session_id=self.session_id) yield from set_entry.to_playlist_entries(playlist_id=self.id)
@classmethod @classmethod
def from_playlist_entries(cls, session_id: int, entries: list[PlaylistEntry]) -> Self: def from_playlist_entries(cls, playlist_id: int, name: str | None, entries: list[PlaylistEntry]) -> Self:
if any(entry.session_id != session_id for entry in entries): if any(entry.playlist_id != playlist_id for entry in entries):
raise ValueError("All PlaylistEntries must have the same session_id") raise ValueError("All PlaylistEntries must have the same playlist_id")
return cls( return cls(
session_id=session_id, id=playlist_id,
name=name,
sets=[ sets=[
Set.from_playlist_entries(set_id, set_entries) Set.from_playlist_entries(set_id, set_entries)
for set_id, set_entries in groupby(entries, key_fn=lambda e: e.set_id, group_fn=list) for set_id, set_entries in groupby(entries, key_fn=lambda e: e.set_id, group_fn=list)

View File

@@ -2,6 +2,8 @@ import dataclasses
import datetime import datetime
import enum import enum
from folkugat_web.model import playlists
DEFAULT_START_TIME = datetime.time(20, 30) DEFAULT_START_TIME = datetime.time(20, 30)
DEFAULT_END_TIME = datetime.time(22, 30) DEFAULT_END_TIME = datetime.time(22, 30)
@@ -19,6 +21,10 @@ class Session:
start_time: datetime.time = DEFAULT_START_TIME start_time: datetime.time = DEFAULT_START_TIME
end_time: datetime.time = DEFAULT_END_TIME end_time: datetime.time = DEFAULT_END_TIME
venue: SessionVenue = dataclasses.field(default_factory=SessionVenue) venue: SessionVenue = dataclasses.field(default_factory=SessionVenue)
notes: str | None = None
cartell_url: str | None = None
slowjam: playlists.Playlist | None = None
setlist: playlists.Playlist | None = None
is_live: bool = False is_live: bool = False

View File

@@ -4,13 +4,15 @@ import dataclasses
import datetime import datetime
import enum import enum
import itertools import itertools
from typing import Self from typing import TYPE_CHECKING, Self
from folkugat_web.model.lilypond.processing import RenderError from folkugat_web.model.lilypond.processing import RenderError
from folkugat_web.model.search import NGrams from folkugat_web.model.search import NGrams
from folkugat_web.model.sessions import Session
from folkugat_web.services import ngrams from folkugat_web.services import ngrams
if TYPE_CHECKING:
from folkugat_web.model.sessions import Session
class ContentType(enum.Enum): class ContentType(enum.Enum):
PARTITURA = "partitura" PARTITURA = "partitura"
@@ -58,6 +60,7 @@ class Lyrics:
tema_id: int tema_id: int
title: str title: str
content: str content: str
max_columns: int | None = None
@dataclasses.dataclass @dataclasses.dataclass

View File

@@ -3,7 +3,7 @@ import mimetypes
import os import os
import re import re
import uuid import uuid
from collections.abc import Iterator from collections.abc import Iterable, Iterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -11,6 +11,7 @@ import aiofiles
import magic import magic
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from folkugat_web.config import db from folkugat_web.config import db
from folkugat_web.dal.sql.sessions import query as sessions_dal
from folkugat_web.dal.sql.temes import links as links_dal from folkugat_web.dal.sql.temes import links as links_dal
from folkugat_web.dal.sql.temes import scores as scores_dal from folkugat_web.dal.sql.temes import scores as scores_dal
from folkugat_web.log import logger from folkugat_web.log import logger
@@ -22,22 +23,16 @@ async def get_mimetype(upload_file: UploadFile) -> str:
return info.mime_type return info.mime_type
ACCEPTED_MIMETYPES = [ IMAGE_MIMETYPE = re.compile(r"image/.+")
re.compile(r"image/.+"), PDF_MIMETYPE = re.compile(r".+/pdf")
re.compile(r".+/pdf"),
]
def check_mimetype(mimetype: str) -> None: def check_mimetype(mimetype: str, accepted_mimetypes: Iterable[re.Pattern[str]]) -> None:
if not any(regex.match(mimetype) for regex in ACCEPTED_MIMETYPES): if not any(regex.match(mimetype) for regex in accepted_mimetypes):
raise HTTPException(status_code=400, detail=f"Unsupported file type: {mimetype}") raise HTTPException(status_code=400, detail=f"Unsupported file type: {mimetype}")
def get_db_file_path(filepath: Path) -> str: def check_upload_file_size(upload_file: UploadFile) -> None:
return f"{db.DB_FILES_URL}/{filepath.relative_to(db.DB_FILES_DIR)}"
async def store_file(tema_id: int, upload_file: UploadFile) -> str:
if not upload_file.size: if not upload_file.size:
raise HTTPException(status_code=400, detail="Couldn't find out the size of the file") raise HTTPException(status_code=400, detail="Couldn't find out the size of the file")
if upload_file.size > db.FILE_MAX_SIZE: if upload_file.size > db.FILE_MAX_SIZE:
@@ -46,8 +41,15 @@ async def store_file(tema_id: int, upload_file: UploadFile) -> str:
detail=f"The uploaded file is too big (max size = {db.FILE_MAX_SIZE} bytes)", detail=f"The uploaded file is too big (max size = {db.FILE_MAX_SIZE} bytes)",
) )
def get_db_file_path(filepath: Path) -> str:
return f"{db.DB_FILES_URL}/{filepath.relative_to(db.DB_FILES_DIR)}"
async def store_tema_file(tema_id: int, upload_file: UploadFile) -> str:
check_upload_file_size(upload_file)
mimetype = await get_mimetype(upload_file) mimetype = await get_mimetype(upload_file)
check_mimetype(mimetype) check_mimetype(mimetype, [IMAGE_MIMETYPE, PDF_MIMETYPE])
extension = mimetypes.guess_extension(mimetype) or "" extension = mimetypes.guess_extension(mimetype) or ""
filepath = create_tema_filename(tema_id=tema_id, extension=extension) filepath = create_tema_filename(tema_id=tema_id, extension=extension)
@@ -58,6 +60,20 @@ async def store_file(tema_id: int, upload_file: UploadFile) -> str:
return get_db_file_path(filepath) return get_db_file_path(filepath)
async def store_session_cartell(session_id: int, upload_file: UploadFile) -> str:
check_upload_file_size(upload_file)
mimetype = await get_mimetype(upload_file)
check_mimetype(mimetype, [IMAGE_MIMETYPE])
extension = mimetypes.guess_extension(mimetype) or ""
filepath = create_cartell_filename(session_id=session_id, extension=extension)
with open(filepath, "wb") as f:
_ = f.write(await upload_file.read())
return get_db_file_path(filepath)
def create_tema_filename(tema_id: int, extension: str = "") -> Path: def create_tema_filename(tema_id: int, extension: str = "") -> Path:
filename = str(uuid.uuid4().hex) + extension filename = str(uuid.uuid4().hex) + extension
filedir = db.DB_FILES_TEMA_DIR / str(tema_id) filedir = db.DB_FILES_TEMA_DIR / str(tema_id)
@@ -66,6 +82,14 @@ def create_tema_filename(tema_id: int, extension: str = "") -> Path:
return filepath return filepath
def create_cartell_filename(session_id: int, extension: str = "") -> Path:
filename = str(uuid.uuid4().hex) + extension
filedir = db.DB_FILES_SESSION_DIR / str(session_id) / "cartell"
filedir.mkdir(parents=True, exist_ok=True)
filepath = filedir / filename
return filepath
def create_tmp_filename(extension: str = "") -> Path: def create_tmp_filename(extension: str = "") -> Path:
filename = str(uuid.uuid4().hex) + extension filename = str(uuid.uuid4().hex) + extension
filepath = db.DB_FILES_TMP_DIR / filename filepath = db.DB_FILES_TMP_DIR / filename
@@ -76,6 +100,10 @@ def get_set_filename(filename: str) -> Path:
return db.DB_FILES_SET_DIR / filename return db.DB_FILES_SET_DIR / filename
def get_playlist_filename(filename: str) -> Path:
return db.DB_FILES_PLAYLIST_DIR / filename
@asynccontextmanager @asynccontextmanager
async def tmp_file(content: str): async def tmp_file(content: str):
input_filename = create_tmp_filename(extension=".ly") input_filename = create_tmp_filename(extension=".ly")
@@ -94,11 +122,13 @@ def list_files(tema_id: str) -> list[str]:
def get_orphan_files() -> Iterator[Path]: def get_orphan_files() -> Iterator[Path]:
link_urls = {link.url for link in links_dal.get_links()} alive_urls = (
score_pdf_urls = {score.pdf_url for score in scores_dal.get_scores() if score.pdf_url is not None} {link.url for link in links_dal.get_links()}
score_img_urls = {score.img_url for score in scores_dal.get_scores() if score.img_url is not None} | {score.pdf_url for score in scores_dal.get_scores() if score.pdf_url is not None}
score_preview_urls = {score.preview_url for score in scores_dal.get_scores() if score.preview_url is not None} | {score.img_url for score in scores_dal.get_scores() if score.img_url is not None}
alive_urls = link_urls | score_pdf_urls | score_img_urls | score_preview_urls | {score.preview_url for score in scores_dal.get_scores() if score.preview_url is not None}
| {session.cartell_url for session in sessions_dal.get_sessions() if session.cartell_url}
)
return filter( return filter(
lambda p: p.is_file() and get_db_file_path(p) not in alive_urls, lambda p: p.is_file() and get_db_file_path(p) not in alive_urls,
itertools.chain( itertools.chain(

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException
from folkugat_web.model import playlists as playlists_model from folkugat_web.model import playlists as playlists_model
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.model.lilypond import score as lilypond_model from folkugat_web.model.lilypond import score as lilypond_model
from folkugat_web.services import playlists as playlists_service
from folkugat_web.services.temes import lyrics as lyrics_service from folkugat_web.services.temes import lyrics as lyrics_service
from folkugat_web.services.temes import properties as properties_service from folkugat_web.services.temes import properties as properties_service
from folkugat_web.services.temes import query as temes_q from folkugat_web.services.temes import query as temes_q
@@ -96,3 +97,20 @@ def set_from_set(set_entry: playlists_model.Set) -> lilypond_model.LilypondSet:
title=set_title, title=set_title,
tunes=tunes tunes=tunes
) )
def playlist_from_playlist(playlist: playlists_model.Playlist) -> lilypond_model.LilypondPlaylist:
"""
The playlist is assumed to be enriched with tunes
"""
lilypond_sets = []
for set_entry in playlist.sets:
lilypond_set = set_from_set(set_entry)
if lilypond_set.tunes and all(map(playlists_service._elegible_for_set_score, lilypond_set.tunes)):
lilypond_sets.append(lilypond_set)
playlist_title = playlist.name or "Llista"
return lilypond_model.LilypondPlaylist(
title=playlist_title,
sets=lilypond_sets
)

View File

@@ -1,4 +1,4 @@
from folkugat_web.model.lilypond.score import LilypondSet, LilypondTune from folkugat_web.model.lilypond.score import LilypondPlaylist, LilypondSet, LilypondTune
from folkugat_web.templates import templates from folkugat_web.templates import templates
SCORE_BEGINNING = "% --- SCORE BEGINNING --- %" SCORE_BEGINNING = "% --- SCORE BEGINNING --- %"
@@ -23,3 +23,10 @@ def set_source(tune_set: LilypondSet) -> str:
score_beginning=SCORE_BEGINNING, score_beginning=SCORE_BEGINNING,
tune_set=tune_set, tune_set=tune_set,
) )
def playlist_source(playlist: LilypondPlaylist) -> str:
return templates.get_template("lilypond/playlist.ly").render(
score_beginning=SCORE_BEGINNING,
playlist=playlist,
)

View File

@@ -5,7 +5,6 @@ from folkugat_web.dal.sql._connection import get_connection
from folkugat_web.dal.sql.playlists import query, write from folkugat_web.dal.sql.playlists import query, write
from folkugat_web.log import logger from folkugat_web.log import logger
from folkugat_web.model import playlists from folkugat_web.model import playlists
from folkugat_web.model import temes as temes_model
from folkugat_web.model.lilypond import score as lilypond_model from folkugat_web.model.lilypond import score as lilypond_model
from folkugat_web.services import files as files_service from folkugat_web.services import files as files_service
from folkugat_web.services.lilypond import build as lilypond_build from folkugat_web.services.lilypond import build as lilypond_build
@@ -41,37 +40,45 @@ def add_tema_to_tema_in_set(tema_in_set: playlists.TemaInSet) -> playlists.TemaI
return tema_in_set return tema_in_set
def get_playlist(session_id: int, con: Connection | None = None) -> playlists.Playlist: def get_playlist(playlist_id: int, con: Connection | None = None) -> playlists.Playlist:
with get_connection(con) as playlist_con:
playlist_name = query.get_playlist_name(playlist_id=playlist_id, con=playlist_con)
return playlists.Playlist.from_playlist_entries( return playlists.Playlist.from_playlist_entries(
session_id=session_id, playlist_id=playlist_id,
entries=list(query.get_playlist_entries(session_id=session_id, con=con)) name=playlist_name,
entries=list(query.get_playlist_entries(playlist_id=playlist_id, con=playlist_con))
) )
def add_set(session_id: int, con: Connection | None = None) -> playlists.Set: def update_name(playlist_id: int, name: str | None) -> playlists.Playlist:
write.update_playlist_name(playlist_id=playlist_id, name=name)
return get_playlist(playlist_id=playlist_id)
def add_set(playlist_id: int, con: Connection | None = None) -> playlists.Set:
with get_connection(con) as con: with get_connection(con) as con:
curr_playlist = get_playlist(session_id=session_id, con=con) curr_playlist = get_playlist(playlist_id=playlist_id, con=con)
new_set_id = max([set_entry.id for set_entry in curr_playlist.sets], default=0) + 1 new_set_id = max([set_entry.id for set_entry in curr_playlist.sets], default=0) + 1
new_entry = playlists.PlaylistEntry(id=None, session_id=session_id, set_id=new_set_id, tema_id=None) new_entry = playlists.PlaylistEntry(id=None, playlist_id=playlist_id, set_id=new_set_id, tema_id=None)
inserted_entry = write.insert_playlist_entry(new_entry) inserted_entry = write.insert_playlist_entry(new_entry)
return playlists.Set.from_playlist_entries(set_id=inserted_entry.set_id, entries=[inserted_entry]) return playlists.Set.from_playlist_entries(set_id=inserted_entry.set_id, entries=[inserted_entry])
def get_set(session_id: int, set_id: int, con: Connection | None = None) -> playlists.Set | None: def get_set(playlist_id: int, set_id: int, con: Connection | None = None) -> playlists.Set | None:
entries = list(query.get_playlist_entries(session_id=session_id, set_id=set_id, con=con)) entries = list(query.get_playlist_entries(playlist_id=playlist_id, set_id=set_id, con=con))
if entries: if entries:
return playlists.Set.from_playlist_entries(set_id=set_id, entries=entries) return playlists.Set.from_playlist_entries(set_id=set_id, entries=entries)
else: else:
return None return None
def delete_set(session_id: int, set_id: int, con: Connection | None = None): def delete_set(playlist_id: int, set_id: int, con: Connection | None = None):
write.delete_playlist_set(session_id=session_id, set_id=set_id, con=con) write.delete_playlist_set(playlist_id=playlist_id, set_id=set_id, con=con)
def add_tema(session_id: int, set_id: int, con: Connection | None = None) -> playlists.TemaInSet: def add_tema(playlist_id: int, set_id: int, con: Connection | None = None) -> playlists.TemaInSet:
with get_connection(con) as con: with get_connection(con) as con:
new_entry = playlists.PlaylistEntry(id=None, session_id=session_id, set_id=set_id, tema_id=None) new_entry = playlists.PlaylistEntry(id=None, playlist_id=playlist_id, set_id=set_id, tema_id=None)
inserted_entry = write.insert_playlist_entry(new_entry) inserted_entry = write.insert_playlist_entry(new_entry)
return playlists.TemaInSet.from_playlist_entry(inserted_entry) return playlists.TemaInSet.from_playlist_entry(inserted_entry)
@@ -87,10 +94,10 @@ def delete_tema(entry_id: int, con: Connection | None = None):
write.delete_playlist_entry(entry_id=entry_id, con=con) write.delete_playlist_entry(entry_id=entry_id, con=con)
def set_tema(session_id: int, set_id: int, entry_id: int, tema_id: int | None, def set_tema(playlist_id: int, set_id: int, entry_id: int, tema_id: int | None,
con: Connection | None = None): con: Connection | None = None):
with get_connection(con) as con: with get_connection(con) as con:
new_entry = playlists.PlaylistEntry(id=entry_id, session_id=session_id, set_id=set_id, tema_id=tema_id) new_entry = playlists.PlaylistEntry(id=entry_id, playlist_id=playlist_id, set_id=set_id, tema_id=tema_id)
write.update_playlist_entry(entry=new_entry, con=con) write.update_playlist_entry(entry=new_entry, con=con)
@@ -165,7 +172,73 @@ async def add_set_score_to_set(tune_set: playlists.Set) -> playlists.Set:
return tune_set return tune_set
def get_commonly_played_temes( async def get_or_create_playlist_score(playlist: playlists.Playlist) -> playlists.PlaylistScore | None:
tema_id: int, # The playlist is assumed to be enriched with tunes
) -> list[temes_model.CommonlyPlayedTema]: if not playlist.sets:
return query.get_commonly_played_tunes(tema_id=tema_id) return None
lilypond_playlist = lilypond_build.playlist_from_playlist(playlist)
if not lilypond_playlist.sets:
return None
playlist_score_hash = lilypond_playlist.hash().hex()
pdf_filepath = files_service.get_playlist_filename(f"{playlist_score_hash}.pdf")
if not pdf_filepath.exists():
# No score exists, so we need to create it
playlist_source = lilypond_source.playlist_source(playlist=lilypond_playlist)
out_filepath = files_service.get_playlist_filename(playlist_score_hash)
async with files_service.tmp_file(content=playlist_source) as source_filepath:
if not pdf_filepath.exists():
pdf_result = await lilypond_render.render_file(
input_file=source_filepath,
output=lilypond_render.RenderOutput.PDF,
output_file=out_filepath,
)
if pdf_result.error is not None:
return None
if pdf_result.result is None:
raise RuntimeError("This shouldn't happen")
pdf_filepath = pdf_result.result
return playlists.PlaylistScore(
img_url=None, # Only PDF generation for now
pdf_url=files_service.get_db_file_path(pdf_filepath),
)
async def add_playlist_score_to_playlist(playlist: playlists.Playlist) -> playlists.Playlist:
if score := await get_or_create_playlist_score(playlist=playlist):
return dataclasses.replace(
playlist,
playlist_score=score,
)
else:
return playlist
def get_all_playlists(logged_in: bool = False) -> list[playlists.Playlist]:
return list(query.get_all_playlists(logged_in=logged_in))
def set_visibility(playlist_id: int, hidden: bool) -> playlists.Playlist:
with get_connection() as con:
playlist = get_playlist(playlist_id=playlist_id, con=con)
if playlist is None:
raise ValueError(f"No playlist found with playlist_id = {playlist_id}!")
# Update hidden status in database
write.update_playlist_visibility(playlist_id=playlist_id, hidden=hidden, con=con)
# Return updated playlist
return dataclasses.replace(playlist, hidden=hidden)
def create_playlist(name: str | None = None) -> playlists.Playlist:
playlist_id = write.create_playlist(name=name)
return get_playlist(playlist_id=playlist_id)
def delete_playlist(playlist_id: int):
write.delete_playlist(playlist_id=playlist_id)

View File

@@ -1,10 +1,17 @@
import dataclasses
import datetime import datetime
from datetime import date as Date from datetime import date as Date
from folkugat_web.config import date as config from folkugat_web.config import date as config
from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.dal.sql.playlists import write as playlists_write
from folkugat_web.dal.sql.sessions import playlists as session_playlists
from folkugat_web.dal.sql.sessions import query, write from folkugat_web.dal.sql.sessions import query, write
from folkugat_web.model import sessions as model from folkugat_web.model import sessions as model
from folkugat_web.model import temes as temes_model
from folkugat_web.model.playlists import Playlist, PlaylistType
from folkugat_web.model.sql import Order, OrderCol, Range from folkugat_web.model.sql import Order, OrderCol, Range
from folkugat_web.services import playlists as playlists_service
def get_date_names(date: Date) -> model.DateNames: def get_date_names(date: Date) -> model.DateNames:
@@ -60,14 +67,19 @@ def get_session(session_id: int) -> model.Session | None:
def insert_session(session: model.Session): def insert_session(session: model.Session):
return write.insert_session(session) created_session = write.insert_session(session)
if created_session.id is not None:
_create_session_playlists(created_session.id)
return created_session
def set_session(session: model.Session): def set_session(session: model.Session):
write.update_session(session) write.update_session(session)
_update_session_playlist_names(session)
def delete_session(session_id: int): def delete_session(session_id: int):
_delete_session_playlists(session_id)
write.delete_session_by_id(session_id) write.delete_session_by_id(session_id)
@@ -77,3 +89,181 @@ def stop_live_sessions():
def set_live_session(session_id: int): def set_live_session(session_id: int):
write.set_live_session(session_id=session_id) write.set_live_session(session_id=session_id)
def get_session_playlist_id(
session_id: int,
playlist_type: PlaylistType,
con: Connection | None = None,
) -> int | None:
with get_connection(con=con) as con:
return session_playlists.get_playlist_id(
session_id=session_id,
playlist_type=playlist_type,
con=con,
)
def get_session_playlist(
session_id: int,
playlist_type: PlaylistType,
con: Connection | None = None,
) -> Playlist | None:
with get_connection(con=con) as con:
playlist_id = get_session_playlist_id(
session_id=session_id,
playlist_type=playlist_type,
con=con,
)
if playlist_id is None:
return None
return playlists_service.get_playlist(
playlist_id=playlist_id,
con=con,
)
def get_session_setlist(
session_id: int,
con: Connection | None = None,
) -> Playlist | None:
return get_session_playlist(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SETLIST,
con=con,
)
def get_session_slowjam(
session_id: int,
con: Connection | None = None,
) -> Playlist | None:
return get_session_playlist(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SLOWJAM,
con=con,
)
def add_playlists_to_session(session: model.Session) -> model.Session:
if session.id is not None:
with get_connection() as con:
setlist = get_session_setlist(session_id=session.id, con=con)
if setlist:
setlist = playlists_service.add_temes_to_playlist(setlist)
slowjam = get_session_slowjam(session_id=session.id, con=con)
if slowjam:
slowjam = playlists_service.add_temes_to_playlist(slowjam)
session = dataclasses.replace(
session,
setlist=setlist,
slowjam=slowjam,
)
return session
def get_commonly_played_temes(
tema_id: int,
) -> list[temes_model.CommonlyPlayedTema]:
return session_playlists.get_commonly_played_tunes(tema_id=tema_id)
def _get_playlist_names(session: model.Session) -> tuple[str, str]:
date_names = get_date_names(session.date)
setlist_name = f"Sessió del {date_names.day} {date_names.month_name} de {date_names.year}"
slowjam_name = f"Slow Jam del {date_names.day} {date_names.month_name} de {date_names.year}"
return setlist_name, slowjam_name
def _create_session_playlists(session_id: int):
session = get_session(session_id=session_id)
if not session:
return
setlist_name, slowjam_name = _get_playlist_names(session=session)
setlist_playlist_id = playlists_write.create_playlist(name=setlist_name)
slowjam_playlist_id = playlists_write.create_playlist(name=slowjam_name)
with get_connection() as con:
session_playlists.insert_playlist(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SETLIST,
playlist_id=setlist_playlist_id,
con=con
)
session_playlists.insert_playlist(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SLOWJAM,
playlist_id=slowjam_playlist_id,
con=con
)
def _update_session_playlist_names(session: model.Session):
if session.id is None:
return
setlist_name, slowjam_name = _get_playlist_names(session=session)
with get_connection() as con:
setlist_playlist_id = session_playlists.get_playlist_id(
session_id=session.id,
playlist_type=PlaylistType.SESSION_SETLIST,
con=con
)
slowjam_playlist_id = session_playlists.get_playlist_id(
session_id=session.id,
playlist_type=PlaylistType.SESSION_SLOWJAM,
con=con
)
if setlist_playlist_id is not None:
playlists_write.update_playlist_name(
playlist_id=setlist_playlist_id,
name=setlist_name,
con=con
)
if slowjam_playlist_id is not None:
playlists_write.update_playlist_name(
playlist_id=slowjam_playlist_id,
name=slowjam_name,
con=con
)
def _delete_session_playlists(session_id: int):
with get_connection() as con:
setlist_playlist_id = session_playlists.get_playlist_id(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SETLIST,
con=con
)
slowjam_playlist_id = session_playlists.get_playlist_id(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SLOWJAM,
con=con
)
if setlist_playlist_id is not None:
session_playlists.delete_playlist(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SETLIST,
con=con,
)
playlists_write.delete_playlist(
playlist_id=setlist_playlist_id,
con=con,
)
if slowjam_playlist_id is not None:
session_playlists.delete_playlist(
session_id=session_id,
playlist_type=PlaylistType.SESSION_SLOWJAM,
con=con,
)
playlists_write.delete_playlist(
playlist_id=slowjam_playlist_id,
con=con,
)

View File

@@ -17,6 +17,7 @@ def _sub(pattern: str, sub: str) -> Callable[[str], str]:
def _clean_string(lyrics_str: str) -> str: def _clean_string(lyrics_str: str) -> str:
return ( return (
FnChain.transform(lyrics_str) | FnChain.transform(lyrics_str) |
# _sub(r"~", "") |
_sub(r"\t", " ") | _sub(r"\t", " ") |
_sub(r" +", " ") | _sub(r" +", " ") |
_sub(r" \n", "\n") | _sub(r" \n", "\n") |
@@ -39,8 +40,13 @@ def get_lyric_by_id(lyric_id: int, tema_id: int | None = None) -> model.Lyrics |
return next(iter(lyrics_dal.get_lyrics(lyric_id=lyric_id, tema_id=tema_id)), None) return next(iter(lyrics_dal.get_lyrics(lyric_id=lyric_id, tema_id=tema_id)), None)
def update_lyric(lyric: model.Lyrics, title: str, content: str): def update_lyric(lyric: model.Lyrics, title: str, content: str, max_columns: int | None = None):
lyric = dataclasses.replace(lyric, title=title.strip(), content=_clean_string(content)) # Validate max_columns if provided
if max_columns is not None:
if not isinstance(max_columns, int) or max_columns < 1 or max_columns > 10:
raise ValueError("max_columns must be an integer between 1 and 10")
lyric = dataclasses.replace(lyric, title=title.strip(), content=_clean_string(content), max_columns=max_columns)
lyrics_dal.update_lyric(lyric=lyric) lyrics_dal.update_lyric(lyric=lyric)
return lyric return lyric

View File

@@ -1,6 +1,6 @@
from collections.abc import Iterable from collections.abc import Iterable
from folkugat_web.dal.sql.playlists import query as playlists_q from folkugat_web.dal.sql.sessions import playlists as session_playlists
from folkugat_web.dal.sql.temes import query as temes_q from folkugat_web.dal.sql.temes import query as temes_q
from folkugat_web.model import sessions as sessions_model from folkugat_web.model import sessions as sessions_model
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
@@ -20,7 +20,7 @@ def tema_compute_stats(
) -> model.Tema: ) -> model.Tema:
if tema.id: if tema.id:
if tune_sessions_dict is None: if tune_sessions_dict is None:
tune_sessions_dict = playlists_q.get_tune_sessions(tema_ids=[tema.id]) tune_sessions_dict = session_playlists.get_tune_sessions(tema_ids=[tema.id])
if tema.id and (tune_sessions := tune_sessions_dict.get(tema.id)): if tema.id and (tune_sessions := tune_sessions_dict.get(tema.id)):
unique_tune_sessions = set(tune_sessions) unique_tune_sessions = set(tune_sessions)
tema.stats = model.Stats( tema.stats = model.Stats(
@@ -33,7 +33,7 @@ def tema_compute_stats(
def temes_compute_stats(temes: Iterable[model.Tema]) -> list[model.Tema]: def temes_compute_stats(temes: Iterable[model.Tema]) -> list[model.Tema]:
temes = list(temes) temes = list(temes)
tema_ids = [tema.id for tema in temes if tema.id is not None] tema_ids = [tema.id for tema in temes if tema.id is not None]
tune_sessions_dict = playlists_q.get_tune_sessions(tema_ids=tema_ids) tune_sessions_dict = session_playlists.get_tune_sessions(tema_ids=tema_ids)
return [tema_compute_stats(tema=tema, tune_sessions_dict=tune_sessions_dict) for tema in temes] return [tema_compute_stats(tema=tema, tune_sessions_dict=tune_sessions_dict) for tema in temes]
@@ -41,5 +41,5 @@ def tema_compute_played_with(
tema: model.Tema, tema: model.Tema,
) -> model.Tema: ) -> model.Tema:
if tema.id: if tema.id:
tema.played_with = playlists_q.get_commonly_played_tunes(tema_id=tema.id) tema.played_with = session_playlists.get_commonly_played_tunes(tema_id=tema.id)
return tema return tema

View File

@@ -0,0 +1,10 @@
from folkugat_web.dal.sql import get_connection
with get_connection() as con:
cur = con.cursor()
alter_query_1 = """ ALTER TABLE sessions ADD COLUMN notes TEXT """
alter_query_2 = """ ALTER TABLE sessions ADD COLUMN cartell_url TEXT """
_ = cur.execute(alter_query_1)
_ = cur.execute(alter_query_2)
print("DONE!")

View File

@@ -0,0 +1,57 @@
from folkugat_web.dal.sql import get_connection
from folkugat_web.dal.sql.playlists import write as playlists_w
from folkugat_web.dal.sql.playlists.ddl import (create_playlist_entries_table,
create_playlists_table)
from folkugat_web.dal.sql.sessions import playlists as session_playlists
from folkugat_web.dal.sql.sessions import query as sessions_q
from folkugat_web.dal.sql.sessions.ddl import create_session_playlists_table
from folkugat_web.model.playlists import PlaylistType
with get_connection() as con:
cur = con.cursor()
drop_query = """DROP TABLE rehearse_data"""
_ = cur.execute(drop_query)
alter_query = """ALTER TABLE playlists RENAME TO playlists_old"""
_ = cur.execute(alter_query)
# Create new tables
create_playlist_entries_table(con=con)
create_playlists_table(con=con)
create_session_playlists_table(con=con)
# Create new playlists
sessions = sessions_q.get_sessions(con=con)
for session in sessions:
if session.id is None:
raise ValueError("Session without id!")
setlist_id = playlists_w.create_playlist(con=con)
session_playlists.insert_playlist(
session_id=session.id,
playlist_type=PlaylistType.SESSION_SETLIST,
playlist_id=setlist_id,
con=con,
)
slowjam_id = playlists_w.create_playlist(con=con)
session_playlists.insert_playlist(
session_id=session.id,
playlist_type=PlaylistType.SESSION_SLOWJAM,
playlist_id=slowjam_id,
con=con,
)
# Migrate
migrate_query = """
INSERT INTO playlist_entries
(id, playlist_id, set_id, tema_id)
SELECT
NULL, sp.playlist_id, pl_old.set_id, pl_old.tema_id
FROM playlists_old pl_old JOIN session_playlists sp USING (session_id)
WHERE sp.playlist_type = "session_setlist"
"""
_ = cur.execute(migrate_query)
print("DONE!")

View File

@@ -0,0 +1,71 @@
import datetime
import json
from collections.abc import Iterable
from folkugat_web.dal.sql import get_connection
from folkugat_web.dal.sql.sessions import playlists as session_playlists
from folkugat_web.model.playlists import PlaylistType
from folkugat_web.services import sessions as sessions_service
from folkugat_web.dal.sql.playlists import write as playlists_write
def main():
"""Main migration function."""
with get_connection() as con:
cur = con.cursor()
query = """ SELECT
sp.playlist_id,
sp.session_id,
s.date,
sp.playlist_type,
p.name as current_name
FROM session_playlists sp
JOIN sessions s ON sp.session_id = s.id
JOIN playlists p ON sp.playlist_id = p.id
WHERE p.name IS NULL OR p.name = ''
ORDER BY sp.session_id, sp.playlist_type """
_ = cur.execute(query)
rows = cur.fetchall()
if not rows:
print("No session playlists need renaming.")
return
updated_count = 0
for row in rows:
playlist_id, session_id, session_date, playlist_type, current_name = row
# Parse session date properly
session_date = sessions_service.get_date_names(
datetime.date.fromisoformat(session_date)
)
# Generate proper name based on playlist type
if playlist_type == PlaylistType.SESSION_SETLIST.value:
new_name = f"Sessió del {session_date.day} de {session_date.month_name} de {session_date.year}"
elif playlist_type == PlaylistType.SESSION_SLOWJAM.value:
new_name = f"Slow Jam del {session_date.day} de {session_date.month_name} de {session_date.year}"
else:
print(f"Unknown playlist type {playlist_type} for playlist {playlist_id}")
continue
# Update the playlist name using existing function
playlists_write.update_playlist_name(
playlist_id=playlist_id,
name=new_name,
con=con
)
current_name_display = current_name or "None"
print(
f"Updated playlist {playlist_id} (session {session_id}) "
f"from '{current_name_display}' to '{new_name}'"
)
updated_count += 1
con.commit()
print(f"Migration completed! Updated {updated_count} playlist names.")
print("DONE!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
Migration script to add hidden column to playlists table and set all existing playlists to hidden.
"""
import sqlite3
import sys
from pathlib import Path
# Add the project root to the path so we can import project modules
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from folkugat_web.config.db import DB_FILE
def main():
print(f"Connecting to database at: {DB_FILE}")
with sqlite3.connect(DB_FILE) as con:
cur = con.cursor()
# Check if hidden column already exists
cur.execute("PRAGMA table_info(playlists)")
columns = [column[1] for column in cur.fetchall()]
if 'hidden' not in columns:
print("Adding hidden column to playlists table...")
cur.execute("ALTER TABLE playlists ADD COLUMN hidden INTEGER NOT NULL DEFAULT 1")
print("Column added successfully.")
else:
print("Hidden column already exists.")
# Set all existing playlists to hidden (1)
print("Setting all existing playlists to hidden...")
cur.execute("UPDATE playlists SET hidden = 1")
updated_count = cur.rowcount
print(f"Updated {updated_count} playlists to hidden.")
con.commit()
print("Migration completed successfully!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,8 @@
from folkugat_web.dal.sql import get_connection
with get_connection() as con:
cur = con.cursor()
alter_query = """ ALTER TABLE tema_lyrics ADD COLUMN max_columns INTEGER"""
_ = cur.execute(alter_query)
print("DONE!")