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,