diff --git a/folkugat_web/api/__init__.py b/folkugat_web/api/__init__.py
index 12f6d40..0031b34 100644
--- a/folkugat_web/api/__init__.py
+++ b/folkugat_web/api/__init__.py
@@ -5,3 +5,5 @@ from .sessio import *
from .sessions import *
from .tema import *
from .temes import *
+
+__all__ = ["router"]
diff --git a/folkugat_web/api/sessio/index.py b/folkugat_web/api/sessio/index.py
index 17249f6..f8cdd62 100644
--- a/folkugat_web/api/sessio/index.py
+++ b/folkugat_web/api/sessio/index.py
@@ -1,9 +1,15 @@
from typing import Annotated
-from fastapi import Form, Request
+from fastapi import Form, HTTPException, Request
+from fastapi.responses import HTMLResponse
from folkugat_web.api import router
from folkugat_web.fragments import live, sessio
-from folkugat_web.services import auth
+from folkugat_web.services import auth, files
+from folkugat_web.services import playlists as playlists_service
+from folkugat_web.services.lilypond import build as lilypond_build
+from folkugat_web.services.lilypond import render as lilypond_render
+from folkugat_web.services.lilypond import source as lilypond_source
+from folkugat_web.services.temes import scores as scores_service
from folkugat_web.services.temes import write as temes_service
from folkugat_web.templates import templates
@@ -197,3 +203,27 @@ def set_tema_new(
entry_id=entry_id,
tema_id=new_tema.id,
)
+
+
+@router.get("/api/sessio/{session_id}/set/{set_id}/score")
+async def render(
+ request: Request,
+ _: auth.RequireLogin,
+ session_id: int,
+ set_id: int,
+):
+ set_entry = playlists_service.get_set(session_id=session_id, set_id=set_id)
+ if not set_entry:
+ raise HTTPException(status_code=404, detail="Could not find set!")
+ tune_set = lilypond_build.set_from_set(set_entry=set_entry)
+ set_source = lilypond_source.set_source(tune_set=tune_set)
+ pdf_result = await lilypond_render.render(
+ source=set_source,
+ output=lilypond_render.RenderOutput.PDF,
+ )
+ if output_filename := pdf_result.result:
+ score_render_url = files.get_db_file_path(output_filename)
+ # return temes.score_render(request=request, score_id=score_id, score_render_url=score_render_url)
+ return HTMLResponse(content=score_render_url)
+ else:
+ return HTMLResponse()
diff --git a/folkugat_web/api/sessions/editor.py b/folkugat_web/api/sessions/editor.py
index c63e6c7..4a85bfb 100644
--- a/folkugat_web/api/sessions/editor.py
+++ b/folkugat_web/api/sessions/editor.py
@@ -1,5 +1,5 @@
import datetime
-from typing import Annotated, Optional
+from typing import Annotated
from fastapi import Form, Request
from folkugat_web.api import router
diff --git a/folkugat_web/api/tema/index.py b/folkugat_web/api/tema/index.py
index 8d15f7e..20ac616 100644
--- a/folkugat_web/api/tema/index.py
+++ b/folkugat_web/api/tema/index.py
@@ -48,6 +48,12 @@ def contingut(request: Request, logged_in: auth.LoggedIn, tema_id: int):
@router.delete("/api/tema/{tema_id}")
def delete_tema(_: auth.RequireLogin, 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 = temes_q.tema_compute_stats(tema=tema)
+ if tema.stats:
+ raise HTTPException(status_code=400, detail="Can't delete a tune that has been played in a set!")
temes_w.delete_tema(tema_id=tema_id)
files_service.clean_orphan_files()
return HTMLResponse(headers={
diff --git a/folkugat_web/api/tema/scores.py b/folkugat_web/api/tema/scores.py
index 37a9b23..ce7daca 100644
--- a/folkugat_web/api/tema/scores.py
+++ b/folkugat_web/api/tema/scores.py
@@ -7,11 +7,11 @@ from fastapi.responses import HTMLResponse
from folkugat_web.api import router
from folkugat_web.fragments import temes
from folkugat_web.fragments.tema import scores as scores_fragments
-from folkugat_web.services import auth, files, lilypond
-from folkugat_web.services.temes import properties as properties_service
-from folkugat_web.services.temes import query as temes_q
+from folkugat_web.services import auth, files
+from folkugat_web.services.lilypond import build as lilypond_build
+from folkugat_web.services.lilypond import render as lilypond_render
+from folkugat_web.services.lilypond import source as lilypond_source
from folkugat_web.services.temes import scores as scores_service
-from folkugat_web.utils import FnChain
@router.get("/api/tema/{tema_id}/score/{score_id}")
@@ -36,10 +36,19 @@ async def set_score(
if not score:
raise HTTPException(status_code=404, detail="Could not find lyric!")
- full_source = scores_service.build_single_tune_full_source(tema_id=tema_id, source=source)
- pdf_filename = files.create_tema_filename(tema_id=tema_id)
- pdf_filename, errors = await lilypond.render(source=full_source, fmt="pdf", output_filename=pdf_filename)
- if errors:
+ tune = lilypond_build.tune_from_tema_id(tema_id=tema_id, score_source=source)
+ tune_source = lilypond_source.tune_source(tune=tune)
+ pdf_result, png_result = await render_tune(tune_source=tune_source, tema_id=tema_id)
+ if errors := pdf_result.error:
+ new_score = dataclasses.replace(
+ score,
+ source=source,
+ title=title,
+ errors=errors,
+ )
+ elif png_result is None:
+ raise RuntimeError(f"Received empty png_result with pdf_result: {pdf_result}")
+ elif errors := png_result.error:
new_score = dataclasses.replace(
score,
source=source,
@@ -47,21 +56,43 @@ async def set_score(
errors=errors,
)
else:
- png_filename = files.create_tema_filename(tema_id=tema_id)
- png_filename, errors = await lilypond.render(source=full_source, fmt="png", output_filename=png_filename)
+ pdf_file = pdf_result.result
+ png_file = png_result.result
new_score = dataclasses.replace(
score,
source=source,
title=title,
- pdf_url=files.get_db_file_path(pdf_filename) if pdf_filename else None,
- img_url=files.get_db_file_path(png_filename) if png_filename else None,
- errors=errors,
+ pdf_url=files.get_db_file_path(pdf_file) if pdf_file else None,
+ img_url=files.get_db_file_path(png_file) if png_file else None,
+ errors=[],
)
scores_service.update_score(score=new_score)
files.clean_orphan_files()
return scores_fragments.score(request=request, logged_in=logged_in, score=new_score)
+async def render_tune(
+ tune_source: str,
+ tema_id: int,
+) -> tuple[lilypond_render.RenderResult, lilypond_render.RenderResult | None]:
+ async with files.tmp_file(content=tune_source) as source_file:
+ pdf_file = files.create_tema_filename(tema_id=tema_id)
+ pdf_result = await lilypond_render.render_file(
+ input_file=source_file,
+ output=lilypond_render.RenderOutput.PDF,
+ output_file=pdf_file,
+ )
+ if pdf_result.error:
+ return pdf_result, None
+ png_file = files.create_tema_filename(tema_id=tema_id)
+ png_result = await lilypond_render.render_file(
+ input_file=source_file,
+ output=lilypond_render.RenderOutput.PNG_CROPPED,
+ output_file=png_file,
+ )
+ return pdf_result, png_result
+
+
@router.post("/api/tema/{tema_id}/score")
def add_score(
request: Request,
@@ -108,13 +139,17 @@ async def render(
score_id: int,
source: Annotated[str, Form()],
):
- full_source = scores_service.build_single_tune_full_source(tema_id=tema_id, source=source)
- output_filename, errors = await lilypond.render(source=full_source, fmt="png")
- if output_filename:
+ tune = lilypond_build.tune_from_tema_id(tema_id=tema_id, score_source=source)
+ full_source = lilypond_source.tune_source(tune=tune)
+ png_result = await lilypond_render.render(
+ source=full_source,
+ output=lilypond_render.RenderOutput.PNG_CROPPED,
+ )
+ if output_filename := png_result.result:
score_render_url = files.get_db_file_path(output_filename)
return temes.score_render(request=request, score_id=score_id, score_render_url=score_render_url)
else:
- return temes.score_render(request=request, score_id=score_id, errors=errors)
+ return temes.score_render(request=request, score_id=score_id, errors=png_result.error)
@router.put("/api/tema/{tema_id}/score/{score_id}/show")
diff --git a/folkugat_web/assets/static/css/main.css b/folkugat_web/assets/static/css/main.css
index 94bfa15..c27b609 100644
--- a/folkugat_web/assets/static/css/main.css
+++ b/folkugat_web/assets/static/css/main.css
@@ -602,6 +602,10 @@ video {
margin: 0.75rem;
}
+.m-4 {
+ margin: 1rem;
+}
+
.m-6 {
margin: 1.5rem;
}
@@ -701,6 +705,10 @@ video {
height: 80%;
}
+.h-auto {
+ height: auto;
+}
+
.h-px {
height: 1px;
}
@@ -729,6 +737,10 @@ video {
max-width: 56rem;
}
+.max-w-full {
+ max-width: 100%;
+}
+
.max-w-xl {
max-width: 36rem;
}
@@ -871,6 +883,10 @@ video {
border-radius: 0.25rem;
}
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
.rounded-md {
border-radius: 0.375rem;
}
@@ -911,6 +927,11 @@ video {
background-color: rgb(62 56 52 / var(--tw-bg-opacity, 1));
}
+.bg-white {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
+}
+
.p-0 {
padding: 0px;
}
@@ -961,6 +982,11 @@ video {
padding-bottom: 0.5rem;
}
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
.pl-5 {
padding-left: 1.25rem;
}
diff --git a/folkugat_web/assets/templates/fragments/tema/editor/score_render.html b/folkugat_web/assets/templates/fragments/tema/editor/score_render.html
index 2af1936..bfbc9ec 100644
--- a/folkugat_web/assets/templates/fragments/tema/editor/score_render.html
+++ b/folkugat_web/assets/templates/fragments/tema/editor/score_render.html
@@ -2,7 +2,10 @@
Previsualització
{% if score_render_url %}
-
+
+

+
{% else %}
{% for error in errors %}
diff --git a/folkugat_web/assets/templates/fragments/tema/score.html b/folkugat_web/assets/templates/fragments/tema/score.html
index c702c28..3970382 100644
--- a/folkugat_web/assets/templates/fragments/tema/score.html
+++ b/folkugat_web/assets/templates/fragments/tema/score.html
@@ -47,22 +47,25 @@
{% endif %}
-
- {% if score.img_url is not none %}
-
-
-
-
+ {% if score.img_url is not none %}
+
+
+ {% if score.pdf_url is not none %}
+
- {% if score.pdf_url is not none %}
-
- {% endif %}
- {% elif score.pdf_url is not none %}
- {% set pdf_url = score.pdf_url %}
- {% include "fragments/pdf_viewer.html" %}
{% endif %}
+ {% elif score.pdf_url is not none %}
+
+ {% set pdf_url = score.pdf_url %}
+ {% include "fragments/pdf_viewer.html" %}
+
+ {% endif %}
{% endif %}
diff --git a/folkugat_web/assets/templates/fragments/tema/title.html b/folkugat_web/assets/templates/fragments/tema/title.html
index b30252d..ef1b54e 100644
--- a/folkugat_web/assets/templates/fragments/tema/title.html
+++ b/folkugat_web/assets/templates/fragments/tema/title.html
@@ -10,11 +10,13 @@
{% include "fragments/tema/visibility.html" %}
-
{% endif %}
+ {% endif %}
diff --git a/folkugat_web/assets/templates/lilypond/score_template.ly b/folkugat_web/assets/templates/lilypond/score_template.ly
index 093f1fa..c95f8cc 100644
--- a/folkugat_web/assets/templates/lilypond/score_template.ly
+++ b/folkugat_web/assets/templates/lilypond/score_template.ly
@@ -1,19 +1,3 @@
-% -----------------------------------------------------
-% Comandes últils
-% -----------------------------------------------------
-% Anacrusa: \partial 2 (2 = blanca)
-%
-% Repetició amb caselles:
-% \repeat volta 2 {
-% %%%
-% \alternative {
-% \volta 1 { %%% }
-% \volta 2 { %%% }
-% }
-% }
-%
-% -----------------------------------------------------
-
\new ChordNames {
\chords {
\set chordChanges = ##t
@@ -50,11 +34,21 @@
% A
\mark \default
-
- \bar "||" \break
+ \repeat volta 2 {
+ \alternative {
+ \volta 1 { }
+ \volta 2 { }
+ }
+ }
% B
\mark \default
+ \repeat volta 2 {
+ \alternative {
+ \volta 1 { }
+ \volta 2 { }
+ }
+ }
\bar "|."
}
diff --git a/folkugat_web/assets/templates/lilypond/single_score.ly b/folkugat_web/assets/templates/lilypond/single_tune.ly
similarity index 66%
rename from folkugat_web/assets/templates/lilypond/single_score.ly
rename to folkugat_web/assets/templates/lilypond/single_tune.ly
index 9e57ad1..bff95ab 100644
--- a/folkugat_web/assets/templates/lilypond/single_score.ly
+++ b/folkugat_web/assets/templates/lilypond/single_tune.ly
@@ -6,11 +6,9 @@
}
\header {
- title = \markup { "{{ tema.title | safe }}" }
- {% if tema.composer() %}
- composer = "{{ tema.composer() | safe }}"
- {% elif tema.origin() %}
- composer = "{{ tema.origin() | safe }}"
+ title = \markup { "{{ tune.header.title | safe }}" }
+ {% if tune.header.composer %}
+ composer = "{{ tune.header.composer | safe }}"
{% endif %}
tagline = "Partitura generada amb LilyPond"
@@ -19,15 +17,17 @@
\markup \vspace #2
+{% if tune.score_source is not none %}
\score {
\language "english"
<<
{{ score_beginning }}
-{{ score_source | safe }}
+{{ tune.score_source | safe }}
>>
}
-{% if lyrics_text is not none %}
-{% for line in lyrics_text.lines %}
+{% endif %}
+{% if tune.lyrics is not none %}
+{% for line in tune.lyrics.lines %}
\markup {
\vspace #2
\fill-line {
@@ -42,7 +42,7 @@
\hspace #1
}
}
-{% if loop.index < lyrics_text.lines|length %}
+{% if loop.index < tune.lyrics.lines|length %}
\markup {
\vspace #1
\fill-line {
diff --git a/folkugat_web/assets/templates/lilypond/tune_in_set.ly b/folkugat_web/assets/templates/lilypond/tune_in_set.ly
new file mode 100644
index 0000000..d03b9c8
--- /dev/null
+++ b/folkugat_web/assets/templates/lilypond/tune_in_set.ly
@@ -0,0 +1,46 @@
+\markup {
+ \vspace #2
+ \fill-line {
+ \center-column {
+ \large \bold "{{ tune.header.title | safe }}"
+ {% if tune.header.composer %}
+ "{{ tune.header.composer | safe }}"
+ {% endif %}
+ }
+ }
+}
+{% if tune.score_source is not none %}
+\score {
+ \language "english"
+ <<
+{{ tune.score_source | safe }}
+ >>
+}
+{% endif %}
+{% if tune.lyrics is not none %}
+{% for line in tune.lyrics.lines %}
+\markup {
+ \vspace #2
+ \fill-line {
+ {% for paragraph in line.paragraphs %}
+ \hspace #1
+ \center-column {
+ {% for line in paragraph.lines %}
+ \line { {{ line | safe }} }
+ {% endfor %}
+ }
+ {% endfor %}
+ \hspace #1
+ }
+}
+{% if loop.index < tune.lyrics.lines|length %}
+\markup {
+ \vspace #1
+ \fill-line {
+ \override #'(span-factor . 4/9)
+ \draw-hline
+ }
+}
+{% endif %}
+{% endfor %}
+{% endif %}
diff --git a/folkugat_web/assets/templates/lilypond/tune_set.ly b/folkugat_web/assets/templates/lilypond/tune_set.ly
new file mode 100644
index 0000000..9f113e9
--- /dev/null
+++ b/folkugat_web/assets/templates/lilypond/tune_set.ly
@@ -0,0 +1,31 @@
+{{ score_beginning }}
+\version "2.24.4"
+\book {
+ \paper {
+ top-margin = 10
+ left-margin = 15
+ right-margin = 15
+
+ scoreTitleMarkup = \markup {
+ \center-column {
+ \fontsize #3 \bold \fromproperty #'header:piece
+ \fill-line {
+ \null
+ \right-column {
+ \fromproperty #'header:composer
+ }
+ }
+ }
+ }
+ }
+
+ \header {
+ title = \markup { "{{ tune_set.title | safe }}" }
+ tagline = "Partitura generada amb LilyPond"
+ copyright = "Folkugat"
+ }
+
+ {% for tune in tune_set.tunes %}
+ {% include "lilypond/tune_in_set.ly" %}
+ {% endfor %}
+}
diff --git a/folkugat_web/dal/sql/playlists/query.py b/folkugat_web/dal/sql/playlists/query.py
index d277bbb..0b962b0 100644
--- a/folkugat_web/dal/sql/playlists/query.py
+++ b/folkugat_web/dal/sql/playlists/query.py
@@ -49,6 +49,7 @@ def get_playlist_entries(
id, session_id, set_id, tema_id
FROM playlists
WHERE {filter_clause}
+ ORDER BY id ASC
"""
with get_connection(con) as con:
cur = con.cursor()
diff --git a/folkugat_web/dal/sql/temes/query.py b/folkugat_web/dal/sql/temes/query.py
index b2af9eb..2d15b8a 100644
--- a/folkugat_web/dal/sql/temes/query.py
+++ b/folkugat_web/dal/sql/temes/query.py
@@ -1,3 +1,5 @@
+from collections.abc import Iterable
+
from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import search as search_model
from folkugat_web.model import temes as model
@@ -22,6 +24,21 @@ def get_tema_by_id(tema_id: int, con: Connection | None = None) -> model.Tema |
return conversion.row_to_tema(row) if row else None
+def get_temes_by_ids(tema_ids: list[int], con: Connection | None = None) -> list[model.Tema]:
+ placeholders = ", ".join(["?" for _ in tema_ids])
+ query = f"""
+ SELECT
+ id, title, alternatives, creation_date, modification_date, hidden
+ FROM temes
+ WHERE id IN ({placeholders})
+ """
+ with get_connection(con) as con:
+ cur = con.cursor()
+ _ = cur.execute(query, tema_ids)
+ rows: Iterable[conversion.TemaRowTuple] = cur.fetchall()
+ return list(map(conversion.row_to_tema, rows))
+
+
def evict_tema_id_to_ngrams_cache():
global _tema_id_to_ngrams_cache
_tema_id_to_ngrams_cache = None
diff --git a/folkugat_web/dal/sql/temes/scores.py b/folkugat_web/dal/sql/temes/scores.py
index 2918525..b74418b 100644
--- a/folkugat_web/dal/sql/temes/scores.py
+++ b/folkugat_web/dal/sql/temes/scores.py
@@ -3,8 +3,8 @@ from collections.abc import Iterable
from typing import TypedDict
from folkugat_web.dal.sql import Connection, get_connection
-from folkugat_web.model import lilypond as lilypond_model
from folkugat_web.model import temes as model
+from folkugat_web.model.lilypond.processing import RenderError
ScoreRowTuple = tuple[int, int, str, str, str, str | None, str | None, bool]
@@ -40,7 +40,7 @@ def row_to_score(row: ScoreRowTuple) -> model.Score:
tema_id=row[1],
title=row[2],
source=row[3],
- errors=list(map(lilypond_model.RenderError.from_dict, errors_dicts)),
+ errors=list(map(RenderError.from_dict, errors_dicts)),
img_url=row[5],
pdf_url=row[6],
hidden=bool(row[7]),
diff --git a/folkugat_web/fragments/sessio.py b/folkugat_web/fragments/sessio.py
index f2de843..70cc18e 100644
--- a/folkugat_web/fragments/sessio.py
+++ b/folkugat_web/fragments/sessio.py
@@ -1,5 +1,3 @@
-from typing import Optional
-
from fastapi import Request
from fastapi.responses import HTMLResponse
from folkugat_web.model.pagines import Pages
diff --git a/folkugat_web/fragments/sessions.py b/folkugat_web/fragments/sessions.py
index f8cf5a0..fc32fde 100644
--- a/folkugat_web/fragments/sessions.py
+++ b/folkugat_web/fragments/sessions.py
@@ -1,5 +1,3 @@
-from typing import Optional
-
from fastapi import Request
from fastapi.responses import HTMLResponse
from folkugat_web.config import calendari as config
diff --git a/folkugat_web/fragments/temes.py b/folkugat_web/fragments/temes.py
index e977f4c..2283228 100644
--- a/folkugat_web/fragments/temes.py
+++ b/folkugat_web/fragments/temes.py
@@ -1,6 +1,6 @@
from fastapi import Request
from folkugat_web.model import temes as model
-from folkugat_web.model.lilypond import RenderError
+from folkugat_web.model.lilypond.processing import RenderError
from folkugat_web.model.pagines import Pages
from folkugat_web.services import sessions as sessions_service
from folkugat_web.services.temes import links as links_service
diff --git a/folkugat_web/model/lilypond/__init__.py b/folkugat_web/model/lilypond/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/folkugat_web/model/lilypond.py b/folkugat_web/model/lilypond/processing.py
similarity index 70%
rename from folkugat_web/model/lilypond.py
rename to folkugat_web/model/lilypond/processing.py
index e5c56d6..0bdeeea 100644
--- a/folkugat_web/model/lilypond.py
+++ b/folkugat_web/model/lilypond/processing.py
@@ -22,18 +22,3 @@ class RenderError:
pos=str(self.pos),
error=self.error,
)
-
-
-@dataclasses.dataclass
-class LyricsParagraph:
- lines: list[str]
-
-
-@dataclasses.dataclass
-class LyricsLine:
- paragraphs: list[LyricsParagraph]
-
-
-@dataclasses.dataclass
-class LyricsText:
- lines: list[LyricsLine]
diff --git a/folkugat_web/model/lilypond/score.py b/folkugat_web/model/lilypond/score.py
new file mode 100644
index 0000000..99feae8
--- /dev/null
+++ b/folkugat_web/model/lilypond/score.py
@@ -0,0 +1,91 @@
+import dataclasses
+import zlib
+from typing import Self
+
+from folkugat_web.model import temes
+from folkugat_web.utils import batched
+
+
+def get_hash(*v: bytes) -> bytes:
+ return zlib.adler32(b"".join(v)).to_bytes(4)
+
+
+@dataclasses.dataclass
+class LyricsParagraph:
+ lines: list[str]
+
+ def hash(self) -> bytes:
+ return get_hash(*(get_hash(line.encode()) for line in self.lines))
+
+
+@dataclasses.dataclass
+class LyricsLine:
+ paragraphs: list[LyricsParagraph]
+
+ def hash(self) -> bytes:
+ return get_hash(*(paragraph.hash() for paragraph in self.paragraphs))
+
+
+@dataclasses.dataclass
+class LyricsText:
+ lines: list[LyricsLine]
+
+ @classmethod
+ def from_lyrics(cls, lyrics: temes.Lyrics, max_cols: int = 3) -> Self:
+ paragraphs = [
+ LyricsParagraph(lines=par_str.splitlines())
+ for par_str in lyrics.content.split("\n\n") if par_str
+ ]
+ return cls(lines=[
+ LyricsLine(paragraphs=list(line_pars))
+ for line_pars in batched(paragraphs, max_cols)
+ ])
+
+ def hash(self) -> bytes:
+ return get_hash(*(line.hash() for line in self.lines))
+
+
+@dataclasses.dataclass
+class HeaderData:
+ title: str
+ composer: str | None
+
+ @classmethod
+ def from_tema(cls, tema: temes.Tema) -> Self:
+ return cls(
+ title=tema.title,
+ composer=tema.composer() or tema.origin(),
+ )
+
+ def hash(self) -> bytes:
+ return get_hash(
+ self.title.encode(),
+ (self.composer or "").encode(),
+ )
+
+
+@dataclasses.dataclass
+class LilypondTune:
+ header: HeaderData
+ score_source: str | None
+ lyrics: LyricsText | None
+ is_unknown: bool = False
+
+ def hash(self) -> bytes:
+ return get_hash(
+ self.header.hash(),
+ (self.score_source or "").encode(),
+ self.lyrics.hash() if self.lyrics else b"",
+ )
+
+
+@dataclasses.dataclass
+class LilypondSet:
+ title: str
+ tunes: list[LilypondTune]
+
+ def hash(self) -> bytes:
+ return get_hash(
+ self.title.encode(),
+ *(tune.hash() for tune in self.tunes),
+ )
diff --git a/folkugat_web/model/playlists.py b/folkugat_web/model/playlists.py
index 5076b75..fba232f 100644
--- a/folkugat_web/model/playlists.py
+++ b/folkugat_web/model/playlists.py
@@ -51,7 +51,7 @@ class Set:
raise ValueError("All PlaylistEntries must have the same session_id")
return cls(
id=set_id,
- temes=[TemaInSet.from_playlist_entry(entry) for entry in entries],
+ temes=[TemaInSet.from_playlist_entry(entry) for entry in sorted(entries, key=lambda e: e.id or 0)],
)
diff --git a/folkugat_web/model/temes.py b/folkugat_web/model/temes.py
index 6a561c5..1b28dcf 100644
--- a/folkugat_web/model/temes.py
+++ b/folkugat_web/model/temes.py
@@ -4,7 +4,7 @@ import enum
import itertools
from typing import Self
-from folkugat_web.model.lilypond import RenderError
+from folkugat_web.model.lilypond.processing import RenderError
from folkugat_web.model.search import NGrams
from folkugat_web.model.sessions import Session
from folkugat_web.services import ngrams
diff --git a/folkugat_web/services/files.py b/folkugat_web/services/files.py
index f5a1180..55c1878 100644
--- a/folkugat_web/services/files.py
+++ b/folkugat_web/services/files.py
@@ -3,8 +3,10 @@ import os
import re
import uuid
from collections.abc import Iterator
+from contextlib import asynccontextmanager
from pathlib import Path
+import aiofiles
import magic
from fastapi import HTTPException, UploadFile
from folkugat_web.config import db
@@ -57,8 +59,8 @@ async def store_file(tema_id: int, upload_file: UploadFile) -> str:
def create_tema_filename(tema_id: int, extension: str = "") -> Path:
filename = str(uuid.uuid4().hex) + extension
- filedir = db.DB_FILES_DIR / str(tema_id)
- filedir.mkdir(exist_ok=True)
+ filedir = db.DB_FILES_DIR / "tema" / str(tema_id)
+ filedir.mkdir(parents=True, exist_ok=True)
filepath = filedir / filename
return filepath
@@ -71,6 +73,18 @@ def create_tmp_filename(extension: str = "") -> Path:
return filepath
+@asynccontextmanager
+async def tmp_file(content: str):
+ input_filename = create_tmp_filename(extension=".ly")
+ async with aiofiles.open(input_filename, "w") as f:
+ _ = await f.write(content)
+ try:
+ yield input_filename
+ finally:
+ if input_filename.exists():
+ os.remove(input_filename)
+
+
def list_files(tema_id: str) -> list[str]:
filedir = db.DB_FILES_DIR / str(tema_id)
return [get_db_file_path(f) for f in filedir.iterdir()]
diff --git a/folkugat_web/services/lilypond.py b/folkugat_web/services/lilypond.py
deleted file mode 100644
index 7f7afa5..0000000
--- a/folkugat_web/services/lilypond.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import asyncio
-import dataclasses
-import os
-import re
-from pathlib import Path
-from typing import Literal
-
-import aiofiles
-from folkugat_web.model.lilypond import (LyricsLine, LyricsParagraph,
- LyricsText, RenderError)
-from folkugat_web.model.temes import Tema
-from folkugat_web.services import files as files_service
-from folkugat_web.templates import templates
-from folkugat_web.utils import batched
-
-SCORE_BEGINNING = "% --- SCORE BEGINNING --- %"
-
-RenderFormat = Literal["png"] | Literal["pdf"]
-
-
-def source_to_single_score(source: str, tema: Tema) -> str:
- if tema.lyrics:
- lyrics_text = build_lyrics(tema.lyrics[0].content)
- else:
- lyrics_text = None
- print("AAAAAA")
- print(lyrics_text)
- print("AAAAAA")
- return templates.get_template("lilypond/single_score.ly").render(
- score_beginning=SCORE_BEGINNING,
- score_source=source,
- tema=tema,
- lyrics_text=lyrics_text,
- )
-
-
-async def render(source: str, fmt: RenderFormat, output_filename: Path | None = None) -> tuple[Path | None, list[RenderError]]:
- input_filename = files_service.create_tmp_filename(extension=".ly")
- async with aiofiles.open(input_filename, "w") as f:
- _ = await f.write(source)
-
- output_filename = output_filename or files_service.create_tmp_filename()
-
- if fmt == "png":
- proc = await asyncio.create_subprocess_exec(
- "lilypond", "--png", "-o", output_filename, input_filename,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- else:
- proc = await asyncio.create_subprocess_exec(
- "lilypond", "-o", output_filename, input_filename,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
-
- _stdout, stderr = await proc.communicate()
-
- if proc.returncode:
- esc_input_filename = re.escape(str(input_filename))
- error_re = re.compile(rf"{esc_input_filename}:(?P\d+):(?P\d+): error: (?P.*)$")
- error_matches = filter(None, map(error_re.match, stderr.decode("utf-8").splitlines()))
- errors = map(lambda m: RenderError.from_dict(m.groupdict()), error_matches)
- line_offset = source.splitlines().index(SCORE_BEGINNING) + 1
- errors_offset = [dataclasses.replace(e, line=e.line - line_offset) for e in errors if e.line > line_offset]
- return None, errors_offset
-
- if fmt == "png":
- # Check if the output generated multiple pages
- pages = sorted(output_filename.parent.rglob(f"{output_filename.name}*.png"))
- if len(pages) > 1:
- output_filename = pages[0]
- for page in pages[1:]:
- os.remove(page)
- else:
- output_filename = output_filename.with_suffix(".png")
- elif fmt == "pdf":
- output_filename = output_filename.with_suffix(".pdf")
-
- return output_filename, []
-
-
-def build_lyrics(text: str, max_cols: int = 3) -> LyricsText:
- paragraphs = [LyricsParagraph(lines=par_str.splitlines()) for par_str in text.split("\n\n") if par_str]
- print(list(batched(paragraphs, max_cols)))
- return LyricsText(lines=[
- LyricsLine(paragraphs=list(line_pars))
- for line_pars in batched(paragraphs, max_cols)
- ])
diff --git a/folkugat_web/services/lilypond/__init__.py b/folkugat_web/services/lilypond/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/folkugat_web/services/lilypond/build.py b/folkugat_web/services/lilypond/build.py
new file mode 100644
index 0000000..8bef2e6
--- /dev/null
+++ b/folkugat_web/services/lilypond/build.py
@@ -0,0 +1,102 @@
+from collections.abc import Iterable, Iterator
+
+from fastapi import HTTPException
+from folkugat_web.dal.sql.temes import scores as scores_dal
+from folkugat_web.model import playlists as playlists_model
+from folkugat_web.model import temes as model
+from folkugat_web.model.lilypond import score as lilypond_model
+from folkugat_web.services import lilypond
+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 query as temes_q
+from folkugat_web.services.temes import scores as scores_service
+from folkugat_web.utils import FnChain
+
+UNKNOWN_TITLE = "Desconegut"
+
+
+def unknown_tune() -> lilypond_model.LilypondTune:
+ return lilypond_model.LilypondTune(
+ header=lilypond_model.HeaderData(
+ title=UNKNOWN_TITLE,
+ composer=None,
+ ),
+ score_source=None,
+ lyrics=None,
+ is_unknown=True,
+ )
+
+
+def tune_from_tema(tema: model.Tema | None, score_source: str | None = None) -> lilypond_model.LilypondTune:
+ """
+ The given `tema` is assumed to have properties, lyrics and scores (if source is None)
+ """
+ if tema is None:
+ return unknown_tune()
+ return lilypond_model.LilypondTune(
+ header=lilypond_model.HeaderData.from_tema(tema=tema),
+ score_source=score_source or (tema.scores[0].source if tema.scores else None),
+ lyrics=lilypond_model.LyricsText.from_lyrics(lyrics=tema.lyrics[0]) if tema.lyrics else None,
+ )
+
+
+def tunes_from_temes(temes: Iterable[model.Tema | None]) -> list[lilypond_model.LilypondTune]:
+ """
+ All `Tema` in `temes` are assumed to have properties, lyrics and scores
+ """
+ return [tune_from_tema(tema) for tema in temes]
+
+
+def tune_from_tema_id(tema_id: int, score_source: str | None = None) -> lilypond_model.LilypondTune:
+ tema = temes_q.get_tema_by_id(tema_id)
+ if not tema:
+ raise HTTPException(status_code=404, detail="Could not find tema!")
+ tema = (
+ FnChain.transform(tema) |
+ properties_service.add_properties_to_tema |
+ lyrics_service.add_lyrics_to_tema
+ ).result()
+ if score_source is None:
+ tema = scores_service.add_scores_to_tema(tema)
+ return tune_from_tema(tema=tema, score_source=score_source)
+
+
+def tunes_from_tema_ids(tema_ids: list[int]) -> list[lilypond_model.LilypondTune]:
+ temes = (
+ FnChain.transform(temes_q.get_temes_by_ids(tema_ids=tema_ids)) |
+ properties_service.add_properties_to_temes |
+ lyrics_service.add_lyrics_to_temes |
+ scores_service.add_scores_to_temes |
+ list
+ ).result()
+ return tunes_from_temes(temes=temes)
+
+
+def build_set_title(temes: list[model.Tema | None]) -> str:
+ def build_title(tema: model.Tema | None) -> str:
+ return tema.title if tema else UNKNOWN_TITLE
+ return " i ".join(filter(bool, [
+ ", ".join([build_title(tema) for tema in temes[:-1]]),
+ build_title(temes[-1])
+ ]))
+
+
+def set_from_set(set_entry: playlists_model.Set) -> lilypond_model.LilypondSet:
+ def _build_temes_by_id(temes: Iterable[model.Tema]) -> dict[int, model.Tema]:
+ return {tema.id: tema for tema in temes if tema.id is not None}
+ tema_ids = [tema_in_set.tema_id for tema_in_set in set_entry.temes]
+ temes_by_id = (
+ FnChain.transform([tema_id for tema_id in tema_ids if tema_id is not None]) |
+ temes_q.get_temes_by_ids |
+ properties_service.add_properties_to_temes |
+ lyrics_service.add_lyrics_to_temes |
+ scores_service.add_scores_to_temes |
+ _build_temes_by_id
+ ).result()
+ temes = [temes_by_id[tema_id] if tema_id is not None else None for tema_id in tema_ids]
+ set_title = build_set_title(temes=temes)
+ tunes = tunes_from_temes(temes)
+ return lilypond_model.LilypondSet(
+ title=set_title,
+ tunes=tunes
+ )
diff --git a/folkugat_web/services/lilypond/render.py b/folkugat_web/services/lilypond/render.py
new file mode 100644
index 0000000..53b369b
--- /dev/null
+++ b/folkugat_web/services/lilypond/render.py
@@ -0,0 +1,105 @@
+import asyncio
+import dataclasses
+import enum
+import re
+from pathlib import Path
+
+import aiofiles
+from folkugat_web.log import logger
+from folkugat_web.model.lilypond.processing import RenderError
+from folkugat_web.services import files as files_service
+from folkugat_web.utils import Result
+
+from .source import SCORE_BEGINNING
+
+RenderResult = Result[Path, list[RenderError]]
+
+
+class RenderOutput(enum.Enum):
+ PDF = "pdf"
+ PNG_CROPPED = "png-cropped"
+ PNG_PREVIEW = "png-preview"
+
+
+async def render(
+ source: str,
+ output: RenderOutput,
+ output_file: Path | None = None,
+) -> RenderResult:
+ async with files_service.tmp_file(content=source) as input_file:
+ return await render_file(
+ input_file=input_file,
+ output=output,
+ output_file=output_file,
+ )
+
+
+async def render_file(
+ input_file: Path,
+ output: RenderOutput,
+ output_file: Path | None = None,
+) -> RenderResult:
+ output_file = output_file or files_service.create_tmp_filename()
+ match output:
+ case RenderOutput.PDF:
+ command = [
+ "lilypond",
+ "-f", "pdf",
+ "-o", str(output_file),
+ str(input_file)
+ ]
+ output_file = output_file.with_suffix(".pdf")
+ case RenderOutput.PNG_CROPPED:
+ command = [
+ "lilypond",
+ "-f", "png",
+ "-dcrop=#t",
+ "-dno-print-pages",
+ # "-duse-paper-size-for-page=#f",
+ "-o", str(output_file),
+ str(input_file)
+ ]
+ output_file = output_file.with_suffix(".cropped.png")
+ # output_file = output_file.with_suffix(".png")
+ case RenderOutput.PNG_PREVIEW:
+ command = [
+ "lilypond",
+ "-f", "png",
+ "-dpreview=#t",
+ "-dno-print-pages",
+ "-o", str(output_file),
+ str(input_file)
+ ]
+ output_file = output_file.with_suffix(".preview.png")
+
+ proc = await asyncio.create_subprocess_exec(
+ *command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+
+ _stdout, stderr = await proc.communicate()
+
+ if proc.returncode:
+ errors = await _build_errors(input_file=input_file, stderr=stderr)
+ return Result(error=errors)
+
+ return Result(result=output_file)
+
+
+async def _build_errors(input_file: Path, stderr: bytes) -> list[RenderError]:
+ stderr_lines = stderr.decode("utf-8").splitlines()
+ logger.warning("Lilypond errors:")
+ for line in stderr_lines:
+ logger.warning(line)
+ async with aiofiles.open(input_file, "r") as f:
+ lines = await f.readlines()
+
+ offset_mark = SCORE_BEGINNING + "\n"
+ line_offset = lines.index(offset_mark) + 1 if offset_mark in lines else 0
+ esc_input_file = re.escape(str(input_file))
+ error_re = re.compile(rf"{esc_input_file}:(?P\d+):(?P\d+): error: (?P.*)$")
+ error_matches = filter(None, map(error_re.match, stderr_lines))
+ errors = map(lambda m: RenderError.from_dict(m.groupdict()), error_matches)
+ errors_offset = [dataclasses.replace(e, line=e.line - line_offset) for e in errors if e.line > line_offset]
+ return errors_offset
diff --git a/folkugat_web/services/lilypond/source.py b/folkugat_web/services/lilypond/source.py
new file mode 100644
index 0000000..de7f98e
--- /dev/null
+++ b/folkugat_web/services/lilypond/source.py
@@ -0,0 +1,18 @@
+from folkugat_web.model.lilypond.score import LilypondSet, LilypondTune
+from folkugat_web.templates import templates
+
+SCORE_BEGINNING = "% --- SCORE BEGINNING --- %"
+
+
+def tune_source(tune: LilypondTune) -> str:
+ return templates.get_template("lilypond/single_tune.ly").render(
+ score_beginning=SCORE_BEGINNING,
+ tune=tune,
+ )
+
+
+def set_source(tune_set: LilypondSet) -> str:
+ return templates.get_template("lilypond/tune_set.ly").render(
+ score_beginning=SCORE_BEGINNING,
+ tune_set=tune_set,
+ )
diff --git a/folkugat_web/services/temes/query.py b/folkugat_web/services/temes/query.py
index a64e0a9..d35b15d 100644
--- a/folkugat_web/services/temes/query.py
+++ b/folkugat_web/services/temes/query.py
@@ -5,7 +5,11 @@ from folkugat_web.model import temes as model
def get_tema_by_id(tema_id: int) -> model.Tema | None:
- return temes_q.get_tema_by_id(tema_id)
+ return temes_q.get_tema_by_id(tema_id=tema_id)
+
+
+def get_temes_by_ids(tema_ids: list[int]) -> list[model.Tema]:
+ return temes_q.get_temes_by_ids(tema_ids=tema_ids)
def tema_compute_stats(
diff --git a/folkugat_web/services/temes/scores.py b/folkugat_web/services/temes/scores.py
index a5567a5..fd5d56f 100644
--- a/folkugat_web/services/temes/scores.py
+++ b/folkugat_web/services/temes/scores.py
@@ -3,6 +3,7 @@ from collections.abc import Iterable, Iterator
from fastapi import HTTPException
from folkugat_web.dal.sql.temes import scores as scores_dal
from folkugat_web.model import temes as model
+from folkugat_web.model.lilypond import score as lilypond_model
from folkugat_web.services import lilypond
from folkugat_web.services.temes import lyrics as lyrics_service
from folkugat_web.services.temes import properties as properties_service
@@ -46,13 +47,29 @@ def create_score(tema_id: int) -> model.Score:
return scores_dal.insert_score(score=new_score)
-def build_single_tune_full_source(tema_id: int, source: str) -> str:
- tema = temes_q.get_tema_by_id(tema_id)
- if not tema:
- raise HTTPException(status_code=404, detail="Could not find tema!")
- tema = (
- FnChain.transform(tema) |
- properties_service.add_properties_to_tema |
- lyrics_service.add_lyrics_to_tema
+def build_tune_set_full_source(tema_ids: list[int]) -> str:
+ temes = (
+ FnChain.transform(temes_q.get_temes_by_ids(tema_ids=tema_ids)) |
+ properties_service.add_properties_to_temes |
+ lyrics_service.add_lyrics_to_temes |
+ add_scores_to_temes |
+ list
).result()
- return lilypond.source_to_single_score(source=source, tema=tema)
+ if not temes:
+ return ""
+ set_title = " i ".join(filter(bool, [
+ ", ".join([tema.title for tema in temes[:-1]]),
+ temes[-1].title
+ ]))
+ tune_set = lilypond_model.LilypondSet(
+ title=set_title,
+ tunes=[
+ lilypond_model.LilypondTune(
+ header=lilypond_model.HeaderData.from_tema(tema=tema),
+ score_source=tema.scores[0].source if tema.scores else None,
+ lyrics=lilypond_model.LyricsText.from_lyrics(lyrics=tema.lyrics[0]) if tema.lyrics else None,
+ ) for tema in temes
+ ]
+ )
+ print("TUNE SET HASH: ", tune_set.hash().hex())
+ return lilypond.tune_set_score(tune_set=tune_set)
diff --git a/folkugat_web/utils.py b/folkugat_web/utils.py
index d69ba1c..3216bee 100644
--- a/folkugat_web/utils.py
+++ b/folkugat_web/utils.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import dataclasses
import itertools
from collections.abc import Callable, Iterable, Iterator
from typing import Generic, Protocol, Self, TypeVar
@@ -55,3 +56,13 @@ def batched(iterable: Iterable[T], n: int) -> Iterator[tuple[T, ...]]:
iterator = iter(iterable)
while batch := tuple(itertools.islice(iterator, n)):
yield batch
+
+
+ResultT = TypeVar("ResultT")
+ErrorT = TypeVar("ErrorT")
+
+
+@dataclasses.dataclass
+class Result(Generic[ResultT, ErrorT]):
+ result: ResultT | None = None
+ error: ErrorT | None = None