diff --git a/CHANGELOG.md b/CHANGELOG.md index 281178e4..cfadc7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ # Changes +## Features + +- The UltraStar format version can now be specified in the settings (see https://usdx.eu/format/). + + + +# Changes + ## Fixes - Switch from browser_cookie3 to rookiepy in order to retrieve browser cookies on diff --git a/src/usdb_syncer/download_options.py b/src/usdb_syncer/download_options.py index 76cabbd9..2c1f33e4 100644 --- a/src/usdb_syncer/download_options.py +++ b/src/usdb_syncer/download_options.py @@ -12,6 +12,7 @@ class TxtOptions: encoding: settings.Encoding newline: settings.Newline + format_version: settings.FormatVersion fix_linebreaks: settings.FixLinebreaks fix_first_words_capitalization: bool fix_spaces: settings.FixSpaces @@ -100,6 +101,7 @@ def _txt_options() -> TxtOptions | None: return TxtOptions( encoding=settings.get_encoding(), newline=settings.get_newline(), + format_version=settings.get_version(), fix_linebreaks=settings.get_fix_linebreaks(), fix_first_words_capitalization=settings.get_fix_first_words_capitalization(), fix_spaces=settings.get_fix_spaces(), diff --git a/src/usdb_syncer/gui/forms/SettingsDialog.ui b/src/usdb_syncer/gui/forms/SettingsDialog.ui index 711bd083..a7e18d32 100644 --- a/src/usdb_syncer/gui/forms/SettingsDialog.ui +++ b/src/usdb_syncer/gui/forms/SettingsDialog.ui @@ -7,7 +7,7 @@ 0 0 640 - 649 + 677 @@ -28,7 +28,7 @@ 20 - + @@ -76,17 +76,20 @@ true - - + + + + + - Line endings: + Encoding: - - + + - Fix capitalization: + Line endings: @@ -103,72 +106,92 @@ - - - - + + + + + 1 + 0 + + + + <html><head/><body><p>USDX and variants can handle both line ending types, but if you want to edit the song file manually, your editor of choice may require a certain line ending. UNIX users (MacOS, Linux) should generally use LF (line feed). On Windows, some text editors may require CRLF (carriage return, line feed). USDB currently requires CRLF line endings.</p></body></html> - - + + - Fix spaces: + Version: - + - <html><head/><body><p>If this setting is enabled, the linebreak timings will be recalculated, either according to USDX or to YASS rules.</p></body></html> + <html><head/><body><p><span style=" font-weight:700;">V1.0.0</span></p><p>Choose this version for highest compatibility. This uses #MP3 to specifiy the audio file.</p><p><span style=" font-weight:700;">V1.1.0</span></p><p>This version switches from #MP3 to #AUDIO to underline that other audio formats than mp3 are supported. For compatibility, the Syncer adds both #MP3 and #AUDIO to the text file, which should cause no issues when loading the song.</p><p><span style=" font-weight:700;">V1.2.0</span></p><p>This version introduces new tags #AUDIOURL, #COVERURL, #BACKGROUNDURL and #VIDEOURL to specify URLs for the respective sources. The syncer will fill these tags with the contents of the respective metatags, if available.</p></body></html> - + + + + + + + Optional song file fixes + + + Fix linebreaks: - - + + - <html><head/><body><p>If this setting is enabled, all inter-word spaces will be shifted to either after or before a word.</p></body></html> + <html><head/><body><p>If this setting is enabled, the linebreak timings will be recalculated, either according to USDX or to YASS rules.</p></body></html> - - - - - 1 - 0 - - - - <html><head/><body><p>USDX and variants can handle both line ending types, but if you want to edit the song file manually, your editor of choice may required a certain line ending. UNIX users (MacOS, Linux) should generally use LF (line feed). On Windows, some text editors may require CRLF (carriage return, line feed). USDB currently requires CRLF line endings.</p></body></html> + + + + Fix capitalization: - - - + + + + + + + - Encoding: + Fix spaces: + + + + + + + <html><head/><body><p>If this setting is enabled, all inter-word spaces will be shifted to either after or before a word.</p></body></html> - + Fix quotation marks: - + diff --git a/src/usdb_syncer/gui/settings_dialog.py b/src/usdb_syncer/gui/settings_dialog.py index 079caac8..9e35f33f 100644 --- a/src/usdb_syncer/gui/settings_dialog.py +++ b/src/usdb_syncer/gui/settings_dialog.py @@ -117,6 +117,7 @@ def _populate_comboboxes(self) -> None: combobox_settings = ( (self.comboBox_encoding, settings.Encoding), (self.comboBox_line_endings, settings.Newline), + (self.comboBox_format_version, settings.FormatVersion), (self.comboBox_fix_linebreaks, settings.FixLinebreaks), (self.comboBox_fix_spaces, settings.FixSpaces), (self.comboBox_cover_max_size, settings.CoverMaxSize), @@ -148,6 +149,9 @@ def _load_settings(self) -> None: self.comboBox_line_endings.setCurrentIndex( self.comboBox_line_endings.findData(settings.get_newline()) ) + self.comboBox_format_version.setCurrentIndex( + self.comboBox_format_version.findData(settings.get_version()) + ) self.comboBox_fix_linebreaks.setCurrentIndex( self.comboBox_fix_linebreaks.findData(settings.get_fix_linebreaks()) ) @@ -244,6 +248,7 @@ def _save_settings(self) -> bool: settings.set_txt(self.groupBox_songfile.isChecked()) settings.set_encoding(self.comboBox_encoding.currentData()) settings.set_newline(self.comboBox_line_endings.currentData()) + settings.set_version(self.comboBox_format_version.currentData()) settings.set_fix_linebreaks(self.comboBox_fix_linebreaks.currentData()) settings.set_fix_first_words_capitalization( self.checkBox_fix_first_words_capitalization.isChecked() diff --git a/src/usdb_syncer/json_export.py b/src/usdb_syncer/json_export.py index f917e211..31e7548b 100644 --- a/src/usdb_syncer/json_export.py +++ b/src/usdb_syncer/json_export.py @@ -13,7 +13,7 @@ from usdb_syncer import SongId from usdb_syncer.logger import logger from usdb_syncer.usdb_song import UsdbSong -from usdb_syncer.utils import url_from_resource +from usdb_syncer.utils import video_url_from_resource JSON_EXPORT_VERSION = 1 @@ -62,12 +62,12 @@ def from_usdb_song(cls, song: UsdbSong) -> SongExportData | None: meta.meta_tags.cover.to_str("co") if meta.meta_tags.cover else None ), audio_url=( - url_from_resource(meta.meta_tags.audio) + video_url_from_resource(meta.meta_tags.audio) if meta.meta_tags.audio else None ), video_url=( - url_from_resource(meta.meta_tags.video) + video_url_from_resource(meta.meta_tags.video) if meta.meta_tags.video else None ), diff --git a/src/usdb_syncer/resource_dl.py b/src/usdb_syncer/resource_dl.py index 6a0ef6b5..4937bdcc 100644 --- a/src/usdb_syncer/resource_dl.py +++ b/src/usdb_syncer/resource_dl.py @@ -17,7 +17,7 @@ from usdb_syncer.meta_tags import ImageMetaTags from usdb_syncer.settings import Browser, CoverMaxSize from usdb_syncer.usdb_scraper import SongDetails -from usdb_syncer.utils import url_from_resource +from usdb_syncer.utils import video_url_from_resource IMAGE_DOWNLOAD_HEADERS = { "User-Agent": ( @@ -134,7 +134,7 @@ def _ytdl_options(format_: str, _browser: Browser, target_stem: Path) -> YtdlOpt def _download_resource(options: YtdlOptions, resource: str, logger: Log) -> str | None: - if (url := url_from_resource(resource)) is None: + if (url := video_url_from_resource(resource)) is None: logger.debug(f"invalid audio/video resource: {resource}") return None diff --git a/src/usdb_syncer/settings.py b/src/usdb_syncer/settings.py index a8375d54..88609230 100644 --- a/src/usdb_syncer/settings.py +++ b/src/usdb_syncer/settings.py @@ -71,10 +71,11 @@ class SettingKey(Enum): TXT = "downloads/txt" ENCODING = "downloads/encoding" NEWLINE = "downloads/newline" + FORMAT_VERSION = "downloads/format_version" FIX_LINEBREAKS = "fixes/linebreaks" FIX_FIRST_WORDS_CAPITALIZATION = "fixes/firstwordscapitalization" FIX_SPACES = "fixes/spaces" - FIX_QUOTATION_MARKS = "fixes/quotationmarks" + FIX_QUOTATION_MARKS = "fixes/quotation_marks" AUDIO = "downloads/audio" AUDIO_FORMAT = "downloads/audio_format" AUDIO_BITRATE = "downloads/audio_bitrate" @@ -146,6 +147,17 @@ def default() -> Newline: return Newline.LF +class FormatVersion(Enum): + """Supported format versions for song txts.""" + + V1_0_0 = "1.0.0" + V1_1_0 = "1.1.0" + V1_2_0 = "1.2.0" + + def __str__(self) -> str: + return str(self.value) + + class FixLinebreaks(Enum): """Supported variants for fixing linebreak timings.""" @@ -624,6 +636,14 @@ def set_audio_embed_artwork(value: bool) -> None: set_setting(SettingKey.AUDIO_EMBED_ARTWORK, value) +def get_encoding() -> Encoding: + return get_setting(SettingKey.ENCODING, Encoding.UTF_8) + + +def set_encoding(value: Encoding) -> None: + set_setting(SettingKey.ENCODING, value) + + def get_newline() -> Newline: return get_setting(SettingKey.NEWLINE, Newline.default()) @@ -632,12 +652,12 @@ def set_newline(value: Newline) -> None: set_setting(SettingKey.NEWLINE, value) -def get_encoding() -> Encoding: - return get_setting(SettingKey.ENCODING, Encoding.UTF_8) +def get_version() -> FormatVersion: + return get_setting(SettingKey.FORMAT_VERSION, FormatVersion.V1_0_0) -def set_encoding(value: Encoding) -> None: - set_setting(SettingKey.ENCODING, value) +def set_version(value: FormatVersion) -> None: + set_setting(SettingKey.FORMAT_VERSION, value) def get_txt() -> bool: diff --git a/src/usdb_syncer/song_loader.py b/src/usdb_syncer/song_loader.py index 96468715..3891a9cd 100644 --- a/src/usdb_syncer/song_loader.py +++ b/src/usdb_syncer/song_loader.py @@ -10,7 +10,7 @@ import traceback from itertools import islice from pathlib import Path -from typing import Iterable, Iterator +from typing import Iterable, Iterator, assert_never import attrs import mutagen.mp4 @@ -39,10 +39,12 @@ from usdb_syncer.constants import ISO_639_2B_LANGUAGE_CODES from usdb_syncer.custom_data import CustomData from usdb_syncer.logger import Log, logger, song_logger +from usdb_syncer.settings import FormatVersion from usdb_syncer.song_txt import SongTxt from usdb_syncer.sync_meta import ResourceFile, SyncMeta from usdb_syncer.usdb_scraper import SongDetails from usdb_syncer.usdb_song import DownloadStatus, UsdbSong +from usdb_syncer.utils import video_url_from_resource class DownloadManager: @@ -518,14 +520,65 @@ def _maybe_write_txt(ctx: _Context) -> None: def _write_headers(ctx: _Context) -> None: + version = ( + ctx.options.txt_options.format_version + if ctx.options and ctx.options.txt_options + else FormatVersion.V1_0_0 + ) + if path := ctx.out.audio.path(ctx.locations, temp=True): - ctx.txt.headers.mp3 = path.name + _set_audio_headers(ctx, version, path) + if path := ctx.out.video.path(ctx.locations, temp=True): - ctx.txt.headers.video = path.name + _set_video_headers(ctx, version, path) + if path := ctx.out.cover.path(ctx.locations, temp=True): - ctx.txt.headers.cover = path.name + _set_cover_headers(ctx, version, path) + if path := ctx.out.background.path(ctx.locations, temp=True): - ctx.txt.headers.background = path.name + _set_background_headers(ctx, version, path) + + +def _set_audio_headers(ctx: _Context, version: FormatVersion, path: Path) -> None: + match version: + case FormatVersion.V1_0_0: + ctx.txt.headers.mp3 = path.name + case FormatVersion.V1_1_0: + # write both #MP3 and #AUDIO to maximize compatibility + ctx.txt.headers.mp3 = path.name + ctx.txt.headers.audio = path.name + case FormatVersion.V1_2_0: + ctx.txt.headers.audio = path.name + if resource := ctx.txt.meta_tags.audio or ctx.txt.meta_tags.video: + ctx.txt.headers.audiourl = video_url_from_resource(resource) + case _ as unreachable: + assert_never(unreachable) + + +def _set_video_headers(ctx: _Context, version: FormatVersion, path: Path) -> None: + ctx.txt.headers.video = path.name + if version == FormatVersion.V1_2_0 and (resource := ctx.txt.meta_tags.video): + ctx.txt.headers.videourl = video_url_from_resource(resource) + + +def _set_cover_headers(ctx: _Context, version: FormatVersion, path: Path) -> None: + ctx.txt.headers.cover = path.name + if ( + version == FormatVersion.V1_2_0 + and ctx.txt.meta_tags.cover + and (url := ctx.txt.meta_tags.cover.source_url(ctx.logger)) + ): + ctx.txt.headers.coverurl = url + + +def _set_background_headers(ctx: _Context, version: FormatVersion, path: Path) -> None: + ctx.txt.headers.background = path.name + if ( + version == FormatVersion.V1_2_0 + and ctx.txt.meta_tags.background + and (url := ctx.txt.meta_tags.background.source_url(ctx.logger)) + ): + ctx.txt.headers.backgroundurl = url def _maybe_write_audio_tags(ctx: _Context) -> None: diff --git a/src/usdb_syncer/song_txt/__init__.py b/src/usdb_syncer/song_txt/__init__.py index 4261c2fe..63c5f8b5 100644 --- a/src/usdb_syncer/song_txt/__init__.py +++ b/src/usdb_syncer/song_txt/__init__.py @@ -86,6 +86,7 @@ def sanitize(self, txt_options: download_options.TxtOptions | None) -> None: """Sanitize USDB issues and prepare for local usage.""" self.headers.reset_file_location_headers() if txt_options: + self.headers.set_version(txt_options.format_version) self.fix(txt_options) def fix(self, txt_options: download_options.TxtOptions) -> None: diff --git a/src/usdb_syncer/song_txt/headers.py b/src/usdb_syncer/song_txt/headers.py index bf226cfa..3fa4c644 100644 --- a/src/usdb_syncer/song_txt/headers.py +++ b/src/usdb_syncer/song_txt/headers.py @@ -8,6 +8,7 @@ from usdb_syncer import errors from usdb_syncer.logger import Log +from usdb_syncer.settings import FormatVersion from usdb_syncer.song_txt.auxiliaries import BeatsPerMinute, replace_false_apostrophes from usdb_syncer.song_txt.language_translations import LANGUAGE_TRANSLATIONS @@ -21,6 +22,7 @@ class Headers: artist: str bpm: BeatsPerMinute gap: int = 0 + version: str | None = None language: str | None = None edition: str | None = None genre: str | None = None @@ -28,9 +30,16 @@ class Headers: year: str | None = None creator: str | None = None mp3: str | None = None + audio: str | None = None + audiourl: str | None = None + vocals: str | None = None + instrumental: str | None = None cover: str | None = None + coverurl: str | None = None background: str | None = None + backgroundurl: str | None = None video: str | None = None + videourl: str | None = None videogap: float | None = None start: float | None = None end: int | None = None @@ -71,14 +80,20 @@ def parse(cls, lines: list[str], logger: Log) -> Headers: ) return cls(**kwargs) + def set_version(self, version: FormatVersion) -> None: + self.version = version.value + def reset_file_location_headers(self) -> None: """Clear all tags with local file locations.""" - self.mp3 = self.video = self.cover = self.background = None + self.mp3 = self.audio = self.vocals = self.instrumental = self.video = ( + self.cover + ) = self.background = None def __str__(self) -> str: out = "\n".join( f"#{key.upper()}:{val}" for key in ( + "version", "title", "artist", "language", @@ -87,9 +102,16 @@ def __str__(self) -> str: "year", "creator", "mp3", + "audio", + "audiourl", + "vocals", + "instrumental", "cover", + "coverurl", "background", + "backgroundurl", "video", + "videourl", "videogap", "resolution", "start", @@ -170,6 +192,9 @@ def _set_header_value(kwargs: dict[str, Any], header: str, value: str) -> None: "year", "creator", "mp3", + "audio", + "vocals", + "instrumental", "cover", "background", "relative", diff --git a/src/usdb_syncer/utils.py b/src/usdb_syncer/utils.py index 37bef2df..c735d669 100644 --- a/src/usdb_syncer/utils.py +++ b/src/usdb_syncer/utils.py @@ -34,7 +34,7 @@ def _root() -> Path: return Path(__file__).parent.parent.parent.absolute() -def url_from_resource(resource: str) -> str | None: +def video_url_from_resource(resource: str) -> str | None: if "://" in resource: return resource if "/" in resource: diff --git a/tests/unit/song_txt/test_notes_parser.py b/tests/unit/song_txt/test_notes_parser.py index 869b3e05..f20e5efb 100644 --- a/tests/unit/song_txt/test_notes_parser.py +++ b/tests/unit/song_txt/test_notes_parser.py @@ -5,7 +5,13 @@ from usdb_syncer.download_options import TxtOptions from usdb_syncer.logger import logger -from usdb_syncer.settings import Encoding, FixLinebreaks, FixSpaces, Newline +from usdb_syncer.settings import ( + Encoding, + FixLinebreaks, + FixSpaces, + FormatVersion, + Newline, +) from usdb_syncer.song_txt import SongTxt @@ -41,6 +47,7 @@ def test_notes_parser_fixes(resource_dir: str) -> None: TxtOptions( encoding=Encoding.UTF_8, newline=Newline.CRLF, + format_version=FormatVersion.V1_0_0, fix_linebreaks=FixLinebreaks.USDX_STYLE, fix_first_words_capitalization=True, fix_spaces=FixSpaces.AFTER,