Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@
- add dialog that displays non-submittable song(s) + reason(s)
- Reading song files from legacy encodings (namely CP1252) is fixed
- Determining local changes for submission is fixed (no canonical fixes for remote txt)
- Saving searches now works in the Linux bundle again.

## Developer notes

- The build process was migrated to uv. Poetry is no longer used.
See the README for instructions.
- Addons are loaded earlier in the startup process than before. This means code executed at addon import time may behave differently. Hooks are unchanged.
- Two new hooks regarding cookies have been introduced. See the new wiki page for details and usage.
- Two new hooks regarding cookies have been introduced. See the new wiki page for details and usage.

<!-- 0.18.0 -->

# Changes

- yt-dlp has been updated to latest available version 2025.11.12, solving latest download issues. This introduces Deno Javascript Runtime as new external dependency.
The Syncer will inform you about this missing dependency and will give hints on how to install Deno on your OS.
The Syncer will inform you about this missing dependency and will give hints on how to install Deno on your OS.

## Features

Expand All @@ -39,8 +41,6 @@ The Syncer will inform you about this missing dependency and will give hints on

## Fixes



<!-- 0.17.0 -->

# Changes
Expand Down
4 changes: 2 additions & 2 deletions src/tools/write_resource_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
from importlib import resources

'''
_LINE_TEMPLATE = '{var} = resources.files() / "{fname}"\n'
_LINE_TEMPLATE = '{var} = resources.files(__package__) / "{fname}"\n'


def main() -> None:
dirs = [d for d in RESOURCE_DIR.iterdir() if d.is_dir() and d.name != "qt"]
for dir_ in dirs:
files = [f.name for f in dir_.iterdir() if f.is_file() and f.suffix != ".py"]
out_path = dir_ / "__init__.py"
with (out_path).open("w", encoding="utf-8") as out:
with (out_path).open("w", encoding="utf-8", newline="\n") as out:
out.write(_FILE_HEADER)
out.writelines(
_LINE_TEMPLATE.format(var=_var_name(f), fname=f) for f in files
Expand Down
98 changes: 41 additions & 57 deletions src/usdb_syncer/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,16 @@
from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator
from enum import StrEnum, auto
from importlib import resources
from pathlib import Path
from typing import Any, ClassVar, assert_never, cast
from typing import Any, assert_never, cast

import attrs
from more_itertools import batched

from usdb_syncer import SongId, SyncMetaId, errors
from usdb_syncer.logger import logger

from . import sql
from .sql import Sql

SCHEMA_VERSION = 8

Expand All @@ -45,18 +44,6 @@
)"""


class _SqlCache:
_cache: ClassVar[dict[str, str]] = {}

@classmethod
def get(cls, name: str, cache: bool = True) -> str:
if (stmt := cls._cache.get(name)) is None:
stmt = resources.files(sql).joinpath(name).read_text("utf8")
if cache:
cls._cache[name] = stmt
return stmt


class _LocalConnection(threading.local):
"""A thread-local database connection."""

Expand Down Expand Up @@ -124,15 +111,15 @@ def _validate_schema(connection: sqlite3.Connection) -> None:
raise errors.UnknownSchemaError
version = row[0]
for ver in range(version + 1, SCHEMA_VERSION + 1):
connection.executescript(_SqlCache.get(f"{ver}_migration.sql", cache=False))
connection.executescript(Sql.migration_text(ver))
logger.debug(f"Database migrated to version {ver}.")
if version < SCHEMA_VERSION:
connection.execute(
"INSERT INTO meta (id, version, ctime) VALUES (1, :version, :ctime) "
"ON CONFLICT (id) DO UPDATE SET version = :version",
{"version": SCHEMA_VERSION, "ctime": int(time.time() * 1_000_000)},
)
connection.executescript(_SqlCache.get("setup_session_script.sql", cache=False))
connection.executescript(Sql.SETUP_SESSION_SCRIPT.text_uncached())


def connect(db_path: Path | str) -> None:
Expand Down Expand Up @@ -419,7 +406,7 @@ def parameters(self) -> Iterator[str | int | bool]:
yield self.golden_notes

def statement(self) -> str:
select_from = _SqlCache.get("select_song_id.sql")
select_from = Sql.SELECT_SONG_ID.text()
where = self._where_clause()
order_by = self._order_by_clause()
return f"{select_from}{where}{order_by}"
Expand Down Expand Up @@ -470,19 +457,17 @@ class SavedSearch:
subscribed: bool = False

def insert(self) -> None:
self.name = (
_DbState.connection()
.execute(
_SqlCache.get("insert_saved_search.sql"),
{
"name": self.name,
"search": self.search.to_json(),
"is_default": self.is_default,
"subscribed": self.subscribed,
},
)
.fetchone()
)[0]
conn = _DbState.connection()
name = conn.execute(
Sql.SELECT_UNIQUE_SEARCH_NAME.text(),
{"new_name": self.name, "old_name": ""},
).fetchone()[0]
conn.execute(
"INSERT INTO saved_search (name, search, is_default, subscribed)"
" VALUES (?, ?, ?, ?)",
(name, self.search.to_json(), self.is_default, self.subscribed),
)
self.name = name

def delete(self) -> None:
_DbState.connection().execute(
Expand Down Expand Up @@ -512,20 +497,19 @@ def get_default(cls) -> SavedSearch | None:
return None

def update(self, new_name: str | None = None) -> None:
self.name = (
_DbState.connection()
.execute(
_SqlCache.get("update_saved_search.sql"),
{
"old_name": self.name,
"new_name": new_name or self.name,
"search": self.search.to_json(),
"is_default": self.is_default,
"subscribed": self.subscribed,
},
)
.fetchone()
)[0]
conn = _DbState.connection()
name = self.name
if new_name:
name = conn.execute(
Sql.SELECT_UNIQUE_SEARCH_NAME.text(),
{"new_name": new_name, "old_name": self.name},
).fetchone()[0]
conn.execute(
"UPDATE saved_search SET name = ?, search = ?, is_default = ?,"
" subscribed = ? WHERE name = ?",
(name, self.search.to_json(), self.is_default, self.subscribed, self.name),
)
self.name = name

@classmethod
def load_saved_searches(
Expand Down Expand Up @@ -596,7 +580,7 @@ def _fts5_start_phrase(text: str) -> str:


def get_usdb_song(song_id: SongId) -> tuple | None:
stmt = f"{_SqlCache.get('select_usdb_song.sql')} WHERE usdb_song.song_id = ?"
stmt = f"{Sql.SELECT_USDB_SONG.text()} WHERE usdb_song.song_id = ?"
return _DbState.connection().execute(stmt, (song_id,)).fetchone()


Expand Down Expand Up @@ -625,22 +609,22 @@ class UsdbSongParams:


def upsert_usdb_song(params: UsdbSongParams) -> None:
stmt = _SqlCache.get("upsert_usdb_song.sql")
stmt = Sql.UPSERT_USDB_SONG.text()
_DbState.connection().execute(stmt, params.__dict__)


def upsert_usdb_songs(params: list[UsdbSongParams]) -> None:
stmt = _SqlCache.get("upsert_usdb_song.sql")
stmt = Sql.UPSERT_USDB_SONG.text()
_DbState.connection().executemany(stmt, (p.__dict__ for p in params))


def set_usdb_song_status(song_id: SongId, status: DownloadStatus) -> None:
stmt = _SqlCache.get("upsert_usdb_song_status.sql")
stmt = Sql.UPSERT_USDB_SONG_STATUS.text()
_DbState.connection().execute(stmt, {"song_id": song_id, "status": status.for_db()})


def set_usdb_song_playing(song_id: SongId, is_playing: bool) -> None:
stmt = _SqlCache.get("upsert_usdb_song_playing.sql")
stmt = Sql.UPSERT_USDB_SONG_PLAYING.text()
_DbState.connection().execute(stmt, {"song_id": song_id, "is_playing": is_playing})


Expand Down Expand Up @@ -835,22 +819,22 @@ def search_usdb_song_creators(search: str) -> set[str]:


def get_in_folder(folder: Path) -> list[tuple]:
stmt = f"{_SqlCache.get('select_sync_meta.sql')} WHERE path GLOB ? || '/*'"
stmt = f"{Sql.SELECT_SYNC_META.text()} WHERE path GLOB ? || '/*'"
return _DbState.connection().execute(stmt, (folder.as_posix(),)).fetchall()


def reset_active_sync_metas(folder: Path) -> None:
_DbState.connection().execute("DELETE FROM active_sync_meta")
params = {"folder": folder.as_posix()}
_DbState.connection().execute(_SqlCache.get("insert_active_sync_metas.sql"), params)
_DbState.connection().execute(Sql.INSERT_ACTIVE_SYNC_METAS.text(), params)


def update_active_sync_metas(folder: Path, song_id: SongId) -> None:
_DbState.connection().execute(
"DELETE FROM active_sync_meta WHERE song_id = ?", (song_id,)
)
params = {"folder": folder.as_posix(), "song_id": song_id}
_DbState.connection().execute(_SqlCache.get("insert_active_sync_meta.sql"), params)
_DbState.connection().execute(Sql.INSERT_ACTIVE_SYNC_META.text(), params)


@attrs.define(frozen=True, slots=False)
Expand All @@ -867,12 +851,12 @@ class SyncMetaParams:


def upsert_sync_meta(params: SyncMetaParams) -> None:
stmt = _SqlCache.get("upsert_sync_meta.sql")
stmt = Sql.UPSERT_SYNC_META.text()
_DbState.connection().execute(stmt, params.__dict__)


def upsert_sync_metas(params: Iterable[SyncMetaParams]) -> None:
stmt = _SqlCache.get("upsert_sync_meta.sql")
stmt = Sql.UPSERT_SYNC_META.text()
_DbState.connection().executemany(stmt, (p.__dict__ for p in params))


Expand Down Expand Up @@ -911,7 +895,7 @@ class CustomMetaDataParams:


def upsert_custom_meta_data(params: Iterable[CustomMetaDataParams]) -> None:
stmt = _SqlCache.get("upsert_custom_meta_data.sql")
stmt = Sql.UPSERT_CUSTOM_META_DATA.text()
_DbState.connection().executemany(stmt, (p.__dict__ for p in params))


Expand Down Expand Up @@ -978,7 +962,7 @@ def delete_resources(ids: Iterable[tuple[SyncMetaId, ResourceKind]]) -> None:


def upsert_resources(params: Iterable[ResourceParams]) -> None:
stmt = _SqlCache.get("upsert_resource.sql")
stmt = Sql.UPSERT_RESOURCE.text()
_DbState.connection().executemany(stmt, (p.__dict__ for p in params))


Expand Down
40 changes: 40 additions & 0 deletions src/usdb_syncer/db/sql/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
"""Data directory for SQL scripts."""

from __future__ import annotations

import enum
import functools
from importlib import resources


class Sql(enum.Enum):
"""Utility class to read and cache SQL files."""

INSERT_ACTIVE_SYNC_META = "insert_active_sync_meta.sql"
INSERT_ACTIVE_SYNC_METAS = "insert_active_sync_metas.sql"
SELECT_SONG_ID = "select_song_id.sql"
SELECT_SYNC_META = "select_sync_meta.sql"
SELECT_UNIQUE_SEARCH_NAME = "select_unique_search_name.sql"
SELECT_USDB_SONG = "select_usdb_song.sql"
SETUP_SESSION_SCRIPT = "setup_session_script.sql"
UPSERT_CUSTOM_META_DATA = "upsert_custom_meta_data.sql"
UPSERT_RESOURCE = "upsert_resource.sql"
UPSERT_SYNC_META = "upsert_sync_meta.sql"
UPSERT_USDB_SONG = "upsert_usdb_song.sql"
UPSERT_USDB_SONG_PLAYING = "upsert_usdb_song_playing.sql"
UPSERT_USDB_SONG_STATUS = "upsert_usdb_song_status.sql"

# size is limited by number of enum variants, so lru checks are redundant
@functools.lru_cache(maxsize=None)
def text(self) -> str:
return self.text_uncached()

def text_uncached(self) -> str:
return resources.files(__package__).joinpath(self.value).read_text("utf8")

@staticmethod
def migration_text(version: int) -> str:
return (
resources.files(__package__)
.joinpath(f"{version}_migration.sql")
.read_text("utf8")
)
32 changes: 0 additions & 32 deletions src/usdb_syncer/db/sql/insert_saved_search.sql

This file was deleted.

28 changes: 28 additions & 0 deletions src/usdb_syncer/db/sql/select_unique_search_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
WITH RECURSIVE candidates AS (
SELECT
:new_name AS name,
0 AS suffix
UNION
ALL
SELECT
:new_name || ' (' || (suffix + 1) || ')',
suffix + 1
FROM
candidates
WHERE
name != :old_name
AND candidates.name IN (
SELECT
name
FROM
saved_search
)
)
SELECT
name
FROM
candidates
ORDER BY
suffix DESC
LIMIT
1;
Loading
Loading