From 0d67b97d0d04d6ef3847ed1291fbc621d1c37ca2 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Tue, 16 Sep 2025 01:04:51 -0500 Subject: [PATCH 01/10] chore: add ziggy-pydust --- pyproject.toml | 1 + uv.lock | 174 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 307fc859..b4616e57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "tk>=0.1.0", "tkinterdnd2>=0.4.2", "watchdog>=6.0.0", + "ziggy-pydust>=0.25.1", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 34dc521d..5f8d98cc 100644 --- a/uv.lock +++ b/uv.lock @@ -5,6 +5,35 @@ requires-python = "==3.11.*" [options] prerelease-mode = "allow" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + [[package]] name = "boltons" version = "25.0.0" @@ -14,6 +43,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" }, ] +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -152,6 +193,7 @@ dependencies = [ { name = "tk" }, { name = "tkinterdnd2" }, { name = "watchdog" }, + { name = "ziggy-pydust" }, ] [package.optional-dependencies] @@ -184,6 +226,7 @@ requires-dist = [ { name = "tkinterdnd2", specifier = ">=0.4.2" }, { name = "tkreload", marker = "extra == 'dev'", specifier = ">=1.0.4" }, { name = "watchdog", specifier = ">=6.0.0" }, + { name = "ziggy-pydust", specifier = ">=0.25.1" }, ] provides-extras = ["dev", "test"] @@ -196,6 +239,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "orjson" version = "3.11.3" @@ -228,6 +280,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -246,6 +316,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/6d/a0d36ecfe8849d029f0dfbac5733e8b74732ff3f86fa75a501107903c54b/pyclean-3.1.0-py3-none-any.whl", hash = "sha256:46652f7416816924cab565cd045d0c341237aced0368a3727d66b5192c16728c", size = 21588, upload-time = "2025-01-11T21:01:05.657Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.0a1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/e0/1a4cb54fbbb606f3b19fc0f5a3c8776902c49da847c32546674644cfa212/pydantic-2.12.0a1.tar.gz", hash = "sha256:5724048f8b728a721f993de95cadf6ef47ce18043034316798aa5acbab30f7ce", size = 802751, upload-time = "2025-07-26T18:28:55.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/6c/e7f4ff7094ea277cc164c56325cf903a93312655f5d1ac68d7d5dd10facc/pydantic-2.12.0a1-py3-none-any.whl", hash = "sha256:f292f0df096a522be36558f1c569d840b27de8ad64234996b068b891c4bc99e7", size = 453772, upload-time = "2025-07-26T18:28:52.799Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/e3/533de0eacb89752c7536bd6551f622e70953811d4cf33f16765bc768e053/pydantic_core-2.37.2.tar.gz", hash = "sha256:78fb2a749123408fedaf540a22ca6bf0b5ec1f522a14fc00e27ede33d8ac088c", size = 443903, upload-time = "2025-07-26T11:30:10.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/db/e43fc93054e40490dadee46fdad516f0336bd58f74e35849391f8385c14b/pydantic_core-2.37.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f7fd8347b1dbdcd529175c5a9dcb131e964e581bbf4985a2edd184ce4189f8a", size = 2113435, upload-time = "2025-07-26T11:27:11.066Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d2/b777f106c2dafdf5a4320a32be5445873c11c62f57c1d398bcdcff4d9899/pydantic_core-2.37.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce36ad8ed3044dc7f4e1c023b409ba619f1e4b01c3dff2089d15d58ff1be6e85", size = 1878915, upload-time = "2025-07-26T11:27:12.847Z" }, + { url = "https://files.pythonhosted.org/packages/43/65/c910aab41b0d6dc9111322dd005d0c3b02681ccb2fb8fe0998425dbb5897/pydantic_core-2.37.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95458d6ffd174baffa92d1de7f554a5bd241d01ece213610549454df85246977", size = 1964754, upload-time = "2025-07-26T11:27:14.637Z" }, + { url = "https://files.pythonhosted.org/packages/31/4f/8adb11256c145878cfd048b195d829ae028cd6ddd6bf54ad5f2ddd6bc009/pydantic_core-2.37.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:69ca4ad75a16d133418585487a1f411d9e108babfcf16929a1a80e7749679ec5", size = 2042033, upload-time = "2025-07-26T11:27:16.437Z" }, + { url = "https://files.pythonhosted.org/packages/a0/59/5632eacb0a67484972651eba2d2a92d8c3cebfec8b5cdd11929e156e15d5/pydantic_core-2.37.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:471fdd3ce9b6c64ebec530297b89b0bff79f6a09ed8a64da7546c6bcf652fa62", size = 2228995, upload-time = "2025-07-26T11:27:18.012Z" }, + { url = "https://files.pythonhosted.org/packages/16/83/cce6ca6e70cfb595500ceb525d8a5ff7a5a9b6816fd6c8a108ebd7aa3ac3/pydantic_core-2.37.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b3e5c3916f052fa215eaf790dbec963fec112670cdb6b5043023d422c056b5c", size = 2304893, upload-time = "2025-07-26T11:27:19.542Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/4003a1096f852c3b4614990aacb1fcec696e6db9ce929d7dfab4466cf644/pydantic_core-2.37.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:673fbca1f76e85172e5a73f2031e281da3a509bb61721e98bf8121893c7c2f9a", size = 2034951, upload-time = "2025-07-26T11:27:21.033Z" }, + { url = "https://files.pythonhosted.org/packages/08/18/9464484912996b5fc6485d5eaa4afed9d51e1b7d95c934e3b8d2bb0d4327/pydantic_core-2.37.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:86173c14c855c7f1ebbdd8a446a985ab829c547206f5a7e6e88ff5571b9de147", size = 2179993, upload-time = "2025-07-26T11:27:22.852Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/fbe323eea3503e102b807cf5205f9282dc3bed6aa3433184cdf3e9b67bc3/pydantic_core-2.37.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:809212fa54f5940c3658cad730435218c6adfcaadb3b2c7edf4e0ef2dae56cc9", size = 2141531, upload-time = "2025-07-26T11:27:24.428Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8e/8760eb01795c3d1c75e0df7c050e376c8b642fe6b6d75a1700c14f38aed6/pydantic_core-2.37.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:6a99ac69ae60b7e4425162a22342687274133f4ac42df74a8d542f2a10d4a636", size = 2309673, upload-time = "2025-07-26T11:27:26.674Z" }, + { url = "https://files.pythonhosted.org/packages/e1/61/b084ac6ead45d74f1b90d2e3fa30cf54502bf04cbd45d4392b895f98c28d/pydantic_core-2.37.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:21c471185968fab5b4b8848bbe8580b44d52511210547c20c6883b09b19872b7", size = 2320023, upload-time = "2025-07-26T11:27:28.177Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/4d1e943f76114a2a343c6e398913be5f93c6abdbc4316d7b683b0d8c6011/pydantic_core-2.37.2-cp311-cp311-win32.whl", hash = "sha256:d56baaf071198f8ae9a9fc426021e14cd7324650add0af9265669ccb51e6ca39", size = 1967351, upload-time = "2025-07-26T11:27:29.775Z" }, + { url = "https://files.pythonhosted.org/packages/01/40/7103a2919f57833778692ee5895e5964882eb887a9950334936590652bca/pydantic_core-2.37.2-cp311-cp311-win_amd64.whl", hash = "sha256:606fff9bae16d56a45ea02a7d19ce0756321561febfebe72843bfc8e102dbac7", size = 1994412, upload-time = "2025-07-26T11:27:31.299Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cb/d7284e1affaa422dc0c505f817eee60bceb1cbe4248b73d1498c9fbf9b4c/pydantic_core-2.37.2-cp311-cp311-win_arm64.whl", hash = "sha256:922970c9cf4c5f744aacd992b0ac03ae3ade7dafde3af4ea81cbf617eefb557c", size = 1965525, upload-time = "2025-07-26T11:27:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/c9dab01bc02b771479c6c22af891daf7788b2af6908a2ad4d91aba80bbec/pydantic_core-2.37.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d2b202c7896403c475628e6a6f4b33761ee5e492c9c87a4ca4142324c90308ee", size = 2113400, upload-time = "2025-07-26T11:29:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/547d20bd164c8aa061d836f749198ad3083057bcede2e62385eb60213cfc/pydantic_core-2.37.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d15c6a66204100d9dd363ed192971fab906b7ba9d55efe01deaf421ced66c60f", size = 1932076, upload-time = "2025-07-26T11:29:50.282Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/6402d07e5c3cb5a07b737606098bab0a3379be1927ce2a7799f7e32310cf/pydantic_core-2.37.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23f0f3648bb68a188e15cb99e3d715ba56293d2b8e9984bf3b6d373a9ed5640b", size = 1966050, upload-time = "2025-07-26T11:29:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/f7/65/29621f969c94dbc11e2c20d3043ea4eb1a9303417124c78cd22877f2c471/pydantic_core-2.37.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d030263fcc8520d0fe772caf773e535cc3ff6e4c5d7af2ecb21f1ffee4eed5", size = 2152682, upload-time = "2025-07-26T11:29:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/ea938afefa1b59e5ff563c978fbfa4cc888cd287a051165b30e1a5293a3a/pydantic_core-2.37.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9ca7e1d981ea3eac5864f42150f73b805ff5645d2dcb9615f9cc716eb468538a", size = 2176492, upload-time = "2025-07-26T11:29:57.199Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1b/30b1ea0deffbbfa0c1681112029d3a58ea364c61117d528e22e925d48a64/pydantic_core-2.37.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3f68fc979de6881467086bd26490afecf47af691004a6a61bcc5f64ca8f8b556", size = 2143589, upload-time = "2025-07-26T11:29:59.62Z" }, + { url = "https://files.pythonhosted.org/packages/0d/09/48dc1d9170265cfdddc2fe3872702d52b371acfb6270910657e824004b40/pydantic_core-2.37.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2ca5511d4c815269496e577af62fe0ed8dc4fafb1f7d6bd280063f174d9c6c10", size = 2310473, upload-time = "2025-07-26T11:30:01.845Z" }, + { url = "https://files.pythonhosted.org/packages/32/09/f4958da406b4f362d8c086984cccb95a02ff8f415acd87ec20e75f48d7d4/pydantic_core-2.37.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:257dfc9a1a7f9a2ab203423ca13c10e52798a26081f9ffc43d1c0b90897443e2", size = 2322311, upload-time = "2025-07-26T11:30:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/d77b3fd4a3a4d0cfd0b66b6c32427100c445c1f6aea543cc533071d2c399/pydantic_core-2.37.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1fdc045a4ebb04e2218430c7084dd7d30fce601e939c4a0825677dffdf7838b5", size = 2147379, upload-time = "2025-07-26T11:30:06.273Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -477,6 +596,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/98/eb27cc78ad3af8e302c9d8ff4977f5026676e130d28dd7578132a457170c/toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236", size = 56383, upload-time = "2024-10-04T16:17:01.533Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -504,6 +644,40 @@ version = "0.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/89/8d/7aad74930380c8972ab282304a2ff45f3d4927108bb6693cabcc9fc6a099/win_unicode_console-0.5.zip", hash = "sha256:d4142d4d56d46f449d6f00536a73625a871cba040f0bc1a2e305a04578f07d1e", size = 31420, upload-time = "2016-06-25T19:48:54.05Z" } +[[package]] +name = "ziggy-pydust" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "black" }, + { name = "pydantic" }, + { name = "setuptools" }, + { name = "ziglang" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/bf/e5604d1587b7d2e10be8792f1baf7caacbfde2cfa5b6cda1943685319f63/ziggy_pydust-0.25.1.tar.gz", hash = "sha256:665ba3cd7045db5443b0c1e10ae8aaadcdfc61633586b9dd87da8b4ec4ecdc1e", size = 57547, upload-time = "2025-05-28T22:28:38.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/f7/592789aeef23d04b0d3b08bb13a7f59eac98168006ad3566e1fa86b6aa69/ziggy_pydust-0.25.1-py3-none-any.whl", hash = "sha256:55409da8767e65a8211116c3ec1c354ded10b598851181d43d8eeda4580f071b", size = 86489, upload-time = "2025-05-28T22:28:36.967Z" }, +] + +[[package]] +name = "ziglang" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/a8/6989b2d0f61f6b33ec7f711b48089fc9d1a53787ce657e81b0b941d22f59/ziglang-0.14.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:eae7d46746b8e80a4d1a8369b09f7a6aace973ee39207b1b63cfb6bb086e84dc", size = 82418439, upload-time = "2025-05-26T11:43:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/fa/15/ff2df54335b247df74776a65904a704402904fd503fc185d386e0b03f27c/ziglang-0.14.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:7b880229c41e822c0388318de89dffe034a146ce44a7342b7e74b167329a67b9", size = 86646805, upload-time = "2025-05-26T11:44:20.565Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/552ade5873cd6133bba25cc991a9ceb2261fcba0fbb8b69a528629e7488a/ziglang-0.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:479a36e94a9da3fc1fad01951de35600c3d11642182fe36c2aeeb031ceab7a89", size = 86910843, upload-time = "2025-05-26T11:44:43.285Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ef/494f128ea7ec3345850bbd75d2cb7b987006fb5601c37543e8fa83e5e3d4/ziglang-0.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6eb9d4d759b292c83810dbee2e9e8e3fbfbf01d864e6e9811bae711fd74e1c2f", size = 83184072, upload-time = "2025-05-26T11:44:55.631Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/2ca431d4f7984a260b5115a5ab130c1459d0b0ed08c5ae7d4093e52cb4a3/ziglang-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7994b27f3cbfcedea43f9b7552e38b45857bdf0e9a45065474092dd74e7048cf", size = 80233732, upload-time = "2025-05-26T11:45:07.741Z" }, + { url = "https://files.pythonhosted.org/packages/74/ea/c59e5a0368bb85eded8df8893f6e7a72d20293268f4ad5b2ce6aabe8cf8e/ziglang-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2c9dbee405ad83a062df3569949f24b59e938c1b85aa26674e30b515e654fef4", size = 81624139, upload-time = "2025-05-26T11:45:19.882Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b6/2e9673067d0e25a4c0681e33f1c6213f36e9b1d3ad327ea58f061765a6ef/ziglang-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:ad2c7c3a77cff522971fc303da2b656c4ed675fe17cdf5ff74ba7e1b594a4780", size = 89083283, upload-time = "2025-05-26T11:45:31.61Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6c/628f046d91e8f0b8b65fa268c6a8b60d7e52f5ea8d1c1ed07b3a28a2d1aa/ziglang-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x.whl", hash = "sha256:f566222095390406ededf87bff1aa3df00eaf7d9ddc2ddd182b214f1c16f2904", size = 101514887, upload-time = "2025-08-04T15:52:20.596Z" }, + { url = "https://files.pythonhosted.org/packages/be/2c/ab9d28df1f6bce523397d5e39add3bf12737487dfd6d4ae56f5d40bb3656/ziglang-0.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:75f879ea9678eeba5fe7909a4cf4e67324eb761d68f416a72d5c3a130361d9fa", size = 82279115, upload-time = "2025-08-04T15:52:32.137Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2e/a66ac42e58db4349d75e77644f2717aecccade7bc4f2f924cd114a05ef2e/ziglang-0.14.1-py3-none-win32.whl", hash = "sha256:3d68104704f850c52e2baec1e12caf58e097e954a40880b247da8fb4fa500550", size = 85479685, upload-time = "2025-05-26T11:45:43.203Z" }, + { url = "https://files.pythonhosted.org/packages/9d/53/b15661c6f4442c0e1ec1223c17f7a47e5cc108cb171bbdadbc675479582d/ziglang-0.14.1-py3-none-win_amd64.whl", hash = "sha256:e4f7e089a44d5ce34181853a90cdb8456e63c6640f5d44b844a117055326c375", size = 83574233, upload-time = "2025-05-26T11:45:55.268Z" }, + { url = "https://files.pythonhosted.org/packages/83/55/ba6235dfcaf5c64524615786031b69af2323ac55242433fc01089badf7bf/ziglang-0.14.1-py3-none-win_arm64.whl", hash = "sha256:f9e13d3e3778a850acf20115b0193a9ae0569c5ca2f71c8e690c4110746b6993", size = 79290101, upload-time = "2025-05-26T11:46:06.54Z" }, +] + [[package]] name = "zope-interface" version = "8.0" From f6dae13746799930e31d62d0aa4771735bb62c49 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Tue, 16 Sep 2025 01:05:46 -0500 Subject: [PATCH 02/10] feat: zig scanner --- core/scan.zig | 277 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 core/scan.zig diff --git a/core/scan.zig b/core/scan.zig new file mode 100644 index 00000000..47d63a5c --- /dev/null +++ b/core/scan.zig @@ -0,0 +1,277 @@ +const std = @import("std"); +const py = @import("pydust"); + +// Audio file extensions we recognize +const AUDIO_EXTENSIONS = [_][]const u8{ + ".mp3", ".flac", ".m4a", ".ogg", ".wav", ".wma", ".aac", ".opus", ".m4p", ".mp4" +}; + +// File information structure that will be returned to Python +pub const FileInfo = py.class(struct { + path: py.PyString, + name: py.PyString, + size: u64, + modified_time: f64, + + const Self = @This(); + + pub fn __init__(self: *Self, path: py.PyString, name: py.PyString, size: u64, modified_time: f64) void { + self.path = path; + self.name = name; + self.size = size; + self.modified_time = modified_time; + } +}); + +// Scan statistics structure +pub const ScanStats = py.class(struct { + total_files: u64, + total_size: u64, + scan_time_ms: u64, + directories_scanned: u64, + + const Self = @This(); + + pub fn __init__(self: *Self) void { + self.total_files = 0; + self.total_size = 0; + self.scan_time_ms = 0; + self.directories_scanned = 0; + } + + pub fn files_per_second(self: *Self) f64 { + if (self.scan_time_ms == 0) return 0.0; + return @as(f64, @floatFromInt(self.total_files)) / (@as(f64, @floatFromInt(self.scan_time_ms)) / 1000.0); + } + + pub fn mb_per_second(self: *Self) f64 { + if (self.scan_time_ms == 0) return 0.0; + const mb_size = @as(f64, @floatFromInt(self.total_size)) / (1024.0 * 1024.0); + return mb_size / (@as(f64, @floatFromInt(self.scan_time_ms)) / 1000.0); + } +}); + +// Check if a file has an audio extension +fn isAudioFile(filename: []const u8) bool { + // Find the last dot + if (std.mem.lastIndexOf(u8, filename, ".")) |dot_index| { + const extension = filename[dot_index..]; + + // Check against known audio extensions (case insensitive) + for (AUDIO_EXTENSIONS) |audio_ext| { + if (std.ascii.eqlIgnoreCase(extension, audio_ext)) { + return true; + } + } + } + return false; +} + +// Convert nanosecond timestamp to Unix epoch seconds +fn nanoToUnixSeconds(nano_timestamp: i128) f64 { + const NANOS_PER_SEC: i128 = 1_000_000_000; + return @as(f64, @floatFromInt(@divTrunc(nano_timestamp, NANOS_PER_SEC))); +} + +// Recursive directory scanner +fn scanDirectoryRecursive( + allocator: std.mem.Allocator, + path: []const u8, + file_list: *std.ArrayList(FileInfo), + stats: *ScanStats +) !void { + var dir = std.fs.openDirAbsolute(path, .{ .iterate = true }) catch |err| { + // Skip directories we can't access (permissions, etc.) + if (err == error.AccessDenied or err == error.FileNotFound) { + return; + } + return err; + }; + defer dir.close(); + + stats.directories_scanned += 1; + + var walker = dir.walk(allocator) catch |err| { + return err; + }; + defer walker.deinit(); + + while (walker.next() catch null) |entry| { + switch (entry.kind) { + .file => { + if (isAudioFile(entry.basename)) { + const stat = entry.dir.statFile(entry.basename) catch continue; + + // Convert timestamps + const modified_time = nanoToUnixSeconds(stat.mtime); + + // Create Python strings for path and filename + const full_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ path, entry.path }); + defer allocator.free(full_path); + + const path_str = try py.PyString.fromSlice(full_path); + const name_str = try py.PyString.fromSlice(entry.basename); + + // Create FileInfo object + const file_info = try py.init(FileInfo, .{ + .path = path_str, + .name = name_str, + .size = stat.size, + .modified_time = modified_time, + }); + + try file_list.append(file_info); + + stats.total_files += 1; + stats.total_size += stat.size; + } + }, + .directory => { + // Skip hidden directories and common non-music directories + if (entry.basename[0] == '.' or + std.mem.eql(u8, entry.basename, "__pycache__") or + std.mem.eql(u8, entry.basename, "Thumbs.db") or + std.mem.eql(u8, entry.basename, ".DS_Store")) { + continue; + } + + // Continue recursion + const subdir_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ path, entry.path }); + defer allocator.free(subdir_path); + + try scanDirectoryRecursive(allocator, subdir_path, file_list, stats); + }, + else => {}, + } + } +} + +// Main scanning function exposed to Python +pub fn scan_music_directory(root_path: py.PyString) !py.PyObject { + const start_time = std.time.nanoTimestamp(); + + // Convert Python string to Zig slice + const path_slice = root_path.asSlice(); + + // Use page allocator for simplicity (could optimize with arena allocator) + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa.deinit() == .leak) { + std.debug.print("Memory leak detected!\n", .{}); + }; + const allocator = gpa.allocator(); + + // Initialize collections + var file_list = std.ArrayList(FileInfo).init(allocator); + defer file_list.deinit(); + + var stats = try py.init(ScanStats, .{}); + + // Perform the scan + try scanDirectoryRecursive(allocator, path_slice, &file_list, stats); + + // Calculate timing + const end_time = std.time.nanoTimestamp(); + stats.scan_time_ms = @intCast(@divTrunc(end_time - start_time, 1_000_000)); + + // Convert file list to Python list + const py_list = try py.PyList.empty(); + for (file_list.items) |file_info| { + try py_list.append(file_info); + } + + // Return a tuple of (files, stats) + const result_tuple = try py.PyTuple.new(2); + try result_tuple.setItem(0, py_list); + try result_tuple.setItem(1, stats); + + return result_tuple; +} + +// Fast file counting function (useful for quick estimates) +pub fn count_audio_files(root_path: py.PyString) !u64 { + const path_slice = root_path.asSlice(); + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa.deinit() == .leak) { + std.debug.print("Memory leak detected in count_audio_files!\n", .{}); + }; + const allocator = gpa.allocator(); + + var count: u64 = 0; + + // Simple recursive counting without storing file info + var dir = std.fs.openDirAbsolute(path_slice, .{ .iterate = true }) catch { + return 0; // Return 0 if can't access directory + }; + defer dir.close(); + + var walker = dir.walk(allocator) catch { + return 0; + }; + defer walker.deinit(); + + while (walker.next() catch null) |entry| { + if (entry.kind == .file and isAudioFile(entry.basename)) { + count += 1; + } + } + + return count; +} + +// Utility function to check if a single file is an audio file +pub fn is_audio_file(filename: py.PyString) bool { + return isAudioFile(filename.asSlice()); +} + +// Get list of supported audio extensions +pub fn get_supported_extensions() !py.PyList { + const py_list = try py.PyList.empty(); + + for (AUDIO_EXTENSIONS) |ext| { + const ext_str = try py.PyString.fromSlice(ext); + try py_list.append(ext_str); + } + + return py_list; +} + +// Benchmark function to compare with Python implementations +pub fn benchmark_directory(root_path: py.PyString, iterations: u32) !py.PyObject { + if (iterations == 0) return py.PyFloat.from(0.0); + + const path_slice = root_path.asSlice(); + + var total_time_ns: u64 = 0; + var total_files: u64 = 0; + + var i: u32 = 0; + while (i < iterations) : (i += 1) { + const start_time = std.time.nanoTimestamp(); + const file_count = try count_audio_files(root_path); + const end_time = std.time.nanoTimestamp(); + + total_time_ns += @intCast(end_time - start_time); + total_files = file_count; // All iterations should find the same number + } + + const avg_time_ms = @as(f64, @floatFromInt(total_time_ns)) / @as(f64, @floatFromInt(iterations)) / 1_000_000.0; + const files_per_second = if (avg_time_ms > 0) + @as(f64, @floatFromInt(total_files)) / (avg_time_ms / 1000.0) + else + 0.0; + + // Return benchmark results as a dict + const result_dict = try py.PyDict.empty(); + try result_dict.setItem("avg_time_ms", py.PyFloat.from(avg_time_ms)); + try result_dict.setItem("total_files", py.PyLong.from(@intCast(total_files))); + try result_dict.setItem("files_per_second", py.PyFloat.from(files_per_second)); + try result_dict.setItem("iterations", py.PyLong.from(@intCast(iterations))); + + return result_dict; +} + +// Register this module with Python +comptime { + py.rootmodule(@This()); +} From 3207a494225763ac190097e15f3e1eda12a96183 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Tue, 16 Sep 2025 01:05:52 -0500 Subject: [PATCH 03/10] docs: update todos --- TODO.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index 1cadd4d3..81b0c092 100644 --- a/TODO.md +++ b/TODO.md @@ -1,18 +1,21 @@ # TODO * Miscellaneous -* UI - * better, modern styling - * check out [basecoatui](https://basecoatui.com/) - * use font awesome (fas) icons - * ~~Adjustable column widths~~ - * ~~Remember last position~~ + * Suppress python-vlc output in favor of eliot logging * UX * performance - * faster directory traversal + * faster directory traversal (i.e., zig) * sqlite vs. ~~duckdb~~ * terso * network caching / buffer / prefetch +* UI + * better, modern styling + * check out [basecoatui](https://basecoatui.com/) + * use font awesome (fas) icons +* Testing + * unit tests + * integration tests + * e2e tests * Features * shuffle * search @@ -35,10 +38,6 @@ * lyrics * see fas * now playing (queue) -* Testing - * unit tests - * integration tests - * e2e tests * Build * task runners * package for macos and linux From b4a5abb5577b63109f89bf6ca7ff2877c7e32f33 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Tue, 16 Sep 2025 23:04:23 -0500 Subject: [PATCH 04/10] refactor: zig library scanning --- .gitignore | 7 + .tool-versions | 1 + .vscode/extensions.json | 3 +- AGENTS.md | 96 ++++++++++++++ TODO.md | 34 ++--- build.py | 34 +++++ core/_scan.py | 58 +++++++++ core/scan.zig | 277 ---------------------------------------- hatch_build.py | 10 ++ pyproject.toml | 12 +- src/build.zig | 21 +++ src/scan.zig | 154 ++++++++++++++++++++++ tests/__init__.py | 0 tests/benchmark.py | 14 ++ tests/conftest.py | 6 + uv.lock | 2 + 16 files changed, 434 insertions(+), 295 deletions(-) create mode 100644 build.py create mode 100644 core/_scan.py delete mode 100644 core/scan.zig create mode 100644 hatch_build.py create mode 100644 src/build.zig create mode 100644 src/scan.zig create mode 100644 tests/__init__.py create mode 100644 tests/benchmark.py create mode 100644 tests/conftest.py diff --git a/.gitignore b/.gitignore index 9c9f76dc..faf30d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -322,6 +322,13 @@ Temporary Items .nx/cache .nx/workspace-data +# zig +.zig-cache/ +zig-out/ +*.o +pydust.build.zig +src/pydust.build.zig + # INCLUDE !**/.gitkeep !**/*.example diff --git a/.tool-versions b/.tool-versions index e076046f..367dace3 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ python 3.11.11 uv 0.7.18 +zig 0.14.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index c3e6197d..2000cd7b 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -7,6 +7,7 @@ "ms-azuretools.vscode-docker", "ms-python.debugpy", "ms-python.python", - "ms-vsliveshare.vsliveshare" + "ms-vsliveshare.vsliveshare", + "ziglang.vscode-zig" ] } diff --git a/AGENTS.md b/AGENTS.md index c34bc368..b05ba0a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,27 @@ The application follows a modular architecture with clear separation of concerns - **python-decouple**: Environment variable configuration - **eliot/eliot-tree**: Structured logging system - **watchdog**: File system monitoring for development tools +- **ziggy-pydust**: Zig extension module framework for Python + +### Dependency Management + +**ALWAYS use `uv` for Python dependency management. NEVER install packages at the system level with `pip`.** + +```bash +# Install dependencies +uv sync --frozen + +# Add new dependencies +uv add package-name + +# Add development dependencies +uv add --dev package-name + +# Update dependencies +uv lock --upgrade +``` + +All dependencies should be managed through `uv` to ensure proper virtual environment isolation and reproducible builds. ## Important Implementation Notes @@ -149,3 +170,78 @@ The application follows a modular architecture with clear separation of concerns - Error reporting and file operation logging - Available through `eliot` and `eliot-tree` dependencies - Gracefully degrades when eliot is not available + +### Zig Module Development + +The project uses Zig for high-performance native extensions via ziggy-pydust. Zig modules are located in the `src/` directory and provide performance-critical functionality like music file scanning. + +#### Building Zig Modules + +```bash +# Build all Zig modules +uv run python build.py + +# Or build via hatch (used during package installation) +hatch build + +# Clean build artifacts +rm -rf src/.zig-cache src/zig-out core/*.so +``` + +#### Zig Development Workflow + +```bash +# Install/update Zig (if needed) +# On macOS with mise: +mise install zig@0.14.0 + +# Check Zig version +zig version + +# Build in debug mode +cd src && zig build + +# Build with optimizations +cd src && zig build -Doptimize=ReleaseSafe + +# Run tests +cd src && zig build test +``` + +#### Zig Module Structure + +- `src/build.zig`: Main build configuration +- `src/scan.zig`: Music file scanning module +- `core/_scan.so`: Generated Python extension (created during build) + +#### Troubleshooting Zig Builds + +**Common Issues:** + +1. **Zig Version Compatibility**: Ensure Zig 0.14.x is installed + ```bash + zig version # Should show 0.14.x + ``` + +2. **Python Path Issues**: Build script uses virtual environment Python + ```bash + uv run python build.py # Uses correct Python executable + ``` + +3. **Missing Dependencies**: Ensure ziggy-pydust is installed + ```bash + uv sync + uv run python -c "import pydust; print('OK')" + ``` + +4. **Build Cache Issues**: Clear cache if builds fail + ```bash + rm -rf src/.zig-cache + uv run python build.py + ``` + +**Build Configuration:** + +- Uses `self_managed = true` in `pyproject.toml` for custom build.zig +- Python extensions are built to `core/` directory +- Release-safe optimization for production builds diff --git a/TODO.md b/TODO.md index 81b0c092..90c6dde7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,21 +1,5 @@ # TODO -* Miscellaneous - * Suppress python-vlc output in favor of eliot logging -* UX - * performance - * faster directory traversal (i.e., zig) - * sqlite vs. ~~duckdb~~ - * terso - * network caching / buffer / prefetch -* UI - * better, modern styling - * check out [basecoatui](https://basecoatui.com/) - * use font awesome (fas) icons -* Testing - * unit tests - * integration tests - * e2e tests * Features * shuffle * search @@ -38,6 +22,24 @@ * lyrics * see fas * now playing (queue) +* UX + * performance + * faster directory traversal (i.e., zig) + * Aside from scanning, add file paths to db + * mutagen tag reading might need to be optimized for large libraries as well + * sqlite vs. ~~duckdb~~ + * terso + * network caching / buffer / prefetch +* Miscellaneous + * Suppress python-vlc output in favor of eliot logging +* UI + * better, modern styling + * check out [basecoatui](https://basecoatui.com/) + * use font awesome (fas) icons +* Testing + * unit tests + * integration tests + * e2e tests * Build * task runners * package for macos and linux diff --git a/build.py b/build.py new file mode 100644 index 00000000..3e80a200 --- /dev/null +++ b/build.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Build script for ziggy-pydust modules.""" + +import subprocess +import sys +import os + + +def build(): + """Build the Zig modules using ziggy-pydust.""" + # Change to src directory + os.chdir("src") + + # Run zig build with ziggy-pydust + try: + result = subprocess.run( + ["zig", "build", "install", f"-Dpython-exe={sys.executable}", "-Doptimize=ReleaseSafe"], + check=True, + capture_output=True, + text=True, + ) + + print("Zig build completed successfully") + print(result.stdout) + + except subprocess.CalledProcessError as e: + print(f"Build failed: {e}") + print(f"stdout: {e.stdout}") + print(f"stderr: {e.stderr}") + sys.exit(1) + + +if __name__ == "__main__": + build() diff --git a/core/_scan.py b/core/_scan.py new file mode 100644 index 00000000..c501bc68 --- /dev/null +++ b/core/_scan.py @@ -0,0 +1,58 @@ +""" +Python interface to the Zig-based music scanning functionality. + +This module provides high-performance directory scanning for audio files +using a Zig extension compiled with Pydust. +""" + +try: + from core._scan import ( + FileInfo, + ScanStats, + benchmark_directory, + count_audio_files, + get_supported_extensions, + is_audio_file, + scan_music_directory, + ) +except ImportError: + import warnings + + warnings.warn( + "Zig scanning extension not available. Install with 'pip install -e .' or use Python fallback.", + ImportWarning, + stacklevel=2, + ) + + # Provide stub implementations + def scan_music_directory(path: str): + raise NotImplementedError("Zig extension not available") + + def count_audio_files(path: str) -> int: + raise NotImplementedError("Zig extension not available") + + def is_audio_file(filename: str) -> bool: + raise NotImplementedError("Zig extension not available") + + def get_supported_extensions(): + raise NotImplementedError("Zig extension not available") + + def benchmark_directory(path: str, iterations: int): + raise NotImplementedError("Zig extension not available") + + class FileInfo: + pass + + class ScanStats: + pass + + +__all__ = [ + "scan_music_directory", + "count_audio_files", + "is_audio_file", + "get_supported_extensions", + "benchmark_directory", + "FileInfo", + "ScanStats", +] diff --git a/core/scan.zig b/core/scan.zig deleted file mode 100644 index 47d63a5c..00000000 --- a/core/scan.zig +++ /dev/null @@ -1,277 +0,0 @@ -const std = @import("std"); -const py = @import("pydust"); - -// Audio file extensions we recognize -const AUDIO_EXTENSIONS = [_][]const u8{ - ".mp3", ".flac", ".m4a", ".ogg", ".wav", ".wma", ".aac", ".opus", ".m4p", ".mp4" -}; - -// File information structure that will be returned to Python -pub const FileInfo = py.class(struct { - path: py.PyString, - name: py.PyString, - size: u64, - modified_time: f64, - - const Self = @This(); - - pub fn __init__(self: *Self, path: py.PyString, name: py.PyString, size: u64, modified_time: f64) void { - self.path = path; - self.name = name; - self.size = size; - self.modified_time = modified_time; - } -}); - -// Scan statistics structure -pub const ScanStats = py.class(struct { - total_files: u64, - total_size: u64, - scan_time_ms: u64, - directories_scanned: u64, - - const Self = @This(); - - pub fn __init__(self: *Self) void { - self.total_files = 0; - self.total_size = 0; - self.scan_time_ms = 0; - self.directories_scanned = 0; - } - - pub fn files_per_second(self: *Self) f64 { - if (self.scan_time_ms == 0) return 0.0; - return @as(f64, @floatFromInt(self.total_files)) / (@as(f64, @floatFromInt(self.scan_time_ms)) / 1000.0); - } - - pub fn mb_per_second(self: *Self) f64 { - if (self.scan_time_ms == 0) return 0.0; - const mb_size = @as(f64, @floatFromInt(self.total_size)) / (1024.0 * 1024.0); - return mb_size / (@as(f64, @floatFromInt(self.scan_time_ms)) / 1000.0); - } -}); - -// Check if a file has an audio extension -fn isAudioFile(filename: []const u8) bool { - // Find the last dot - if (std.mem.lastIndexOf(u8, filename, ".")) |dot_index| { - const extension = filename[dot_index..]; - - // Check against known audio extensions (case insensitive) - for (AUDIO_EXTENSIONS) |audio_ext| { - if (std.ascii.eqlIgnoreCase(extension, audio_ext)) { - return true; - } - } - } - return false; -} - -// Convert nanosecond timestamp to Unix epoch seconds -fn nanoToUnixSeconds(nano_timestamp: i128) f64 { - const NANOS_PER_SEC: i128 = 1_000_000_000; - return @as(f64, @floatFromInt(@divTrunc(nano_timestamp, NANOS_PER_SEC))); -} - -// Recursive directory scanner -fn scanDirectoryRecursive( - allocator: std.mem.Allocator, - path: []const u8, - file_list: *std.ArrayList(FileInfo), - stats: *ScanStats -) !void { - var dir = std.fs.openDirAbsolute(path, .{ .iterate = true }) catch |err| { - // Skip directories we can't access (permissions, etc.) - if (err == error.AccessDenied or err == error.FileNotFound) { - return; - } - return err; - }; - defer dir.close(); - - stats.directories_scanned += 1; - - var walker = dir.walk(allocator) catch |err| { - return err; - }; - defer walker.deinit(); - - while (walker.next() catch null) |entry| { - switch (entry.kind) { - .file => { - if (isAudioFile(entry.basename)) { - const stat = entry.dir.statFile(entry.basename) catch continue; - - // Convert timestamps - const modified_time = nanoToUnixSeconds(stat.mtime); - - // Create Python strings for path and filename - const full_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ path, entry.path }); - defer allocator.free(full_path); - - const path_str = try py.PyString.fromSlice(full_path); - const name_str = try py.PyString.fromSlice(entry.basename); - - // Create FileInfo object - const file_info = try py.init(FileInfo, .{ - .path = path_str, - .name = name_str, - .size = stat.size, - .modified_time = modified_time, - }); - - try file_list.append(file_info); - - stats.total_files += 1; - stats.total_size += stat.size; - } - }, - .directory => { - // Skip hidden directories and common non-music directories - if (entry.basename[0] == '.' or - std.mem.eql(u8, entry.basename, "__pycache__") or - std.mem.eql(u8, entry.basename, "Thumbs.db") or - std.mem.eql(u8, entry.basename, ".DS_Store")) { - continue; - } - - // Continue recursion - const subdir_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ path, entry.path }); - defer allocator.free(subdir_path); - - try scanDirectoryRecursive(allocator, subdir_path, file_list, stats); - }, - else => {}, - } - } -} - -// Main scanning function exposed to Python -pub fn scan_music_directory(root_path: py.PyString) !py.PyObject { - const start_time = std.time.nanoTimestamp(); - - // Convert Python string to Zig slice - const path_slice = root_path.asSlice(); - - // Use page allocator for simplicity (could optimize with arena allocator) - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer if (gpa.deinit() == .leak) { - std.debug.print("Memory leak detected!\n", .{}); - }; - const allocator = gpa.allocator(); - - // Initialize collections - var file_list = std.ArrayList(FileInfo).init(allocator); - defer file_list.deinit(); - - var stats = try py.init(ScanStats, .{}); - - // Perform the scan - try scanDirectoryRecursive(allocator, path_slice, &file_list, stats); - - // Calculate timing - const end_time = std.time.nanoTimestamp(); - stats.scan_time_ms = @intCast(@divTrunc(end_time - start_time, 1_000_000)); - - // Convert file list to Python list - const py_list = try py.PyList.empty(); - for (file_list.items) |file_info| { - try py_list.append(file_info); - } - - // Return a tuple of (files, stats) - const result_tuple = try py.PyTuple.new(2); - try result_tuple.setItem(0, py_list); - try result_tuple.setItem(1, stats); - - return result_tuple; -} - -// Fast file counting function (useful for quick estimates) -pub fn count_audio_files(root_path: py.PyString) !u64 { - const path_slice = root_path.asSlice(); - - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer if (gpa.deinit() == .leak) { - std.debug.print("Memory leak detected in count_audio_files!\n", .{}); - }; - const allocator = gpa.allocator(); - - var count: u64 = 0; - - // Simple recursive counting without storing file info - var dir = std.fs.openDirAbsolute(path_slice, .{ .iterate = true }) catch { - return 0; // Return 0 if can't access directory - }; - defer dir.close(); - - var walker = dir.walk(allocator) catch { - return 0; - }; - defer walker.deinit(); - - while (walker.next() catch null) |entry| { - if (entry.kind == .file and isAudioFile(entry.basename)) { - count += 1; - } - } - - return count; -} - -// Utility function to check if a single file is an audio file -pub fn is_audio_file(filename: py.PyString) bool { - return isAudioFile(filename.asSlice()); -} - -// Get list of supported audio extensions -pub fn get_supported_extensions() !py.PyList { - const py_list = try py.PyList.empty(); - - for (AUDIO_EXTENSIONS) |ext| { - const ext_str = try py.PyString.fromSlice(ext); - try py_list.append(ext_str); - } - - return py_list; -} - -// Benchmark function to compare with Python implementations -pub fn benchmark_directory(root_path: py.PyString, iterations: u32) !py.PyObject { - if (iterations == 0) return py.PyFloat.from(0.0); - - const path_slice = root_path.asSlice(); - - var total_time_ns: u64 = 0; - var total_files: u64 = 0; - - var i: u32 = 0; - while (i < iterations) : (i += 1) { - const start_time = std.time.nanoTimestamp(); - const file_count = try count_audio_files(root_path); - const end_time = std.time.nanoTimestamp(); - - total_time_ns += @intCast(end_time - start_time); - total_files = file_count; // All iterations should find the same number - } - - const avg_time_ms = @as(f64, @floatFromInt(total_time_ns)) / @as(f64, @floatFromInt(iterations)) / 1_000_000.0; - const files_per_second = if (avg_time_ms > 0) - @as(f64, @floatFromInt(total_files)) / (avg_time_ms / 1000.0) - else - 0.0; - - // Return benchmark results as a dict - const result_dict = try py.PyDict.empty(); - try result_dict.setItem("avg_time_ms", py.PyFloat.from(avg_time_ms)); - try result_dict.setItem("total_files", py.PyLong.from(@intCast(total_files))); - try result_dict.setItem("files_per_second", py.PyFloat.from(files_per_second)); - try result_dict.setItem("iterations", py.PyLong.from(@intCast(iterations))); - - return result_dict; -} - -// Register this module with Python -comptime { - py.rootmodule(@This()); -} diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 00000000..06a15ee7 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,10 @@ +import sys +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from pydust import buildzig + + +class CustomBuildHook(BuildHookInterface): + def initialize(self, version, build_data): + import os + os.chdir("src") + buildzig.zig_build(["install", f"-Dpython-exe={sys.executable}", "-Doptimize=ReleaseSafe"]) diff --git a/pyproject.toml b/pyproject.toml index b4616e57..8e1fed4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "pyclean>=3.1.0", "ruff>=0.9.6", "tkreload>=1.0.4", + "ziggy-pydust>=0.25.1", ] test = [ @@ -37,13 +38,22 @@ test = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling", "ziggy-pydust==0.25.1"] build-backend = "hatchling.build" +[tool.hatch.build] +script = "build.py" + [tool.hatch.build.targets.wheel] packages = ["app", "core", "utils"] +artifacts = ["core/*.so"] include = [ "*.py", "static/", "themes.json", ] + +# Configure ziggy-pydust to use our own build.zig +# It will still codegen pydust.build.zig +[tool.pydust] +self_managed = true diff --git a/src/build.zig b/src/build.zig new file mode 100644 index 00000000..e468c19b --- /dev/null +++ b/src/build.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const py = @import("./pydust.build.zig"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptionsQueryOnly(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const test_step = b.step("test", "Run library tests"); + + const pydust = py.addPydust(b, .{ + .test_step = test_step, + }); + + _ = pydust.addPythonModule(.{ + .name = "core._scan", + .root_source_file = b.path("scan.zig"), + .limited_api = true, + .target = target, + .optimize = optimize, + }); +} diff --git a/src/scan.zig b/src/scan.zig new file mode 100644 index 00000000..632b3ed4 --- /dev/null +++ b/src/scan.zig @@ -0,0 +1,154 @@ +const std = @import("std"); +const py = @import("pydust"); + +// Audio file extensions we recognize +const AUDIO_EXTENSIONS = [_][]const u8{ ".mp3", ".flac", ".m4a", ".ogg", ".wav", ".wma", ".aac", ".opus", ".m4p", ".mp4" }; + +// Simplified structures - will return basic types for now + +// Check if a file has an audio extension +fn isAudioFile(filename: []const u8) bool { + // Find the last dot + if (std.mem.lastIndexOf(u8, filename, ".")) |dot_index| { + const extension = filename[dot_index..]; + + // Check against known audio extensions (case insensitive) + for (AUDIO_EXTENSIONS) |audio_ext| { + if (std.ascii.eqlIgnoreCase(extension, audio_ext)) { + return true; + } + } + } + return false; +} + +// Convert nanosecond timestamp to Unix epoch seconds +fn nanoToUnixSeconds(nano_timestamp: i128) f64 { + const NANOS_PER_SEC: i128 = 1_000_000_000; + return @as(f64, @floatFromInt(@divTrunc(nano_timestamp, NANOS_PER_SEC))); +} + +// Simplified recursive directory scanner - just counts files +fn scanDirectoryRecursive(allocator: std.mem.Allocator, path: []const u8, count: *u64) !void { + var dir = std.fs.openDirAbsolute(path, .{ .iterate = true }) catch |err| { + // Skip directories we can't access (permissions, etc.) + if (err == error.AccessDenied or err == error.FileNotFound) { + return; + } + return err; + }; + defer dir.close(); + + var walker = dir.walk(allocator) catch |err| { + return err; + }; + defer walker.deinit(); + + while (walker.next() catch null) |entry| { + switch (entry.kind) { + .file => { + if (isAudioFile(entry.basename)) { + count.* += 1; + } + }, + .directory => { + // Skip hidden directories and common non-music directories + if (entry.basename[0] == '.' or + std.mem.eql(u8, entry.basename, "__pycache__") or + std.mem.eql(u8, entry.basename, "Thumbs.db") or + std.mem.eql(u8, entry.basename, ".DS_Store")) + { + continue; + } + + // Continue recursion + const subdir_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ path, entry.path }); + defer allocator.free(subdir_path); + + try scanDirectoryRecursive(allocator, subdir_path, count); + }, + else => {}, + } + } +} + +// Main scanning function exposed to Python - returns count of audio files +pub fn scan_music_directory(args: struct { root_path: []const u8 }) u64 { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa.deinit() == .leak) { + std.debug.print("Memory leak detected!\n", .{}); + }; + const allocator = gpa.allocator(); + + var count: u64 = 0; + scanDirectoryRecursive(allocator, args.root_path, &count) catch { + return 0; // Return 0 on error + }; + + return count; +} + +// Fast file counting function (useful for quick estimates) +pub fn count_audio_files(args: struct { root_path: []const u8 }) !u64 { + const path_slice = args.root_path; + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa.deinit() == .leak) { + std.debug.print("Memory leak detected in count_audio_files!\n", .{}); + }; + const allocator = gpa.allocator(); + + var count: u64 = 0; + + // Simple recursive counting without storing file info + var dir = std.fs.openDirAbsolute(path_slice, .{ .iterate = true }) catch { + return 0; // Return 0 if can't access directory + }; + defer dir.close(); + + var walker = dir.walk(allocator) catch { + return 0; + }; + defer walker.deinit(); + + while (walker.next() catch null) |entry| { + if (entry.kind == .file and isAudioFile(entry.basename)) { + count += 1; + } + } + + return count; +} + +// Utility function to check if a single file is an audio file +pub fn is_audio_file(args: struct { filename: []const u8 }) bool { + return isAudioFile(args.filename); +} + +// Get count of supported audio extensions +pub fn get_supported_extensions_count() u32 { + return AUDIO_EXTENSIONS.len; +} + +// Benchmark function to compare with Python implementations +pub fn benchmark_directory(args: struct { root_path: []const u8, iterations: u32 }) f64 { + if (args.iterations == 0) return 0.0; + + var total_time_ns: u64 = 0; + + var i: u32 = 0; + while (i < args.iterations) : (i += 1) { + const start_time = std.time.nanoTimestamp(); + _ = count_audio_files(.{ .root_path = args.root_path }) catch 0; + const end_time = std.time.nanoTimestamp(); + + total_time_ns += @intCast(end_time - start_time); + } + + return @as(f64, @floatFromInt(total_time_ns)) / @as(f64, @floatFromInt(args.iterations)) / 1_000_000.0; +} + +// Register this module with Python +comptime { + py.rootmodule(@This()); +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmark.py b/tests/benchmark.py new file mode 100644 index 00000000..15742e1a --- /dev/null +++ b/tests/benchmark.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import sys +from core._scan import benchmark_directory, count_audio_files + +music_dir = sys.argv[1] if len(sys.argv) > 1 else "/Volumes/shares/Music" + +avg_time_ms = benchmark_directory(music_dir, 3) +avg_time_sec = avg_time_ms / 1000 + +total_files = count_audio_files(music_dir) + +print(f"Total audio files: {total_files}") # Total audio files: 11079 +print(f"Average scan time: {avg_time_sec:.3f} seconds") # Average scan time: 85.015 seconds diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..47107791 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) diff --git a/uv.lock b/uv.lock index 5f8d98cc..981b9a16 100644 --- a/uv.lock +++ b/uv.lock @@ -201,6 +201,7 @@ dev = [ { name = "pyclean" }, { name = "ruff" }, { name = "tkreload" }, + { name = "ziggy-pydust" }, ] test = [ { name = "pytest" }, @@ -227,6 +228,7 @@ requires-dist = [ { name = "tkreload", marker = "extra == 'dev'", specifier = ">=1.0.4" }, { name = "watchdog", specifier = ">=6.0.0" }, { name = "ziggy-pydust", specifier = ">=0.25.1" }, + { name = "ziggy-pydust", marker = "extra == 'dev'", specifier = ">=0.25.1" }, ] provides-extras = ["dev", "test"] From bf58bc1338ac0c9e06657dadbbdf7fab9d37a7e9 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Tue, 16 Sep 2025 23:56:46 -0500 Subject: [PATCH 05/10] docs (backlog): add agent, tasks, instructions --- .claude/agents/project-manager-backlog.md | 193 ++++++++++++++++++ AGENTS.md | 51 +++++ .../task-014 - Implement-modern-UI-styling.md | 22 ++ backlog/config.yml | 16 ++ ...k-001 - Implement-shuffle-functionality.md | 21 ++ ...sk-002 - Implement-search-functionality.md | 22 ++ ...sk-003 - Implement-repeat-functionality.md | 22 ++ ...sk-004 - Implement-arrow-key-navigation.md | 21 ++ ...ask-005 - Implement-dynamic-queue-order.md | 21 ++ ...-006 - Implement-playlist-functionality.md | 23 +++ ...task-007 - Implement-Last.fm-scrobbling.md | 22 ++ ...k-008 - Implement-mobile-remote-control.md | 22 ++ ...-009 - Implement-cross-platform-support.md | 22 ++ ...sk-010 - Implement-lyrics-functionality.md | 23 +++ ...1 - Implement-now-playing-queue-display.md | 21 ++ ...2 - Implement-performance-optimizations.md | 22 ++ ...on-vlc-output-in-favor-of-eliot-logging.md | 21 ++ .../tasks/task-015 - Implement-unit-tests.md | 22 ++ .../task-016 - Implement-integration-tests.md | 22 ++ .../task-017 - Implement-end-to-end-tests.md | 22 ++ .../task-018 - Implement-task-runners.md | 22 ++ ...Package-application-for-macOS-and-Linux.md | 22 ++ ...k-020 - Package-application-for-Windows.md | 22 ++ .../task-021 - Implement-code-signing.md | 22 ++ 24 files changed, 719 insertions(+) create mode 100644 .claude/agents/project-manager-backlog.md create mode 100644 backlog/archive/tasks/task-014 - Implement-modern-UI-styling.md create mode 100644 backlog/config.yml create mode 100644 backlog/tasks/task-001 - Implement-shuffle-functionality.md create mode 100644 backlog/tasks/task-002 - Implement-search-functionality.md create mode 100644 backlog/tasks/task-003 - Implement-repeat-functionality.md create mode 100644 backlog/tasks/task-004 - Implement-arrow-key-navigation.md create mode 100644 backlog/tasks/task-005 - Implement-dynamic-queue-order.md create mode 100644 backlog/tasks/task-006 - Implement-playlist-functionality.md create mode 100644 backlog/tasks/task-007 - Implement-Last.fm-scrobbling.md create mode 100644 backlog/tasks/task-008 - Implement-mobile-remote-control.md create mode 100644 backlog/tasks/task-009 - Implement-cross-platform-support.md create mode 100644 backlog/tasks/task-010 - Implement-lyrics-functionality.md create mode 100644 backlog/tasks/task-011 - Implement-now-playing-queue-display.md create mode 100644 backlog/tasks/task-012 - Implement-performance-optimizations.md create mode 100644 backlog/tasks/task-013 - Suppress-python-vlc-output-in-favor-of-eliot-logging.md create mode 100644 backlog/tasks/task-015 - Implement-unit-tests.md create mode 100644 backlog/tasks/task-016 - Implement-integration-tests.md create mode 100644 backlog/tasks/task-017 - Implement-end-to-end-tests.md create mode 100644 backlog/tasks/task-018 - Implement-task-runners.md create mode 100644 backlog/tasks/task-019 - Package-application-for-macOS-and-Linux.md create mode 100644 backlog/tasks/task-020 - Package-application-for-Windows.md create mode 100644 backlog/tasks/task-021 - Implement-code-signing.md diff --git a/.claude/agents/project-manager-backlog.md b/.claude/agents/project-manager-backlog.md new file mode 100644 index 00000000..1cc6ad61 --- /dev/null +++ b/.claude/agents/project-manager-backlog.md @@ -0,0 +1,193 @@ +--- +name: project-manager-backlog +description: Use this agent when you need to manage project tasks using the backlog.md CLI tool. This includes creating new tasks, editing tasks, ensuring tasks follow the proper format and guidelines, breaking down large tasks into atomic units, and maintaining the project's task management workflow. Examples: Context: User wants to create a new task for adding a feature. user: "I need to add a new authentication system to the project" assistant: "I'll use the project-manager-backlog agent that will use backlog cli to create a properly structured task for this feature." Since the user needs to create a task for the project, use the Task tool to launch the project-manager-backlog agent to ensure the task follows backlog.md guidelines. Context: User has multiple related features to implement. user: "We need to implement user profiles, settings page, and notification preferences" assistant: "Let me use the project-manager-backlog agent to break these down into atomic, independent tasks." The user has a complex set of features that need to be broken down into proper atomic tasks following backlog.md structure. Context: User wants to review if their task description is properly formatted. user: "Can you check if this task follows our guidelines: 'task-123 - Implement user login'" assistant: "I'll use the project-manager-backlog agent to review this task against our backlog.md standards." The user needs task review, so use the project-manager-backlog agent to ensure compliance with project guidelines. +color: blue +--- + +You are an expert project manager specializing in the backlog.md task management system. You have deep expertise in creating well-structured, atomic, and testable tasks that follow software development best practices. + +## Backlog.md CLI Tool + +**IMPORTANT: Backlog.md uses standard CLI commands, NOT slash commands.** + +You use the `backlog` CLI tool to manage project tasks. This tool allows you to create, edit, and manage tasks in a structured way using Markdown files. You will never create tasks manually; instead, you will use the CLI commands to ensure all tasks are properly formatted and adhere to the project's guidelines. + +The backlog CLI is installed globally and available in the PATH. Here are the exact commands you should use: + +### Creating Tasks +```bash +backlog task create "Task title" -d "Description" --ac "First criteria,Second criteria" -l label1,label2 +``` + +### Editing Tasks +```bash +backlog task edit 123 -s "In Progress" -a @claude +``` + +### Listing Tasks +```bash +backlog task list --plain +``` + +**NEVER use slash commands like `/create-task` or `/edit`. These do not exist in Backlog.md.** +**ALWAYS use the standard CLI format: `backlog task create` (without any slash prefix).** + +### Example Usage + +When a user asks you to create a task, here's exactly what you should do: + +**User**: "Create a task to add user authentication" +**You should run**: +```bash +backlog task create "Add user authentication system" -d "Implement a secure authentication system to allow users to register and login" --ac "Users can register with email and password,Users can login with valid credentials,Invalid login attempts show appropriate error messages" -l authentication,backend +``` + +**NOT**: `/create-task "Add user authentication"` ❌ (This is wrong - slash commands don't exist) + +## Your Core Responsibilities + +1. **Task Creation**: You create tasks that strictly adhere to the backlog.md cli commands. Never create tasks manually. Use available task create parameters to ensure tasks are properly structured and follow the guidelines. +2. **Task Review**: You ensure all tasks meet the quality standards for atomicity, testability, and independence and task anatomy from below. +3. **Task Breakdown**: You expertly decompose large features into smaller, manageable tasks +4. **Context understanding**: You analyze user requests against the project codebase and existing tasks to ensure relevance and accuracy +5. **Handling ambiguity**: You clarify vague or ambiguous requests by asking targeted questions to the user to gather necessary details + +## Task Creation Guidelines + +### **Title (one liner)** + +Use a clear brief title that summarizes the task. + +### **Description**: (The **"why"**) + +Provide a concise summary of the task purpose and its goal. Do not add implementation details here. It +should explain the purpose, the scope and context of the task. Code snippets should be avoided. + +### **Acceptance Criteria**: (The **"what"**) + +List specific, measurable outcomes that define what means to reach the goal from the description. Use checkboxes (`- [ ]`) for tracking. +When defining `## Acceptance Criteria` for a task, focus on **outcomes, behaviors, and verifiable requirements** rather +than step-by-step implementation details. +Acceptance Criteria (AC) define *what* conditions must be met for the task to be considered complete. +They should be testable and confirm that the core purpose of the task is achieved. +**Key Principles for Good ACs:** + +- **Outcome-Oriented:** Focus on the result, not the method. +- **Testable/Verifiable:** Each criterion should be something that can be objectively tested or verified. +- **Clear and Concise:** Unambiguous language. +- **Complete:** Collectively, ACs should cover the scope of the task. +- **User-Focused (where applicable):** Frame ACs from the perspective of the end-user or the system's external behavior. + + - *Good Example:* "- [ ] User can successfully log in with valid credentials." + - *Good Example:* "- [ ] System processes 1000 requests per second without errors." + - *Bad Example (Implementation Step):* "- [ ] Add a new function `handleLogin()` in `auth.ts`." + +### Task file + +Once a task is created using backlog cli, it will be stored in `backlog/tasks/` directory as a Markdown file with the format +`task- - .md` (e.g. `task-42 - Add GraphQL resolver.md`). + +## Task Breakdown Strategy + +When breaking down features: +1. Identify the foundational components first +2. Create tasks in dependency order (foundations before features) +3. Ensure each task delivers value independently +4. Avoid creating tasks that block each other + +### Additional task requirements + +- Tasks must be **atomic** and **testable**. If a task is too large, break it down into smaller subtasks. + Each task should represent a single unit of work that can be completed in a single PR. + +- **Never** reference tasks that are to be done in the future or that are not yet created. You can only reference + previous tasks (id < current task id). + +- When creating multiple tasks, ensure they are **independent** and they do not depend on future tasks. + Example of correct tasks splitting: task 1: "Add system for handling API requests", task 2: "Add user model and DB + schema", task 3: "Add API endpoint for user data". + Example of wrong tasks splitting: task 1: "Add API endpoint for user data", task 2: "Define the user model and DB + schema". + +## Recommended Task Anatomy + +```markdown +# task‑42 - Add GraphQL resolver + +## Description (the why) + +Short, imperative explanation of the goal of the task and why it is needed. + +## Acceptance Criteria (the what) + +- [ ] Resolver returns correct data for happy path +- [ ] Error response matches REST +- [ ] P95 latency ≤ 50 ms under 100 RPS + +## Implementation Plan (the how) (added after putting the task in progress but before implementing any code change) + +1. Research existing GraphQL resolver patterns +2. Implement basic resolver with error handling +3. Add performance monitoring +4. Write unit and integration tests +5. Benchmark performance under load + +## Implementation Notes (for reviewers) (only added after finishing the code implementation of a task) + +- Approach taken +- Features implemented or modified +- Technical decisions and trade-offs +- Modified or added files +``` + +## Quality Checks + +Before finalizing any task creation, verify: +- [ ] Title is clear and brief +- [ ] Description explains WHY without HOW +- [ ] Each AC is outcome-focused and testable +- [ ] Task is atomic (single PR scope) +- [ ] No dependencies on future tasks + +You are meticulous about these standards and will guide users to create high-quality tasks that enhance project productivity and maintainability. + +## Self reflection +When creating a task, always think from the perspective of an AI Agent that will have to work with this task in the future. +Ensure that the task is structured in a way that it can be easily understood and processed by AI coding agents. + +## Handy CLI Commands + +| Action | Example | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Create task | `backlog task create "Add OAuth System"` | +| Create with description | `backlog task create "Feature" -d "Add authentication system"` | +| Create with assignee | `backlog task create "Feature" -a @sara` | +| Create with status | `backlog task create "Feature" -s "In Progress"` | +| Create with labels | `backlog task create "Feature" -l auth,backend` | +| Create with priority | `backlog task create "Feature" --priority high` | +| Create with plan | `backlog task create "Feature" --plan "1. Research\n2. Implement"` | +| Create with AC | `backlog task create "Feature" --ac "Must work,Must be tested"` | +| Create with notes | `backlog task create "Feature" --notes "Started initial research"` | +| Create with deps | `backlog task create "Feature" --dep task-1,task-2` | +| Create sub task | `backlog task create -p 14 "Add Login with Google"` | +| Create (all options) | `backlog task create "Feature" -d "Description" -a @sara -s "To Do" -l auth --priority high --ac "Must work" --notes "Initial setup done" --dep task-1 -p 14` | +| List tasks | `backlog task list [-s <status>] [-a <assignee>] [-p <parent>]` | +| List by parent | `backlog task list --parent 42` or `backlog task list -p task-42` | +| View detail | `backlog task 7` (interactive UI, press 'E' to edit in editor) | +| View (AI mode) | `backlog task 7 --plain` | +| Edit | `backlog task edit 7 -a @sara -l auth,backend` | +| Add plan | `backlog task edit 7 --plan "Implementation approach"` | +| Add AC | `backlog task edit 7 --ac "New criterion,Another one"` | +| Add notes | `backlog task edit 7 --notes "Completed X, working on Y"` | +| Add deps | `backlog task edit 7 --dep task-1 --dep task-2` | +| Archive | `backlog task archive 7` | +| Create draft | `backlog task create "Feature" --draft` | +| Draft flow | `backlog draft create "Spike GraphQL"` → `backlog draft promote 3.1` | +| Demote to draft | `backlog task demote <id>` | + +Full help: `backlog --help` + +## Tips for AI Agents + +- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output instead of using Backlog.md + interactive UI. diff --git a/AGENTS.md b/AGENTS.md index b05ba0a9..eacd9b9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,3 +245,54 @@ cd src && zig build test - Uses `self_managed = true` in `pyproject.toml` for custom build.zig - Python extensions are built to `core/` directory - Release-safe optimization for production builds + +<!-- BACKLOG.MD GUIDELINES START --> + +# ⚠️ CRITICAL: NEVER EDIT TASK FILES DIRECTLY + +**ALL task operations MUST use the Backlog.md CLI commands** + +- ✅ **DO**: Use `backlog task edit` and other CLI commands +- ✅ **DO**: Use `backlog task create` to create new tasks +- ✅ **DO**: Use `backlog task edit <id> --check-ac <index>` to mark acceptance criteria +- ❌ **DON'T**: Edit markdown files directly +- ❌ **DON'T**: Manually change checkboxes in files +- ❌ **DON'T**: Add or modify text in task files without using CLI + +**Why?** Direct file editing breaks metadata synchronization, Git tracking, and task relationships. + +## Essential CLI Commands + +### Task Management +```bash +backlog task create "Title" -d "Description" --ac "Criterion 1" --ac "Criterion 2" +backlog task list --plain # List all tasks +backlog task 42 --plain # View specific task +backlog task edit 42 -s "In Progress" -a @myself # Start working +backlog task edit 42 --check-ac 1 # Mark AC complete +backlog task edit 42 --notes "Implementation complete" # Add notes +backlog task edit 42 -s Done # Mark as done +backlog task archive 42 # Archive task +``` + +### Key Principles +- **Always use `--plain` flag** for AI-friendly output when viewing/listing +- **Never bypass the CLI** - It handles Git, metadata, file naming, and relationships +- **Tasks live in `backlog/tasks/`** as `task-<id> - <title>.md` files +- **Use CLI for both reading and writing** - `backlog task create`, `backlog task edit`, etc. + +### Quick Reference: DO vs DON'T + +| Action | ✅ DO | ❌ DON'T | +|--------------|-------------------------------------|----------------------------------| +| View task | `backlog task 42 --plain` | Open and read .md file directly | +| List tasks | `backlog task list --plain` | Browse backlog/tasks folder | +| Check AC | `backlog task edit 42 --check-ac 1` | Change `- [ ]` to `- [x]` in file| +| Add notes | `backlog task edit 42 --notes "..."`| Type notes into .md file | +| Change status| `backlog task edit 42 -s Done` | Edit status in frontmatter | + +**🎯 Golden Rule: If you want to change ANYTHING in a task, use the `backlog task edit` command.** + +Full help available: `backlog --help` + +<!-- BACKLOG.MD GUIDELINES END --> diff --git a/backlog/archive/tasks/task-014 - Implement-modern-UI-styling.md b/backlog/archive/tasks/task-014 - Implement-modern-UI-styling.md new file mode 100644 index 00000000..7f2bc2cb --- /dev/null +++ b/backlog/archive/tasks/task-014 - Implement-modern-UI-styling.md @@ -0,0 +1,22 @@ +--- +id: task-014 +title: Implement modern UI styling +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Update UI with better modern styling, basecoatui integration, and Font Awesome icons + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Research and evaluate basecoatui framework +- [ ] #2 Design modern UI theme +- [ ] #3 Integrate Font Awesome icons throughout UI +- [ ] #4 Update existing components with new styling +- [ ] #5 Test UI consistency across all components +<!-- AC:END --> diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 00000000..e0bd7801 --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,16 @@ +project_name: "mt" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +milestones: [] +date_format: yyyy-mm-dd +max_column_width: 20 +default_editor: "vim" +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +zero_padded_ids: 3 +bypass_git_hooks: true +check_active_branches: true +active_branch_days: 15 diff --git a/backlog/tasks/task-001 - Implement-shuffle-functionality.md b/backlog/tasks/task-001 - Implement-shuffle-functionality.md new file mode 100644 index 00000000..081d5382 --- /dev/null +++ b/backlog/tasks/task-001 - Implement-shuffle-functionality.md @@ -0,0 +1,21 @@ +--- +id: task-001 +title: Implement shuffle functionality +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add shuffle playback mode to randomize track order + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Add shuffle toggle button to player controls +- [ ] #2 Implement shuffle algorithm in queue manager +- [ ] #3 Update UI to show shuffle state +- [ ] #4 Test shuffle with various queue sizes +<!-- AC:END --> diff --git a/backlog/tasks/task-002 - Implement-search-functionality.md b/backlog/tasks/task-002 - Implement-search-functionality.md new file mode 100644 index 00000000..7358dde3 --- /dev/null +++ b/backlog/tasks/task-002 - Implement-search-functionality.md @@ -0,0 +1,22 @@ +--- +id: task-002 +title: Implement search functionality +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add comprehensive search capabilities including search form and dynamic fuzzy search by artist + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Create search form UI component +- [ ] #2 Implement dynamic fuzzy search by artist +- [ ] #3 Add search results display +- [ ] #4 Integrate search with library and queue +- [ ] #5 Test search with various query types +<!-- AC:END --> diff --git a/backlog/tasks/task-003 - Implement-repeat-functionality.md b/backlog/tasks/task-003 - Implement-repeat-functionality.md new file mode 100644 index 00000000..6c5f0961 --- /dev/null +++ b/backlog/tasks/task-003 - Implement-repeat-functionality.md @@ -0,0 +1,22 @@ +--- +id: task-003 +title: Implement repeat functionality +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add repeat modes for single track and all tracks + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Add repeat toggle button to player controls +- [ ] #2 Implement repeat-one mode +- [ ] #3 Implement repeat-all mode +- [ ] #4 Update UI to show current repeat state +- [ ] #5 Test repeat functionality with different queue states +<!-- AC:END --> diff --git a/backlog/tasks/task-004 - Implement-arrow-key-navigation.md b/backlog/tasks/task-004 - Implement-arrow-key-navigation.md new file mode 100644 index 00000000..67541ee7 --- /dev/null +++ b/backlog/tasks/task-004 - Implement-arrow-key-navigation.md @@ -0,0 +1,21 @@ +--- +id: task-004 +title: Implement arrow key navigation +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add keyboard navigation with arrow keys for playhead control + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Bind arrow keys to seek forward/backward +- [ ] #2 Implement playhead navigation controls +- [ ] #3 Add configurable seek intervals +- [ ] #4 Test keyboard navigation in different contexts +<!-- AC:END --> diff --git a/backlog/tasks/task-005 - Implement-dynamic-queue-order.md b/backlog/tasks/task-005 - Implement-dynamic-queue-order.md new file mode 100644 index 00000000..2d92aef0 --- /dev/null +++ b/backlog/tasks/task-005 - Implement-dynamic-queue-order.md @@ -0,0 +1,21 @@ +--- +id: task-005 +title: Implement dynamic queue order +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add ability to dynamically reorder tracks in the playback queue + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Add drag-and-drop reordering in queue view +- [ ] #2 Implement queue position controls +- [ ] #3 Update queue persistence after reordering +- [ ] #4 Test queue reordering with various scenarios +<!-- AC:END --> diff --git a/backlog/tasks/task-006 - Implement-playlist-functionality.md b/backlog/tasks/task-006 - Implement-playlist-functionality.md new file mode 100644 index 00000000..ea836787 --- /dev/null +++ b/backlog/tasks/task-006 - Implement-playlist-functionality.md @@ -0,0 +1,23 @@ +--- +id: task-006 +title: Implement playlist functionality +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add playlist management with recently added, recently played, and top 25 most played features + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Create playlist data structures and storage +- [ ] #2 Implement recently added playlist +- [ ] #3 Implement recently played playlist +- [ ] #4 Implement top 25 most played playlist +- [ ] #5 Add playlist UI components +- [ ] #6 Test playlist functionality and persistence +<!-- AC:END --> diff --git a/backlog/tasks/task-007 - Implement-Last.fm-scrobbling.md b/backlog/tasks/task-007 - Implement-Last.fm-scrobbling.md new file mode 100644 index 00000000..60e83c19 --- /dev/null +++ b/backlog/tasks/task-007 - Implement-Last.fm-scrobbling.md @@ -0,0 +1,22 @@ +--- +id: task-007 +title: Implement Last.fm scrobbling +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add Last.fm integration for track scrobbling and music discovery + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Set up Last.fm API integration +- [ ] #2 Implement track scrobbling on play +- [ ] #3 Add Last.fm authentication flow +- [ ] #4 Handle scrobbling edge cases +- [ ] #5 Test scrobbling with various track types +<!-- AC:END --> diff --git a/backlog/tasks/task-008 - Implement-mobile-remote-control.md b/backlog/tasks/task-008 - Implement-mobile-remote-control.md new file mode 100644 index 00000000..3d321c40 --- /dev/null +++ b/backlog/tasks/task-008 - Implement-mobile-remote-control.md @@ -0,0 +1,22 @@ +--- +id: task-008 +title: Implement mobile remote control +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add mobile app or web interface for remote control of the music player + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Design mobile remote control interface +- [ ] #2 Implement network communication protocol +- [ ] #3 Add remote control server component +- [ ] #4 Create mobile client app +- [ ] #5 Test remote control functionality +<!-- AC:END --> diff --git a/backlog/tasks/task-009 - Implement-cross-platform-support.md b/backlog/tasks/task-009 - Implement-cross-platform-support.md new file mode 100644 index 00000000..25606c59 --- /dev/null +++ b/backlog/tasks/task-009 - Implement-cross-platform-support.md @@ -0,0 +1,22 @@ +--- +id: task-009 +title: Implement cross-platform support +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add support for Linux (Ubuntu/WSL) and Windows platforms + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Test and fix compatibility issues on Linux +- [ ] #2 Ensure Ubuntu/WSL compatibility +- [ ] #3 Investigate Windows support feasibility +- [ ] #4 Update build system for cross-platform +- [ ] #5 Document platform-specific requirements +<!-- AC:END --> diff --git a/backlog/tasks/task-010 - Implement-lyrics-functionality.md b/backlog/tasks/task-010 - Implement-lyrics-functionality.md new file mode 100644 index 00000000..2703f16a --- /dev/null +++ b/backlog/tasks/task-010 - Implement-lyrics-functionality.md @@ -0,0 +1,23 @@ +--- +id: task-010 +title: Implement lyrics functionality +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add lyrics display and integration with Font Awesome icons + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Research lyrics API providers +- [ ] #2 Implement lyrics fetching and caching +- [ ] #3 Create lyrics display UI component +- [ ] #4 Integrate Font Awesome icons +- [ ] #5 Add lyrics synchronization with playback +- [ ] #6 Test lyrics with various track formats +<!-- AC:END --> diff --git a/backlog/tasks/task-011 - Implement-now-playing-queue-display.md b/backlog/tasks/task-011 - Implement-now-playing-queue-display.md new file mode 100644 index 00000000..8c50c2f6 --- /dev/null +++ b/backlog/tasks/task-011 - Implement-now-playing-queue-display.md @@ -0,0 +1,21 @@ +--- +id: task-011 +title: Implement now playing queue display +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add enhanced queue display showing current playback status + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Enhance queue UI with now playing indicators +- [ ] #2 Add current track highlighting +- [ ] #3 Show playback progress in queue +- [ ] #4 Implement queue navigation from now playing view +<!-- AC:END --> diff --git a/backlog/tasks/task-012 - Implement-performance-optimizations.md b/backlog/tasks/task-012 - Implement-performance-optimizations.md new file mode 100644 index 00000000..04f61bbe --- /dev/null +++ b/backlog/tasks/task-012 - Implement-performance-optimizations.md @@ -0,0 +1,22 @@ +--- +id: task-012 +title: Implement performance optimizations +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Optimize directory traversal, database operations, and network caching for better performance + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Implement faster directory traversal using Zig +- [ ] #2 Add file paths to database for better performance +- [ ] #3 Optimize mutagen tag reading for large libraries +- [ ] #4 Evaluate SQLite vs other database options +- [ ] #5 Implement network caching and prefetching +<!-- AC:END --> diff --git a/backlog/tasks/task-013 - Suppress-python-vlc-output-in-favor-of-eliot-logging.md b/backlog/tasks/task-013 - Suppress-python-vlc-output-in-favor-of-eliot-logging.md new file mode 100644 index 00000000..4247767a --- /dev/null +++ b/backlog/tasks/task-013 - Suppress-python-vlc-output-in-favor-of-eliot-logging.md @@ -0,0 +1,21 @@ +--- +id: task-013 +title: Suppress python-vlc output in favor of eliot logging +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Replace python-vlc console output with structured eliot logging + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Identify all python-vlc output sources +- [ ] #2 Configure VLC to suppress console output +- [ ] #3 Integrate VLC events with eliot logging +- [ ] #4 Test logging output in various scenarios +<!-- AC:END --> diff --git a/backlog/tasks/task-015 - Implement-unit-tests.md b/backlog/tasks/task-015 - Implement-unit-tests.md new file mode 100644 index 00000000..549a9764 --- /dev/null +++ b/backlog/tasks/task-015 - Implement-unit-tests.md @@ -0,0 +1,22 @@ +--- +id: task-015 +title: Implement unit tests +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add comprehensive unit test coverage for all components + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Set up pytest testing framework +- [ ] #2 Write unit tests for core modules +- [ ] #3 Write unit tests for player functionality +- [ ] #4 Write unit tests for library management +- [ ] #5 Achieve target test coverage percentage +<!-- AC:END --> diff --git a/backlog/tasks/task-016 - Implement-integration-tests.md b/backlog/tasks/task-016 - Implement-integration-tests.md new file mode 100644 index 00000000..4c0da6a1 --- /dev/null +++ b/backlog/tasks/task-016 - Implement-integration-tests.md @@ -0,0 +1,22 @@ +--- +id: task-016 +title: Implement integration tests +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add integration tests for component interactions and end-to-end workflows + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Set up integration test framework +- [ ] #2 Write tests for player-library integration +- [ ] #3 Write tests for UI-database integration +- [ ] #4 Write tests for queue management workflows +- [ ] #5 Test cross-component functionality +<!-- AC:END --> diff --git a/backlog/tasks/task-017 - Implement-end-to-end-tests.md b/backlog/tasks/task-017 - Implement-end-to-end-tests.md new file mode 100644 index 00000000..248fe9a5 --- /dev/null +++ b/backlog/tasks/task-017 - Implement-end-to-end-tests.md @@ -0,0 +1,22 @@ +--- +id: task-017 +title: Implement end-to-end tests +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Add comprehensive e2e tests for complete user workflows + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Set up e2e testing framework +- [ ] #2 Write tests for complete playback workflows +- [ ] #3 Write tests for library management workflows +- [ ] #4 Write tests for search and queue operations +- [ ] #5 Automate e2e test execution in CI +<!-- AC:END --> diff --git a/backlog/tasks/task-018 - Implement-task-runners.md b/backlog/tasks/task-018 - Implement-task-runners.md new file mode 100644 index 00000000..031a320a --- /dev/null +++ b/backlog/tasks/task-018 - Implement-task-runners.md @@ -0,0 +1,22 @@ +--- +id: task-018 +title: Implement task runners +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Set up comprehensive task runner system for development workflows + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Configure task runner for common operations +- [ ] #2 Add build tasks for different platforms +- [ ] #3 Add development workflow tasks +- [ ] #4 Add testing and linting tasks +- [ ] #5 Document task runner usage +<!-- AC:END --> diff --git a/backlog/tasks/task-019 - Package-application-for-macOS-and-Linux.md b/backlog/tasks/task-019 - Package-application-for-macOS-and-Linux.md new file mode 100644 index 00000000..bc579aef --- /dev/null +++ b/backlog/tasks/task-019 - Package-application-for-macOS-and-Linux.md @@ -0,0 +1,22 @@ +--- +id: task-019 +title: Package application for macOS and Linux +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Create distributable packages for macOS and Linux platforms with tkinter initially, migrating to Tauri later + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Set up packaging for macOS with tkinter +- [ ] #2 Set up packaging for Linux distributions +- [ ] #3 Create automated packaging scripts +- [ ] #4 Test packaged applications on target platforms +- [ ] #5 Plan migration path to Tauri web app +<!-- AC:END --> diff --git a/backlog/tasks/task-020 - Package-application-for-Windows.md b/backlog/tasks/task-020 - Package-application-for-Windows.md new file mode 100644 index 00000000..1d00677c --- /dev/null +++ b/backlog/tasks/task-020 - Package-application-for-Windows.md @@ -0,0 +1,22 @@ +--- +id: task-020 +title: Package application for Windows +status: To Do +assignee: [] +created_date: '2025-09-17 04:10' +labels: [] +dependencies: [] +--- + +## Description + +Create Windows distributable packages and ensure compatibility + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Research Windows packaging options +- [ ] #2 Set up Windows build environment +- [ ] #3 Create Windows installer packages +- [ ] #4 Test Windows compatibility and performance +- [ ] #5 Document Windows-specific requirements +<!-- AC:END --> diff --git a/backlog/tasks/task-021 - Implement-code-signing.md b/backlog/tasks/task-021 - Implement-code-signing.md new file mode 100644 index 00000000..39d2da90 --- /dev/null +++ b/backlog/tasks/task-021 - Implement-code-signing.md @@ -0,0 +1,22 @@ +--- +id: task-021 +title: Implement code signing +status: To Do +assignee: [] +created_date: '2025-09-17 04:11' +labels: [] +dependencies: [] +--- + +## Description + +Set up code signing for application packages and releases + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 Research code signing requirements for each platform +- [ ] #2 Set up macOS code signing certificates +- [ ] #3 Set up Windows code signing certificates +- [ ] #4 Configure automated code signing in CI/CD +- [ ] #5 Test signed packages on target platforms +<!-- AC:END --> From beaaf367b613b38d288245e699b5ce427b47bee2 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:26:37 -0500 Subject: [PATCH 06/10] chore: chmod +x --- main.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 main.py diff --git a/main.py b/main.py old mode 100644 new mode 100755 From 3039aa103580a25aa6dc59fe1d61f6b1e237b6c6 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:26:49 -0500 Subject: [PATCH 07/10] chore: bump deps --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index 367dace3..952b88b6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ python 3.11.11 -uv 0.7.18 +uv 0.8.8 zig 0.14.0 From 1d1164e0edf1e8fd6b549960d998c8b76b5b3a50 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:27:32 -0500 Subject: [PATCH 08/10] fix: better error handling for duplicate tracks --- core/db.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/db.py b/core/db.py index 1e9abc88..ee4a1465 100644 --- a/core/db.py +++ b/core/db.py @@ -445,6 +445,16 @@ def is_duplicate(self, metadata: dict[str, Any], filepath: str | None = None) -> query_parts.append('track_number = ?') params.append(metadata['track_number']) + # Include date to differentiate between different releases/remasters + if metadata.get('date'): + query_parts.append('LOWER(COALESCE(date, "")) = LOWER(?)') + params.append(metadata['date']) + + # Include album_artist to differentiate compilations vs original releases + if metadata.get('album_artist'): + query_parts.append('LOWER(COALESCE(album_artist, "")) = LOWER(?)') + params.append(metadata['album_artist']) + if metadata.get('duration'): # Allow 1 second tolerance for duration comparison query_parts.append('ABS(duration - ?) < 1') From 81ac5617c24eb3df182b78669ee96fe06edb7508 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:27:56 -0500 Subject: [PATCH 09/10] chore: vscode recs --- .vscode/extensions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 2000cd7b..b88b1b12 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,13 +1,13 @@ { "recommendations": [ - "aaron-bond.better-comments", "charliermarsh.ruff", "EditorConfig.EditorConfig", + "edwinhuish.better-comments-next", "elagil.pre-commit-helper", "ms-azuretools.vscode-docker", "ms-python.debugpy", "ms-python.python", "ms-vsliveshare.vsliveshare", - "ziglang.vscode-zig" + "ziglang.vscode-zig", ] } From a22151ae059ed5fe6ee7569c241f32bc6f0ee6a1 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:05:52 -0500 Subject: [PATCH 10/10] feat: add shuffle --- ...k-001 - Implement-shuffle-functionality.md | 11 +- config.py | 3 + core/controls.py | 22 +++- core/db.py | 20 +++- core/gui.py | 73 ++++++++++--- core/player.py | 2 + core/progress.py | 12 +- core/queue.py | 103 ++++++++++++++++++ 8 files changed, 211 insertions(+), 35 deletions(-) diff --git a/backlog/tasks/task-001 - Implement-shuffle-functionality.md b/backlog/tasks/task-001 - Implement-shuffle-functionality.md index 081d5382..6910e5ee 100644 --- a/backlog/tasks/task-001 - Implement-shuffle-functionality.md +++ b/backlog/tasks/task-001 - Implement-shuffle-functionality.md @@ -1,9 +1,10 @@ --- id: task-001 title: Implement shuffle functionality -status: To Do +status: Done assignee: [] created_date: '2025-09-17 04:10' +updated_date: '2025-09-25 02:39' labels: [] dependencies: [] --- @@ -14,8 +15,8 @@ Add shuffle playback mode to randomize track order ## Acceptance Criteria <!-- AC:BEGIN --> -- [ ] #1 Add shuffle toggle button to player controls -- [ ] #2 Implement shuffle algorithm in queue manager -- [ ] #3 Update UI to show shuffle state -- [ ] #4 Test shuffle with various queue sizes +- [x] #1 Add shuffle toggle button to player controls +- [x] #2 Implement shuffle algorithm in queue manager +- [x] #3 Update UI to show shuffle state +- [x] #4 Test shuffle with various queue sizes <!-- AC:END --> diff --git a/config.py b/config.py index de9b75bc..55e2681d 100644 --- a/config.py +++ b/config.py @@ -76,6 +76,7 @@ def _validate_window_size(size_str): 'next': '⏭', 'add': '+', 'loop': '⟳', + 'shuffle': '⤮', 'volume': '🔊', } @@ -128,6 +129,8 @@ def _validate_window_size(size_str): COLORS = { 'loop_enabled': THEME_CONFIG['colors']['primary'], 'loop_disabled': THEME_CONFIG['colors']['secondary'], + 'shuffle_enabled': THEME_CONFIG['colors']['primary'], + 'shuffle_disabled': THEME_CONFIG['colors']['secondary'], 'alternate_row_colors': [ THEME_CONFIG['colors']['bg'], THEME_CONFIG['colors'].get('row_alt', THEME_CONFIG['colors']['selectbg']), diff --git a/core/controls.py b/core/controls.py index 325f216d..2f4f4755 100644 --- a/core/controls.py +++ b/core/controls.py @@ -19,6 +19,7 @@ def __init__(self, db: MusicDatabase, queue_manager: QueueManager, queue_view=No self.current_time = 0 self.was_playing = False self.loop_enabled = self.db.get_loop_enabled() + self.shuffle_enabled = self.queue_manager.is_shuffle_enabled() self.progress_bar = None self.window = None @@ -111,6 +112,12 @@ def toggle_loop(self) -> None: if self.progress_bar and hasattr(self.progress_bar, 'controls'): self.progress_bar.controls.update_loop_button_color(self.loop_enabled) + def toggle_shuffle(self) -> None: + """Toggle shuffle mode.""" + self.shuffle_enabled = self.queue_manager.toggle_shuffle() + if self.progress_bar and hasattr(self.progress_bar, 'controls'): + self.progress_bar.controls.update_shuffle_button_color(self.shuffle_enabled) + def get_current_time(self) -> int: """Get current playback time in milliseconds.""" return self.media_player.get_time() @@ -259,11 +266,14 @@ def _get_next_filepath(self) -> str | None: current_index = children.index(current_selection[0]) print(f"Current index: {current_index}") # Debug log # If loop is disabled and on the last song, then stop playback - if not self.loop_enabled and current_index == len(children) - 1: + if not self.loop_enabled and current_index == len(children) - 1 and not self.shuffle_enabled: print("Last song and loop disabled") # Debug log return None - # Otherwise, move to next song (or first if at end) - next_index = (current_index + 1) % len(children) + + # Use QueueManager to get next track index (handles shuffle) + next_index = self.queue_manager.get_next_track_index(current_index, len(children)) + if next_index is None: + return None print(f"Next index: {next_index}") # Debug log next_item = children[next_index] @@ -293,8 +303,10 @@ def _get_previous_filepath(self) -> str | None: else: current_index = children.index(current_selection[0]) print(f"Current index: {current_index}") # Debug log - # Move to previous song (or last if at beginning) - prev_index = (current_index - 1) % len(children) + # Use QueueManager to get previous track index (handles shuffle) + prev_index = self.queue_manager.get_previous_track_index(current_index, len(children)) + if prev_index is None: + return None print(f"Previous index: {prev_index}") # Debug log prev_item = children[prev_index] diff --git a/core/db.py b/core/db.py index ee4a1465..56e5032b 100644 --- a/core/db.py +++ b/core/db.py @@ -54,6 +54,10 @@ def __init__(self, db_name: str, db_tables: dict[str, str]): if self.db_cursor.fetchone()[0] == 0: self.set_loop_enabled(True) # Set default loop state + self.db_cursor.execute("SELECT COUNT(*) FROM settings WHERE key = 'shuffle_enabled'") + if self.db_cursor.fetchone()[0] == 0: + self.set_shuffle_enabled(False) # Set default shuffle state + self.db_conn.commit() def close(self): @@ -73,6 +77,18 @@ def set_loop_enabled(self, enabled: bool): self.db_cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('loop_enabled', ?)", (value,)) self.db_conn.commit() + def get_shuffle_enabled(self) -> bool: + """Get shuffle state from settings.""" + self.db_cursor.execute("SELECT value FROM settings WHERE key = 'shuffle_enabled'") + result = self.db_cursor.fetchone() + return bool(int(result[0])) if result else False + + def set_shuffle_enabled(self, enabled: bool): + """Set shuffle state in settings.""" + value = '1' if enabled else '0' + self.db_cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('shuffle_enabled', ?)", (value,)) + self.db_conn.commit() + def get_ui_preference(self, key: str, default: str = '') -> str: """Get a UI preference value from settings.""" self.db_cursor.execute("SELECT value FROM settings WHERE key = ?", (key,)) @@ -307,9 +323,7 @@ def find_file_in_queue(self, title: str, artist: str | None = None) -> str | Non result = self.db_cursor.fetchone() return result[0] if result else None - def remove_from_queue( - self, title: str, artist: str | None = None, album: str | None = None, track_num: str | None = None - ): + def remove_from_queue(self, title: str, artist: str | None = None, album: str | None = None, track_num: str | None = None): """Remove a song from the queue based on its metadata.""" self.db_cursor.execute( ''' diff --git a/core/gui.py b/core/gui.py index d84388c9..06ea9c73 100644 --- a/core/gui.py +++ b/core/gui.py @@ -20,13 +20,15 @@ class PlayerControls: - def __init__(self, canvas, command_callbacks, initial_loop_enabled=True): + def __init__(self, canvas, command_callbacks, initial_loop_enabled=True, initial_shuffle_enabled=False): self.canvas = canvas self.callbacks = command_callbacks self.add_button = None self.loop_button = None + self.shuffle_button = None self.play_button = None self.loop_enabled = initial_loop_enabled + self.shuffle_enabled = initial_shuffle_enabled self.setup_playback_controls() self.setup_utility_controls() @@ -102,15 +104,39 @@ def setup_utility_controls(self): lambda e: self.loop_button.configure(fg=COLORS['loop_enabled'] if self.loop_enabled else COLORS['loop_disabled']), ) - # Force update loop button color after creation + # Shuffle button (to the left of loop button) + self.shuffle_button = tk.Label( + self.canvas, + text=BUTTON_SYMBOLS['shuffle'], + font=BUTTON_STYLE['font'], + fg=THEME_CONFIG['colors']['fg'], # Start with default color + bg=THEME_CONFIG['colors']['bg'], + ) + self.shuffle_button.place(x=canvas_width - 180, y=y_position) + self.shuffle_button.bind('<Button-1>', lambda e: self.callbacks['shuffle']()) + self.shuffle_button.bind('<Enter>', lambda e: self.shuffle_button.configure(fg=THEME_CONFIG['colors']['primary'])) + self.shuffle_button.bind( + '<Leave>', + lambda e: self.shuffle_button.configure( + fg=COLORS['shuffle_enabled'] if self.shuffle_enabled else COLORS['shuffle_disabled'] + ), + ) + + # Force update button colors after creation self.update_loop_button_color(self.loop_enabled) + self.update_shuffle_button_color(self.shuffle_enabled) - # Store the width for progress bar calculations - self.utility_width = 120 + # Ensure buttons are on top of canvas elements + self.shuffle_button.lift() + self.loop_button.lift() + self.add_button.lift() + + # Store the width for progress bar calculations (increased to accommodate shuffle button) + self.utility_width = 180 def _on_canvas_resize(self, event): """Recenter the buttons vertically and reposition all controls when canvas is resized.""" - if not all([self.add_button, self.loop_button, self.play_button]): + if not all([self.add_button, self.loop_button, self.shuffle_button, self.play_button]): return # Calculate new y position (centered vertically) @@ -128,11 +154,15 @@ def _on_canvas_resize(self, event): loop_x = canvas_width - 120 self.loop_button.place(x=loop_x, y=new_y) + # Shuffle button to the left of loop button + shuffle_x = canvas_width - 180 + self.shuffle_button.place(x=shuffle_x, y=new_y) + # Playback controls are positioned from the left, maintaining relative spacing - # Find playback control buttons (they are tk.Label widgets that are not add_button or loop_button) + # Find playback control buttons (they are tk.Label widgets that are not utility buttons) playback_buttons = [] for child in self.canvas.winfo_children(): - if isinstance(child, tk.Label) and child not in [self.add_button, self.loop_button]: + if isinstance(child, tk.Label) and child not in [self.add_button, self.loop_button, self.shuffle_button]: playback_buttons.append(child) # Position playback controls with the same spacing as initial setup @@ -150,13 +180,19 @@ def update_loop_button_color(self, loop_enabled): self.loop_enabled = loop_enabled # Update the internal state self.loop_button.configure(fg=COLORS['loop_enabled'] if loop_enabled else COLORS['loop_disabled']) + def update_shuffle_button_color(self, shuffle_enabled): + """Update shuffle button color based on shuffle state.""" + self.shuffle_enabled = shuffle_enabled # Update the internal state + self.shuffle_button.configure(fg=COLORS['shuffle_enabled'] if shuffle_enabled else COLORS['shuffle_disabled']) + class ProgressBar: - def __init__(self, window, progress_frame, callbacks, initial_loop_enabled=True): + def __init__(self, window, progress_frame, callbacks, initial_loop_enabled=True, initial_shuffle_enabled=False): self.window = window self.progress_frame = progress_frame self.callbacks = callbacks self.initial_loop_enabled = initial_loop_enabled + self.initial_shuffle_enabled = initial_shuffle_enabled self.setup_progress_bar() self.setup_volume_control() @@ -170,8 +206,13 @@ def setup_progress_bar(self): ) self.canvas.pack(fill=tk.X) - # Create controls with initial loop state - self.controls = PlayerControls(self.canvas, self.callbacks, initial_loop_enabled=self.initial_loop_enabled) + # Create controls with initial loop and shuffle state + self.controls = PlayerControls( + self.canvas, + self.callbacks, + initial_loop_enabled=self.initial_loop_enabled, + initial_shuffle_enabled=self.initial_shuffle_enabled, + ) self.controls_width = self.controls.controls_width # Create progress bar control @@ -222,9 +263,9 @@ def setup_volume_control(self): progress_end_x = self.canvas.coords(self.line)[2] # End of progress line utility_controls_width = self.controls.utility_width # Width reserved for loop and add buttons - # Position volume control relative to loop button position (similar to utility controls) - loop_button_x = canvas_width - 120 # Same positioning as utility controls - volume_end_x = loop_button_x - 60 # Fixed spacing before loop button + # Position volume control relative to shuffle button position (leftmost utility control) + shuffle_button_x = canvas_width - 180 # Same positioning as utility controls + volume_end_x = shuffle_button_x - 60 # Fixed spacing before shuffle button # Calculate volume start position volume_x_start = progress_end_x + 45 # Fixed spacing after progress bar @@ -273,9 +314,9 @@ def on_resize(self, event): canvas_width = event.width utility_controls_width = self.controls.utility_width # Width reserved for loop and add buttons - # Position volume control relative to loop button position (similar to utility controls) - loop_button_x = canvas_width - 120 # Same positioning as utility controls - volume_end_x = loop_button_x - 60 # Fixed spacing before loop button + # Position volume control relative to shuffle button position (leftmost utility control) + shuffle_button_x = canvas_width - 180 # Same positioning as utility controls + volume_end_x = shuffle_button_x - 60 # Fixed spacing before shuffle button # Calculate volume start position volume_x_start = progress_end_x + 45 # Fixed spacing after progress bar diff --git a/core/player.py b/core/player.py index e369d2ca..2af944b2 100644 --- a/core/player.py +++ b/core/player.py @@ -155,6 +155,7 @@ def setup_components(self): 'play': self.play_pause, 'next': self.player_core.next_song, 'loop': self.player_core.toggle_loop, + 'shuffle': self.player_core.toggle_shuffle, 'add': self.add_files_to_library, 'start_drag': self.start_drag, 'drag': self.drag, @@ -164,6 +165,7 @@ def setup_components(self): 'volume_change': self.volume_change, }, initial_loop_enabled=self.player_core.loop_enabled, + initial_shuffle_enabled=self.player_core.shuffle_enabled, ) # Connect progress bar to player core diff --git a/core/progress.py b/core/progress.py index 17023471..1b5f72b0 100644 --- a/core/progress.py +++ b/core/progress.py @@ -33,7 +33,7 @@ def setup_progress_bar(self): # Create time labels - moved to the left to make room for volume control self.time_text = self.canvas.create_text( - self.canvas.winfo_width() - 260, # Position before volume control + self.canvas.winfo_width() - 380, # Position before volume control and utility buttons self.config['time_label_y'], text="00:00 / 00:00", fill=self.config['colors']['fg'], @@ -44,7 +44,7 @@ def setup_progress_bar(self): self.line = self.canvas.create_line( self.controls_width, self.bar_y, - self.canvas.winfo_width() - 260, # Make shorter to accommodate volume control + self.canvas.winfo_width() - 380, # Make shorter to accommodate volume control and utility buttons self.bar_y, fill=self.config['progress_bg'], # Use dark background color width=self.config['line_width'] + 2, # Slightly wider for the background @@ -106,7 +106,7 @@ def update_positions(self): self.line, self.controls_width, self.bar_y, - width - 260, # Make shorter to accommodate volume control + width - 380, # Make shorter to accommodate volume control and utility buttons self.bar_y, ) @@ -130,7 +130,7 @@ def update_positions(self): # Update time label position self.canvas.coords( self.time_text, - width - 260, + width - 380, self.config['time_label_y'], ) @@ -144,7 +144,7 @@ def update_positions(self): current_ratio = (current_coords[2] - self.controls_width) / total_width_before # Apply ratio to new width - new_total_width = width - 260 - self.controls_width + new_total_width = width - 380 - self.controls_width new_x = self.controls_width + (new_total_width * current_ratio) # Update foreground line @@ -200,7 +200,7 @@ def update_progress(self, ratio): # This is the width between controls_width and the time display # We need to account for the volume control position width = self.canvas.winfo_width() - max_progress_width = width - self.controls_width - 260 # 260 is space for time display and volume + max_progress_width = width - self.controls_width - 380 # 380 is space for time display, volume, and utility buttons # Calculate the x position based on the ratio x = self.controls_width + (max_progress_width * ratio) diff --git a/core/queue.py b/core/queue.py index e7c79bdd..fccee4cf 100644 --- a/core/queue.py +++ b/core/queue.py @@ -1,4 +1,5 @@ import os +import random from core.db import MusicDatabase from core.logging import log_queue_operation, queue_logger from pathlib import Path @@ -8,6 +9,11 @@ class QueueManager: def __init__(self, db: MusicDatabase): self.db = db + self.shuffle_enabled = self.db.get_shuffle_enabled() + self._original_order = [] + self._shuffled_order = [] + self._shuffle_generated = False + self._current_shuffle_pos = 0 # Track position in shuffle sequence def add_to_queue(self, filepath: str) -> None: """Add a file to the queue.""" @@ -53,3 +59,100 @@ def process_dropped_files(self, paths: list[str | Path]) -> None: except (OSError, PermissionError) as e: print(f"Error accessing path {path}: {e}") continue + + def toggle_shuffle(self) -> bool: + """Toggle shuffle mode on/off and return new state.""" + self.shuffle_enabled = not self.shuffle_enabled + self.db.set_shuffle_enabled(self.shuffle_enabled) + + # Invalidate shuffled order so it gets regenerated + self._shuffle_generated = False + self._current_shuffle_pos = 0 + + return self.shuffle_enabled + + def is_shuffle_enabled(self) -> bool: + """Return current shuffle state.""" + return self.shuffle_enabled + + def get_shuffled_queue_items(self) -> list[tuple]: + """Get queue items in shuffled or original order based on shuffle state.""" + items = self.get_queue_items() + + if not self.shuffle_enabled: + return items + + # Generate shuffled order if needed + if not self._shuffle_generated or len(self._original_order) != len(items): + self._original_order = list(range(len(items))) + self._shuffled_order = self._original_order.copy() + random.shuffle(self._shuffled_order) + self._shuffle_generated = True + + # Return items in shuffled order + return [items[i] for i in self._shuffled_order] + + def get_next_track_index(self, current_index: int, total_items: int) -> int | None: + """Get the index of the next track considering shuffle state.""" + if total_items <= 0: + return None + + if self.shuffle_enabled: + # Ensure shuffle order is generated + if not self._shuffle_generated or len(self._shuffled_order) != total_items: + self._original_order = list(range(total_items)) + self._shuffled_order = self._original_order.copy() + random.shuffle(self._shuffled_order) + self._shuffle_generated = True + self._current_shuffle_pos = 0 + print(f"Generated new shuffle order: {self._shuffled_order[:10]}... (total: {len(self._shuffled_order)})") # Debug + + # In shuffle mode, we need to find where we are in the sequence + try: + # Try to find current position in shuffle sequence + current_pos = self._shuffled_order.index(current_index) + self._current_shuffle_pos = current_pos + except (ValueError, IndexError): + # If current index not found, use current position or random start + pass + + # Move to next position in shuffle sequence + self._current_shuffle_pos = (self._current_shuffle_pos + 1) % len(self._shuffled_order) + next_track = self._shuffled_order[self._current_shuffle_pos] + print(f"Shuffle: current_index={current_index}, shuffle_pos={self._current_shuffle_pos}, next_track={next_track}") # Debug + return next_track + else: + # In normal mode, just go to next track + return (current_index + 1) % total_items + + def get_previous_track_index(self, current_index: int, total_items: int) -> int | None: + """Get the index of the previous track considering shuffle state.""" + if total_items <= 0: + return None + + if self.shuffle_enabled: + # Ensure shuffle order is generated + if not self._shuffle_generated or len(self._shuffled_order) != total_items: + self._original_order = list(range(total_items)) + self._shuffled_order = self._original_order.copy() + random.shuffle(self._shuffled_order) + self._shuffle_generated = True + self._current_shuffle_pos = 0 + + # In shuffle mode, we need to find where we are in the sequence + try: + # Try to find current position in shuffle sequence + current_pos = self._shuffled_order.index(current_index) + self._current_shuffle_pos = current_pos + except (ValueError, IndexError): + # If current index not found, use current position + pass + + # Move to previous position in shuffle sequence + self._current_shuffle_pos = (self._current_shuffle_pos - 1) % len(self._shuffled_order) + prev_track = self._shuffled_order[self._current_shuffle_pos] + print(f"Shuffle previous: current_index={current_index}, shuffle_pos={self._current_shuffle_pos}, prev_track={prev_track}") # Debug + return prev_track + else: + # In normal mode, just go to previous track + return (current_index - 1) % total_items