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