Compare commits
608 Commits
115969856c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
996d581a07
|
|||
|
ba05a4a7ef
|
|||
|
b07a56082a
|
|||
|
9c4749e49b
|
|||
|
fd744a1a85
|
|||
|
79a6f26c2f
|
|||
|
27933dd495
|
|||
|
3aca43b372
|
|||
|
5ae2817eea
|
|||
|
2420091a4e
|
|||
|
089b144626
|
|||
|
b3772ff484
|
|||
| b4ff618d41 | |||
| 8a05d5ff8f | |||
| a8ebe89cc1 | |||
| 3afe96a8cd | |||
| 8e935c6116 | |||
| 16be4b4afa | |||
| f886125857 | |||
| 8dd8a3c1ad | |||
| 1c885e3fc5 | |||
| 4cc48ae98f | |||
| 288d30ad8b | |||
| ebb9422591 | |||
| 852804ee5c | |||
| 5064b38315 | |||
| dff14ec3e8 | |||
| b40deb0d5f | |||
| 202dd9eaee | |||
| a1d1f87b65 | |||
| c54574224a | |||
| 4bc43174b6 | |||
| 3a63e13639 | |||
| bc3e7fd4fd | |||
| dac298a524 | |||
| 72f9098a70 | |||
| 8c9966000e | |||
| ec18262bba | |||
| 45d3cdbad4 | |||
| 831e66a5b9 | |||
| e76d47412d | |||
| d3c3094880 | |||
| e1a2fe52c6 | |||
| 2aa8c023f4 | |||
| 938cc9be46 | |||
| 9bf22a6b22 | |||
| d581754062 | |||
| 0216f7be71 | |||
| 39320db3a0 | |||
| 3ba021dfbb | |||
| ca176d7844 | |||
| 37777310c9 | |||
| 0c73bfcd98 | |||
| ffa46d4a2a | |||
| 9fd09cd0d0 | |||
| 99ba5d6d8a | |||
| ba07a3300c | |||
| 0d5cbb2df1 | |||
| abef0d8a6d | |||
| abec69fb84 | |||
| d2f96baf63 | |||
| e78b634bee | |||
| 8dc0991619 | |||
| 4ae78b830d | |||
| 5bbecf1e1d | |||
| fe0f8241ff | |||
| bb461898e4 | |||
| a2e11eec06 | |||
| 9856dbf301 | |||
| 3d06c4dba6 | |||
| d8c3ce8fa1 | |||
| d19ac96ff7 | |||
| a512c603ce | |||
| c9217fccb7 | |||
| 57f5450725 | |||
| 405ae07cac | |||
| 3b6ac7ce81 | |||
| cf4c4e37c8 | |||
| fb209351f2 | |||
| 6eb12043e3 | |||
| 717e9b40ae | |||
| ab7d77800a | |||
| a51d8092a8 | |||
| 00fc685e88 | |||
| 9b085aa547 | |||
| 71a68d3947 | |||
| ac5690b9b0 | |||
| 93033d6661 | |||
| e86536d585 | |||
| 7acb3d0853 | |||
| 47dafe565f | |||
| 2495c77d3c | |||
| fda6a4059d | |||
| 99f1adf8eb | |||
| 7bf62c542c | |||
| 7a39c4a4ba | |||
| 437344695d | |||
| f66ea09ba6 | |||
| 1b3d60a1bc | |||
| 7d5aa5da7e | |||
| 4f25b268f7 | |||
| 62b8ede273 | |||
| b048f8ce09 | |||
| fff674a0b8 | |||
| 4a16aad2ac | |||
| 67dc1c83f2 | |||
| b43ed3212c | |||
| 9d1e83f7df | |||
| fe828a5191 | |||
| c4c3e18b1b | |||
| 64c9cf84da | |||
| 9afca46c5d | |||
| 518bd20e54 | |||
| 0078959be9 | |||
| a98dad0a33 | |||
| 7bf4eec9fa | |||
| 86e0121a58 | |||
| 1cfb106ccc | |||
| 4775f827a0 | |||
| c34f33cc7a | |||
| 3de27938d0 | |||
| 51d43e38bf | |||
| 0d8bf820ec | |||
| 6b6394b3b6 | |||
| 026bd45c32 | |||
| 7490f1939b | |||
| a3abd3309f | |||
| a2c16bf793 | |||
| 201d2fc8b6 | |||
| c4e09cf433 | |||
| 79f2613cf7 | |||
| f33122fecd | |||
| 96cade3226 | |||
| 04e043c689 | |||
| d250c1b05f | |||
| d2bc08e937 | |||
| 3d5dfd6d48 | |||
| 9f1381d27e | |||
| c8ecdf8eb6 | |||
| 351504ade9 | |||
| 40069c039a | |||
| b1d0034537 | |||
| 1fbc3a43c2 | |||
| 656a77f943 | |||
| ed1469898c | |||
| a65f0702ac | |||
| cd9e410ea3 | |||
| e86704c5cd | |||
| 49dde84481 | |||
| 0b0e3add3c | |||
| 4f5e30e6cf | |||
| 7e57cd6e46 | |||
| b776b3efa9 | |||
| 030b37c505 | |||
| cfccbceb8e | |||
| ad63cdcca1 | |||
| 5d9054154c | |||
| ac11cbd7bb | |||
| c11a4a0280 | |||
| ce4a034224 | |||
| 536594a56e | |||
| 3ea5fedfa3 | |||
| dab35a1a3a | |||
| cc3a00e1e0 | |||
| bf9e11d08f | |||
| 6c98d729ea | |||
| 2935eb01c1 | |||
| 79a85cf707 | |||
| 7890a94702 | |||
| 36eff02b18 | |||
| 091c6c68c1 | |||
| f9a9dc4879 | |||
| 621d47d701 | |||
| 2d42be1a0c | |||
| ffe64acbeb | |||
| dd727c7aa9 | |||
| 7bc4488635 | |||
| 08c2255fba | |||
| 387621cb24 | |||
| d05ca74db2 | |||
| 38f08543d9 | |||
| e32bb6ace5 | |||
| d41fc149b2 | |||
| 92c5484ffe | |||
| 2e42d26f44 | |||
| c8ff4849fe | |||
| c15d615b06 | |||
| 9a1fb33996 | |||
| 96dadd7e5b | |||
| 4fb9815ac6 | |||
| fa3dc0236f | |||
| ca6b53715e | |||
| ad5f304d4e | |||
| 7efcfaa85b | |||
| de7d7965f2 | |||
| a0df6b273f | |||
| 2e5e40cbf5 | |||
| 32b2b544f4 | |||
| 2aac706ef7 | |||
| f9eace71e0 | |||
| acda70150f | |||
| 99d3b6ae17 | |||
| 065c5c9c36 | |||
| 0dffa8fbbd | |||
| d985980e88 | |||
| 07765a7a19 | |||
| 2d343dfacf | |||
| 3fbddff279 | |||
| 5eda759152 | |||
| dd6918eefc | |||
| 1102f7fd4e | |||
| 53049fc046 | |||
| d2dd73d34d | |||
| 180f9e77a4 | |||
| 7fbef73531 | |||
| f0b05d74f4 | |||
| 0c03e3b4ba | |||
| 1dba5b629a | |||
| e43b1bbb2e | |||
| 66870d062b | |||
| 6c9a62f6b8 | |||
| b9b0285e95 | |||
| 02ede913d2 | |||
| 217c03379e | |||
| 3ae18678e8 | |||
| 64560fabb2 | |||
| d5d7a0186d | |||
| 370836b814 | |||
| 9759fa565f | |||
| 4d4d9e10b9 | |||
| a871ded15f | |||
| e2c4dfea4e | |||
| 7c2e461141 | |||
| 5f17fabc50 | |||
| 29f713cf17 | |||
| ea54f469ac | |||
| a92ffd3b01 | |||
| 0d1eb35ba2 | |||
| 9a81c4b564 | |||
| ca7ac168fe | |||
| 1e5157bbad | |||
| 6a4264eeab | |||
| 5ed67cb32f | |||
| d087c306f2 | |||
| 67fdb508f4 | |||
| ffb938b181 | |||
| 2b7cce1333 | |||
| fd192455f5 | |||
| f8f2910f06 | |||
| c16e84105e | |||
| 26d063327c | |||
| a4df946169 | |||
| 07e3525042 | |||
| b3144798f4 | |||
| c7751edd91 | |||
| 67d0919237 | |||
| 2574ed0467 | |||
| f7cf752fb2 | |||
| c9691c6a56 | |||
| f1d1a68d32 | |||
| 0b5b5b3189 | |||
| 462252b452 | |||
| 0edc64a5e9 | |||
| 45ce4ba69c | |||
| 16e61c5d6f | |||
| 192841a836 | |||
| 4aa0a7b70e | |||
| d8c754382f | |||
| ba8011ce44 | |||
| 35c58bfcfa | |||
| ce3fd4217d | |||
| 5a996efd19 | |||
| d45a4a35a4 | |||
| b70dfb58e7 | |||
| f644581953 | |||
| e3097f4501 | |||
| e034e9bbcf | |||
| 9350982f8c | |||
| 1d958175f2 | |||
| bfd30513f6 | |||
| 835ae45121 | |||
| e8482ce180 | |||
| 83d37676ae | |||
| bcbfd6f605 | |||
| 478daf4771 | |||
| 9d1f886305 | |||
| 62298dabf3 | |||
| b8804210dc | |||
| 15451f7715 | |||
| b58b1a6857 | |||
| fc719a8cc8 | |||
| 7a6aaa6704 | |||
| c99b9b76ac | |||
| 0a66202f1d | |||
| c2a8cbd679 | |||
| 91a0f3e4d7 | |||
| 35e2a7f4e9 | |||
| dfc59a6aa0 | |||
| f03ab5093f | |||
| 6ae92c937e | |||
| dfb1943e54 | |||
| a6f375abac | |||
| 3f0db8208b | |||
| f2b6dc26cb | |||
| d5c3b4799b | |||
| 15bf5673e3 | |||
| ac7deddd1f | |||
| 8057b355ce | |||
| 2255c4b6d2 | |||
| a3aebff19f | |||
| e7ae5566e5 | |||
| 274ba94ff4 | |||
| 9d5a2460b1 | |||
| 894f999017 | |||
| 38a8d9d29f | |||
| bb231ccd12 | |||
| 4c07423b49 | |||
| b0c7616704 | |||
| 37f75b7d75 | |||
| 0764e3327c | |||
| 2e302f1d0e | |||
| 9f9bcf2d1e | |||
| a3a4352f9d | |||
| 683946377e | |||
| b3206d8890 | |||
| f88cf66a23 | |||
| 4bbb6af693 | |||
| 17a33b0c9c | |||
| 4d191427d9 | |||
| d454b03b46 | |||
| ac91fbd571 | |||
| d9fa7063e0 | |||
| ba5ff8c4a8 | |||
| f2a6f3c3e6 | |||
| 3729d2941e | |||
| 70a913b903 | |||
| c765e69453 | |||
| d009202230 | |||
| df4a7ab49d | |||
| e862c811c2 | |||
| 73479a46bd | |||
| 60a6eecc75 | |||
| eeb601cbbd | |||
| 32c2d899ba | |||
| 97aaa16cc4 | |||
| f31b07e018 | |||
| 1ba4703631 | |||
| 8d2e7bd553 | |||
| 952ac88518 | |||
| 22a7951d80 | |||
| fe5638393a | |||
| 41d35060c1 | |||
| aab5fd9549 | |||
| 9bc5935d12 | |||
| 08021f6c53 | |||
| 96a175cac5 | |||
| c00ef14703 | |||
| 6ed1314bec | |||
| f9c8b80bfe | |||
| 115718c396 | |||
| 3c8fa38a94 | |||
| 0530f86a4c | |||
| 1a36cb1a81 | |||
| d80dff371c | |||
| 2ccdb532b5 | |||
| 5fc67ff48c | |||
| 6ada5d6c92 | |||
| 44eb36b2e0 | |||
| e28cf90a5f | |||
| 47b2ad5cf8 | |||
| 400b0e1f2f | |||
| 79b03bd083 | |||
| 4bc677add3 | |||
| a627dfbf64 | |||
| d48a474e00 | |||
| 720c315d33 | |||
| 67d1ad1fbc | |||
| 3164c07416 | |||
| be122d77fe | |||
| d81adc4781 | |||
| f461e9480b | |||
| f85e3a6a83 | |||
| 0ccb974708 | |||
| 8d0bcf60c1 | |||
| 828f105c92 | |||
| fdbb4a6972 | |||
| b9e8e34e8f | |||
| b02ca9ae01 | |||
| 4b212c08e2 | |||
| 47cdd06494 | |||
| 9005bb61c1 | |||
| 3cdcfe2ce5 | |||
| acf65b01b2 | |||
| 0ff96e371d | |||
| acfa5df527 | |||
| acd4bb192a | |||
| e5cc892dec | |||
| bb8bf033bf | |||
| 69507abf32 | |||
| c5916d57c0 | |||
| 0376c1c4cd | |||
| 55f7a90c76 | |||
| 377157d415 | |||
| 16b50934ab | |||
| d6fb926644 | |||
| e9de913b47 | |||
| 79d8d224df | |||
| 2f0b4b9334 | |||
| 4434eafe48 | |||
| fe01f725d5 | |||
| 1ba920aa11 | |||
| 1693b8fbed | |||
| d105f87177 | |||
| e287b0b6b5 | |||
| f0232d535d | |||
| c140acef5a | |||
| 3f1d697568 | |||
| 725b395bcd | |||
| 770acc2d16 | |||
| b063b4f01e | |||
| 9e89228c6d | |||
| afcab75b20 | |||
| 8198f7604f | |||
| 90b173c389 | |||
| b8af75848d | |||
| 40b9a8ec79 | |||
| c57faca4b5 | |||
| f2d3a0b0ed | |||
| 83e23f2259 | |||
| 120afef8f7 | |||
| 09d117cb53 | |||
| 1b552c4ae8 | |||
| aebe5f1aa6 | |||
| 81480cbebe | |||
| 5d48e29d68 | |||
| 9ddac31b4b | |||
| cb48b21cdc | |||
| 51d9c1d119 | |||
| fe7c9c3797 | |||
| 87d21c9a9d | |||
| 45d96a1f83 | |||
| 3fbc587e02 | |||
| edb06e8e03 | |||
| 87dcce947e | |||
| 5256ab3652 | |||
| bb7d000208 | |||
| 70488e6ecc | |||
| 7be376c2fd | |||
| f390d23f54 | |||
| b23be4335e | |||
| a6a175b3d7 | |||
| 4779ab269f | |||
| 3312dc682b | |||
| a344d084a6 | |||
| b45434bf6e | |||
| d02117f7c3 | |||
| f7a40b7c45 | |||
| d435cac3d5 | |||
| 44b69d99e9 | |||
| 4a425661f7 | |||
| d5b6b46b6c | |||
| 29e995eee0 | |||
| c24d76a1bc | |||
| 81fb3dd00d | |||
| 360e67d38e | |||
| 498e6ae655 | |||
| c91768aec4 | |||
| 17ec207852 | |||
| 94601a0b8b | |||
| 4b7e701613 | |||
| 8c27c73570 | |||
| defdedc9b1 | |||
| 700ec8e27e | |||
| fbe95d8754 | |||
| d3b62bcee3 | |||
| 858fb0fc98 | |||
| e2454965dd | |||
| 42dfb5d8a4 | |||
| 3f125bba98 | |||
| 86e3c15fa4 | |||
| 7fb316be9b | |||
| b337667ce8 | |||
| c54893fd1b | |||
| f44116baa3 | |||
| 365fb7304b | |||
| dbd0c040a6 | |||
| acb7c297a2 | |||
| fb14263ce2 | |||
| 89f1fd4773 | |||
| 0b3a8e9fa6 | |||
| f5ee279bfc | |||
| 13b481bd40 | |||
| 1eb138172c | |||
| afb7e3c060 | |||
| eaf024d78b | |||
| 6cc3db2ce5 | |||
| e1b9991be3 | |||
| 82fb4e7240 | |||
| 09b538c0bd | |||
| fa983d75a5 | |||
| 6ff72f296a | |||
| 47609c7017 | |||
| eaf1786d21 | |||
| 0db890dd81 | |||
| ca7287ee60 | |||
| cb342eb499 | |||
| 99a55f66e2 | |||
| 44621a59f0 | |||
| 7093753636 | |||
| 9c13b4b61c | |||
| 6652d7f00f | |||
| 248fe30346 | |||
| 609583821c | |||
| 9c0897e787 | |||
| 73e662148c | |||
| a352371c45 | |||
| 1e95e643c2 | |||
| 1484259692 | |||
| b1a452ab8e | |||
| ba03a02aa1 | |||
| b5d7406a34 | |||
| b339a322e8 | |||
| 1fcde5d6b9 | |||
| 0cb40f4338 | |||
| 8ee436aa14 | |||
| 4fdc815f70 | |||
| 97a046cbae | |||
| 42edf075c6 | |||
| 5d417e980e | |||
| 989a070782 | |||
| d72685ca28 | |||
| 0299affaad | |||
| 0cb7437d5f | |||
| 227c6e7474 | |||
| 227e543ad9 | |||
| 7e586c07f5 | |||
| e926543eeb | |||
| 97375ec93b | |||
| 0d384227fa | |||
| 99d0750a74 | |||
| 49c53abe1e | |||
| 67d11592bc | |||
| c75c36eea9 | |||
| 36f9996424 | |||
| 5a49e606cd | |||
| 320e064ddc | |||
| b04502b28b | |||
| df41c42ae8 | |||
| 4e939149de | |||
| 03a210507d | |||
| 1cc16cab5d | |||
| 236521a8eb | |||
| 55c7cd5032 | |||
| 633af43ec9 | |||
| c138a19822 | |||
| d9fc067e62 | |||
| b8b73f0bf2 | |||
| 6bd6ba8dd1 | |||
| b4d1254c64 | |||
| 8293b3f91d | |||
| 2bd11f9727 | |||
| 062b145085 | |||
| 63fa3d991e | |||
| 4b56e23a13 | |||
| 55feca69c6 | |||
| e1a0f76298 | |||
| cfc7e9891a | |||
| 3384cef83a | |||
| 156d1a4d70 | |||
| f5b952075d | |||
| 48ad55635c | |||
| aa0cc57d4d | |||
| 985ba9d864 | |||
| 7e354a1938 | |||
| 82de19c95a | |||
| d792b23222 | |||
| 030760eae8 | |||
| be11ab069b | |||
| ccc1105ef1 | |||
| 05263176ac | |||
| 705f16be57 | |||
| a1c51a4342 | |||
| 732188059b | |||
| 7c71d6bd9e | |||
| f84e0f2ef0 | |||
| 8d0de0dcd5 | |||
| ec334c6213 | |||
| 01e4d7daae | |||
| 989e9ce708 | |||
| 9003183484 | |||
| 9f914a9f7d | |||
| a1b07a64d6 | |||
| a4483822a5 | |||
| 1121fa9ccd | |||
| f2c2f477a4 | |||
| 8ad6c3491d | |||
| 6d11720dca | |||
| c66261705e | |||
| 611cfe4cec | |||
| 7bedadc909 | |||
| d0b220b8fc | |||
| 304604f0d6 | |||
| 8c5fd6f4bb | |||
| 134c73d2d3 | |||
| 7fc9932df4 | |||
| f5decd6b8d | |||
| 8573445e27 | |||
| e8069a6fa5 |
@@ -0,0 +1,113 @@
|
||||
# YTSage CI/CD Workflow
|
||||
|
||||
This repository uses GitHub Actions to automatically build and release YTSage for multiple platforms. The workflow is designed to be manually triggered, allowing for flexibility in building specific versions or platforms on demand.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Trigger
|
||||
The workflows are triggered manually via the GitHub Actions "Workflow Dispatch" interface. This allows you to specify the version number explicitly (e.g., `1.0.0`) at runtime.
|
||||
|
||||
### Workflows
|
||||
- **Create All Releases** (`release-all.yml`): The master workflow. Triggering this will automatically run the Windows, Linux, macOS, and PyPI builds in parallel with the version you provide.
|
||||
- **Platform Specific**: You can also trigger `Build Windows Release`, `Build Linux Release`, `Build macOS Release`, or `Build PyPI Package` individually.
|
||||
|
||||
### Build Process
|
||||
1. **Setup**: Uses Python 3.13 on all platforms
|
||||
2. **Builds**: Creates platform-specific executables using cx_Freeze
|
||||
3. **Packages**: Generates native package formats for each platform
|
||||
4. **Release**: Creates or updates a draft GitHub release with the artifacts
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a Full Release (Recommended)
|
||||
|
||||
1. Go to the **Actions** tab in the GitHub repository.
|
||||
2. Select **"Create All Releases"** from the left sidebar.
|
||||
3. Click **Run workflow**.
|
||||
4. Enter the **Version name** (e.g., `1.0.0`).
|
||||
> *Note: Do not include the 'v' prefix in the input field unless you want your filenames to be `v1.0.0`.*
|
||||
5. Click the green **Run workflow** button.
|
||||
6. The system will trigger the Windows, Linux, and macOS jobs. Once complete, a draft release will be available in the Releases section.
|
||||
|
||||
### Creating a Single Platform Build
|
||||
|
||||
1. Go to the **Actions** tab.
|
||||
2. Select the specific workflow (e.g., **"Build Windows Release"**).
|
||||
3. Click **Run workflow** and enter the version.
|
||||
4. Only that specific platform's artifacts will be built and added to the release.
|
||||
|
||||
### Release Artifacts
|
||||
|
||||
The workflow creates the following files based on the platform:
|
||||
|
||||
#### Windows
|
||||
- `YTSage-v{version}-portable.zip` - Standard portable version
|
||||
- `YTSage-v{version}-ffmpeg-portable.zip` - FFmpeg bundle portable
|
||||
- `YTSage-v{version}-Setup.exe` - Standard installer
|
||||
- `YTSage-v{version}-ffmpeg-Setup.exe` - FFmpeg bundle installer
|
||||
|
||||
#### Linux
|
||||
- `YTSage-v{version}-{arch}.AppImage` - AppImage portable (x86_64, aarch64)
|
||||
- `YTSage-v{version}-{arch}.rpm` - RPM package
|
||||
- `YTSage-v{version}-{arch}.deb` - Debian package
|
||||
- `YTSage-v{version}-{arch}.flatpak` - Flatpak bundle
|
||||
|
||||
#### macOS
|
||||
- `YTSage-v{version}-arm64.app.zip` - Zipped application bundle
|
||||
- `YTSage-v{version}-arm64.dmg` - Disk image installer
|
||||
|
||||
#### PyPI
|
||||
- `ytsage-{version}-py3-none-any.whl` - Python Wheel
|
||||
- `ytsage-{version}.tar.gz` - Source Distribution
|
||||
|
||||
## Workflow Features
|
||||
|
||||
- **PyPI**: Standard Python build system (Wheel & Source)
|
||||
### Multi-Platform Support
|
||||
- **Windows**: Uses PowerShell scripts with cx_Freeze
|
||||
- **Linux**: Uses Bash scripts with cx_Freeze, creates AppImage, RPM, and DEB
|
||||
- **macOS**: Matrix build for both Intel (x64) and Apple Silicon (arm64)
|
||||
|
||||
### Manual Versioning
|
||||
- Version is strictly controlled by the input you provide at runtime.
|
||||
- No longer dependent on git tags, reducing accidental releases.
|
||||
|
||||
### Caching
|
||||
- Python dependencies and virtual environments are cached to speed up builds.
|
||||
|
||||
### Error Handling
|
||||
- Comprehensive error checking at each step.
|
||||
- Artifact verification before upload.
|
||||
|
||||
### Security
|
||||
- Uses official GitHub Actions.
|
||||
- Secure token handling via `secrets: inherit` for the master workflow.
|
||||
|
||||
## Manual Intervention
|
||||
|
||||
### After Workflow Completion
|
||||
1. **Review the draft release** in GitHub.
|
||||
2. **Test the artifacts** if needed.
|
||||
3. **Edit release notes** to add changelogs or descriptions.
|
||||
4. **Publish the release** when ready (change from Draft to Published).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Modifying the Workflow
|
||||
The workflow files are located in `.github/workflows/`:
|
||||
- `release-all.yml` - Master workflow that orchestrates the others
|
||||
- `build-windows.yml` - Windows builds logic
|
||||
- `build-linux.yml` - Linux builds logic
|
||||
- `build-pypi.yml` - PyPI build logic
|
||||
- `build-macos.yml` - macOS builds logic
|
||||
|
||||
### Key Configuration Options
|
||||
- `PYTHON_VERSION`: Python version (currently 3.13)
|
||||
- `version` input: Defined as a required string in all workflows.
|
||||
|
||||
## Notes
|
||||
|
||||
- All builds use cx_Freeze for packaging.
|
||||
- FFmpeg binaries are bundled where needed (Windows).
|
||||
- Screenshots are removed from builds to reduce size.
|
||||
- Draft releases allow for review before publication.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: "\U0001F41B Bug Report"
|
||||
about: Provide a clear and descriptive title
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description
|
||||
<!--
|
||||
Describe the bug in detail. Include:
|
||||
- Affected feature (e.g., audio extraction, playlist downloads)
|
||||
- Specific error messages (if any)
|
||||
-->
|
||||
|
||||
## 🔗 Affected Video URL
|
||||
<!-- Paste the YouTube URL that caused the issue -->
|
||||
<!-- Example: https://www.youtube.com/watch?v=ABC123XYZ -->
|
||||
|
||||
## 🔄 Steps to Reproduce
|
||||
1. <!-- Step 1 (e.g., "Pasted the URL above into YTSage") -->
|
||||
2. <!-- Step 2 -->
|
||||
3. <!-- Step 3 -->
|
||||
4. <!-- ... -->
|
||||
|
||||
## ✅ Expected Behavior
|
||||
<!-- What should have happened? -->
|
||||
## ❌ Actual Behavior
|
||||
<!-- What actually happened? -->
|
||||
|
||||
## 🖥️ Environment
|
||||
- **YTSage Version**: <!-- e.g., v4.5.0 -->
|
||||
- **yt-dlp Version**: <!-- e.g., 2025.11.12 -->
|
||||
- **OS**: <!-- e.g., Windows 11, macOS Ventura, Ubuntu 22.04 -->
|
||||
- **Python Version**: <!-- e.g., 3.13.6 -->
|
||||
- **Deno Version**: <!-- e.g., 2.5.6 -->
|
||||
- **FFmpeg Version**: <!-- Run `ffmpeg -version` -->
|
||||
- **Installation Method**: <!-- - pip - executable - manual -->
|
||||
|
||||
> 💡 You can find most of these details by clicking the About button to open the About dialog.
|
||||
|
||||
## 📸 Screenshots/Logs
|
||||
<!--
|
||||
Attach screenshots (for GUI issues) or terminal logs (for CLI errors).
|
||||
|
||||
To collect log files:
|
||||
1. Open YTSage and reproduce the issue.
|
||||
2. Click the **About** button.
|
||||
3. Click **Logs** (📂) to open the logs folder.
|
||||
4. Attach `ytsage.log` and `ytsage_error.log`.
|
||||
|
||||
Use ``` to format logs:
|
||||
-->
|
||||
|
||||
## [ERROR] Failed to download: ...
|
||||
## 🛠️ Possible Fix
|
||||
<!-- (Optional) Suggest a fix if you have insights -->
|
||||
|
||||
## 📌 Additional Context
|
||||
<!-- Any other details (e.g., frequency of the issue, related PRs) -->
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have searched existing issues to avoid duplicates
|
||||
- [ ] I have provided **the affected YouTube URL**
|
||||
- [ ] I have included clear steps to reproduce
|
||||
- [ ] I have attached relevant screenshots/logs
|
||||
- [ ] I have provided all required environment details
|
||||
|
||||
/label ~bug ~needs-triage
|
||||
@@ -0,0 +1,627 @@
|
||||
name: Build Linux Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.13'
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ inputs.version || github.event.inputs.version }}"
|
||||
echo "Extracted version: $version"
|
||||
echo "VERSION=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Cache Python dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
venv
|
||||
~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install system dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
# Tools commonly required by cx_Freeze on Linux packaging
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
rpm alien fakeroot patchelf desktop-file-utils file xz-utils zsync
|
||||
|
||||
- name: Create virtual environment and install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install --no-cache-dir .
|
||||
pip install --no-cache-dir cx_Freeze
|
||||
|
||||
- name: Prepare build variables
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
echo "VERSION=$version" >> "$GITHUB_ENV"
|
||||
arch="$(uname -m)" # x86_64 or aarch64
|
||||
echo "ARCH=$arch" >> "$GITHUB_ENV"
|
||||
echo "Prepared build variables for version: $version on $(uname -a)"
|
||||
|
||||
- name: Create cx_Freeze setup script (Linux)
|
||||
shell: bash
|
||||
run: |
|
||||
# Create entry point script
|
||||
cat > ytsage_entry.py <<'ENTRY'
|
||||
from ytsage.main import main
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
ENTRY
|
||||
|
||||
cat > setup_cxfreeze.py <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from cx_Freeze import setup, Executable
|
||||
|
||||
version = os.environ.get("VERSION", "0.0.0")
|
||||
|
||||
# Prepare include_files list
|
||||
include_files_list = [
|
||||
("ytsage/assets/Icon", "lib/assets/Icon"),
|
||||
("ytsage/assets/sound", "lib/assets/sound"),
|
||||
("ytsage/languages", "lib/languages"),
|
||||
("branding/icons", "lib/assets/branding/icons"),
|
||||
("ytsage.desktop", "share/applications/ytsage.desktop"),
|
||||
("branding/icons/icon.png", "share/pixmaps/ytsage.png"),
|
||||
]
|
||||
|
||||
build_exe_options = dict(
|
||||
optimize=2,
|
||||
silent=True,
|
||||
packages=[
|
||||
"ytsage",
|
||||
"PySide6.QtCore",
|
||||
"PySide6.QtGui",
|
||||
"PySide6.QtWidgets",
|
||||
"PySide6.QtMultimedia",
|
||||
"PySide6.QtNetwork",
|
||||
"PySide6.QtDBus",
|
||||
"PySide6.QtSvg",
|
||||
"requests",
|
||||
"PIL",
|
||||
"packaging",
|
||||
"markdown",
|
||||
"loguru",
|
||||
"setuptools",
|
||||
],
|
||||
zip_include_packages=[
|
||||
"PySide6",
|
||||
"shiboken6",
|
||||
"requests",
|
||||
"PIL",
|
||||
"packaging",
|
||||
],
|
||||
excludes=[
|
||||
"PySide6.QtBluetooth",
|
||||
"PySide6.QtOpenGL",
|
||||
"PySide6.QtPrintSupport",
|
||||
"PySide6.QtTest",
|
||||
"PySide6.QtXml",
|
||||
"PySide6.QtSql",
|
||||
"PySide6.QtHelp",
|
||||
"PySide6.QtQml",
|
||||
"PySide6.QtQuick",
|
||||
"PySide6.QtWebEngineCore",
|
||||
"PIL.ImageDraw",
|
||||
"PIL.ImageFont",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"wx",
|
||||
"pandas",
|
||||
"tkinter",
|
||||
"yt_dlp",
|
||||
"unittest",
|
||||
"test",
|
||||
"tests",
|
||||
"pydoc",
|
||||
"doctest",
|
||||
"email",
|
||||
],
|
||||
include_files=include_files_list,
|
||||
# Bundle all dependencies - avoid system library references
|
||||
bin_includes=[],
|
||||
bin_excludes=[],
|
||||
# Important: include system libs to avoid external dependencies
|
||||
replace_paths=[("*", "")],
|
||||
)
|
||||
|
||||
executables = [
|
||||
Executable(
|
||||
script="ytsage_entry.py",
|
||||
target_name="ytsage",
|
||||
icon="branding/icons/icon.png",
|
||||
)
|
||||
]
|
||||
|
||||
setup(
|
||||
name="ytsage",
|
||||
version=version,
|
||||
description="YTSage",
|
||||
options={
|
||||
"build_exe": build_exe_options,
|
||||
# AppImage packaging
|
||||
"bdist_appimage": {
|
||||
# If ends with .AppImage, use verbatim file name
|
||||
"target_name": f"ytsage-v{version}.AppImage",
|
||||
},
|
||||
},
|
||||
executables=executables,
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Create desktop entry
|
||||
shell: bash
|
||||
run: |
|
||||
cat > ytsage.desktop <<'DESKTOP'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=YTSage
|
||||
Comment=YouTube downloader
|
||||
Exec=/usr/bin/ytsage %U
|
||||
Icon=ytsage
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Network;Utility;
|
||||
StartupWMClass=YTSage
|
||||
DESKTOP
|
||||
|
||||
- name: Build AppImage and RPM
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
source venv/bin/activate
|
||||
|
||||
# Ensure a clean build and build_exe first with -OO
|
||||
python -OO setup_cxfreeze.py build_exe
|
||||
|
||||
# Clean unnecessary files from build directory before packaging
|
||||
build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1 || true)
|
||||
|
||||
if [ -n "$build_dir" ]; then
|
||||
echo "Trimming files in $build_dir..."
|
||||
lib_dir="$build_dir/lib"
|
||||
|
||||
if [ -d "$lib_dir" ]; then
|
||||
# 1. Remove screenshots
|
||||
if [ -d "$lib_dir/assets/branding/screenshots" ]; then
|
||||
rm -rf "$lib_dir/assets/branding/screenshots"
|
||||
echo "Removed screenshots folder"
|
||||
fi
|
||||
|
||||
# 2. Remove unused Qt translations
|
||||
if [ -d "$lib_dir/PySide6/translations" ]; then
|
||||
rm -rf "$lib_dir/PySide6/translations"
|
||||
echo "Removed Qt translations"
|
||||
fi
|
||||
|
||||
# 3. Remove unused Qt plugins
|
||||
plugins_dir="$lib_dir/PySide6/plugins"
|
||||
if [ -d "$plugins_dir" ]; then
|
||||
for plugin in designer pdf sql help qml quick webengine bluetooth opengl printsupport test xml; do
|
||||
if [ -d "$plugins_dir/$plugin" ]; then
|
||||
rm -rf "$plugins_dir/$plugin"
|
||||
echo "Removed plugin: $plugin"
|
||||
fi
|
||||
done
|
||||
# Specific cleanups (imageformats)
|
||||
rm -f "$plugins_dir/imageformats/libqpdf.so"
|
||||
fi
|
||||
|
||||
# 4. Remove bloat shared libraries (Recursively finds in lib/ and lib/PySide6/)
|
||||
# Matches libQt6Qml.so.6, Qt6Qml.abi3.so, etc.
|
||||
echo "Removing bloat Qt libraries..."
|
||||
find "$lib_dir" -name "*Qt6Web*" -delete
|
||||
find "$lib_dir" -name "*Qt6Pdf*" -delete
|
||||
find "$lib_dir" -name "*Qt6Qml*" -delete
|
||||
find "$lib_dir" -name "*Qt6Quick*" -delete
|
||||
find "$lib_dir" -name "*Qt6VirtualKeyboard*" -delete
|
||||
find "$lib_dir" -name "*Qt6OpenGL*" -delete
|
||||
|
||||
# 5. Remove build tools and unused python modules
|
||||
for tool in setuptools wheel pkg_resources _distutils_hack curses _pyrepl; do
|
||||
rm -rf "$lib_dir/$tool"
|
||||
echo "Removed build tool/module: $tool"
|
||||
done
|
||||
else
|
||||
echo "Warning: lib directory not found inside build_dir"
|
||||
fi
|
||||
else
|
||||
echo "Warning: Build directory not found, cannot trim files"
|
||||
fi
|
||||
|
||||
# Build AppImage manually to avoid cx_Freeze rebuilding (and untrimming) the artifacts
|
||||
echo "Creating AppImage manually..."
|
||||
mkdir -p dist
|
||||
|
||||
# Download appimagetool
|
||||
wget -q -O appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool
|
||||
|
||||
# Setup AppDir structure (Root layout matches cx_Freeze build output exactly)
|
||||
rm -rf AppDir
|
||||
mkdir -p AppDir
|
||||
|
||||
# Copy trimmed build content DIRECTLY to AppDir root
|
||||
# This keeps 'lib' adjacent to 'ytsage', preventing 'ModuleNotFoundError: encodings'
|
||||
cp -r "$build_dir"/* AppDir/
|
||||
|
||||
# Setup metadata
|
||||
cp ytsage.desktop AppDir/
|
||||
# Fix Exec path for AppImage context (binary is in root, not /usr/bin)
|
||||
sed -i 's|Exec=/usr/bin/ytsage|Exec=ytsage|g' AppDir/ytsage.desktop
|
||||
cp branding/icons/icon.png AppDir/ytsage.png
|
||||
# .DirIcon for file managers
|
||||
cp branding/icons/icon.png AppDir/.DirIcon
|
||||
|
||||
# Create AppRun as a script to correctly set environment
|
||||
cat > AppDir/AppRun <<'APPRUN'
|
||||
#!/bin/sh
|
||||
SELF=$(readlink -f "$0")
|
||||
HERE=$(dirname "$SELF")
|
||||
export LD_LIBRARY_PATH="$HERE/lib:$LD_LIBRARY_PATH"
|
||||
exec "$HERE/ytsage" "$@"
|
||||
APPRUN
|
||||
chmod +x AppDir/AppRun
|
||||
|
||||
# Build AppImage (using --appimage-extract-and-run to avoid FUSE issues in CI)
|
||||
# We explicitly set ARCH for appimagetool
|
||||
ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir "dist/ytsage-v${version}.AppImage"
|
||||
|
||||
# Build RPM manually with proper spec file
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
arch_uname="${ARCH}"
|
||||
workspace_root="$(pwd)"
|
||||
build_dir_abs="${workspace_root}/${build_dir}"
|
||||
|
||||
# Create custom RPM spec file that doesn't auto-detect dependencies
|
||||
mkdir -p build/rpm/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
|
||||
|
||||
cat > build/rpm/SPECS/ytsage.spec <<SPEC
|
||||
%global __requires_exclude_from ^/opt/ytsage/.*$
|
||||
%global __provides_exclude_from ^/opt/ytsage/.*$
|
||||
%define _build_id_links none
|
||||
%undefine _missing_build_ids_terminate_build
|
||||
|
||||
Name: ytsage
|
||||
Version: ${version//-/.}
|
||||
Release: 1%{?dist}
|
||||
Summary: YTSage - A modern YouTube downloader
|
||||
License: MIT
|
||||
URL: https://github.com/oop7/YTSage
|
||||
AutoReqProv: no
|
||||
|
||||
%description
|
||||
YTSage is a modern YouTube downloader with a PySide6 interface.
|
||||
Download videos, extract audio, fetch subtitles, and more.
|
||||
All dependencies are bundled within the package.
|
||||
|
||||
%install
|
||||
rm -rf %{buildroot}
|
||||
mkdir -p %{buildroot}/opt/ytsage
|
||||
mkdir -p %{buildroot}/usr/bin
|
||||
mkdir -p %{buildroot}/usr/share/applications
|
||||
mkdir -p %{buildroot}/usr/share/pixmaps
|
||||
|
||||
# Copy application files
|
||||
cp -a ${build_dir_abs}/* %{buildroot}/opt/ytsage/
|
||||
|
||||
# Create wrapper script
|
||||
cat > %{buildroot}/usr/bin/ytsage <<'WRAPPER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APPDIR="/opt/ytsage"
|
||||
cd "\$APPDIR"
|
||||
exec "\$APPDIR/ytsage" "\$@"
|
||||
WRAPPER
|
||||
chmod 0755 %{buildroot}/usr/bin/ytsage
|
||||
|
||||
# Install desktop file and icon
|
||||
install -m 0644 ${workspace_root}/ytsage.desktop %{buildroot}/usr/share/applications/ytsage.desktop
|
||||
install -m 0644 ${workspace_root}/branding/icons/icon.png %{buildroot}/usr/share/pixmaps/ytsage.png
|
||||
|
||||
%files
|
||||
/opt/ytsage
|
||||
/usr/bin/ytsage
|
||||
/usr/share/applications/ytsage.desktop
|
||||
/usr/share/pixmaps/ytsage.png
|
||||
|
||||
%post
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database -q /usr/share/applications || true
|
||||
fi
|
||||
|
||||
%postun
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database -q /usr/share/applications || true
|
||||
fi
|
||||
|
||||
%changelog
|
||||
* $(date +'%a %b %d %Y') YTSage Maintainers <noreply@example.com> - ${version//-/.}-1
|
||||
- Release ${version}
|
||||
SPEC
|
||||
|
||||
# Build the RPM
|
||||
rpmbuild -bb build/rpm/SPECS/ytsage.spec \
|
||||
--define "_topdir $(pwd)/build/rpm" \
|
||||
--buildroot "$(pwd)/build/rpm/BUILDROOT"
|
||||
|
||||
# Copy RPM to dist
|
||||
mkdir -p dist
|
||||
find build/rpm/RPMS -name "*.rpm" -exec cp {} dist/ \;
|
||||
|
||||
echo "Post-build directory listing:"
|
||||
echo "-- dist --"; ls -lah dist || true
|
||||
echo "-- build --"; ls -lah build || true
|
||||
|
||||
- name: Build native DEB package
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
arch_uname="${ARCH}"
|
||||
# Map uname -m to Debian arch names
|
||||
case "$arch_uname" in
|
||||
x86_64) deb_arch=amd64 ;;
|
||||
aarch64) deb_arch=arm64 ;;
|
||||
*) deb_arch="$arch_uname" ;;
|
||||
esac
|
||||
|
||||
# Locate build output
|
||||
build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1)
|
||||
if [ -z "$build_dir" ]; then
|
||||
echo "Error: build/exe.* directory not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Staging root
|
||||
pkgroot=deb_pkg
|
||||
rm -rf "$pkgroot"
|
||||
mkdir -p "$pkgroot/DEBIAN" \
|
||||
"$pkgroot/usr/bin" \
|
||||
"$pkgroot/usr/share/applications" \
|
||||
"$pkgroot/usr/share/pixmaps" \
|
||||
"$pkgroot/opt/ytsage"
|
||||
|
||||
# Install application payload under /opt/ytsage
|
||||
cp -a "$build_dir"/* "$pkgroot/opt/ytsage/"
|
||||
|
||||
# Wrapper to ensure correct working directory
|
||||
cat > "$pkgroot/usr/bin/ytsage" <<'WRAP'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APPDIR="/opt/ytsage"
|
||||
cd "$APPDIR"
|
||||
exec "$APPDIR/ytsage" "$@"
|
||||
WRAP
|
||||
chmod 0755 "$pkgroot/usr/bin/ytsage"
|
||||
|
||||
# Desktop file and icon
|
||||
install -m 0644 ytsage.desktop "$pkgroot/usr/share/applications/ytsage.desktop"
|
||||
install -m 0644 branding/icons/icon.png "$pkgroot/usr/share/pixmaps/ytsage.png"
|
||||
|
||||
# Control file
|
||||
cat > "$pkgroot/DEBIAN/control" <<CONTROL
|
||||
Package: ytsage
|
||||
Version: ${version}
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: ${deb_arch}
|
||||
Maintainer: YTSage Maintainers <noreply@example.com>
|
||||
Homepage: https://github.com/oop7/YTSage
|
||||
Description: YTSage - A modern YouTube downloader with a PySide6 interface
|
||||
Download videos, extract audio, fetch subtitles, and more.
|
||||
CONTROL
|
||||
|
||||
# Post-install script to refresh desktop/menu caches (best-effort)
|
||||
cat > "$pkgroot/DEBIAN/postinst" <<'POSTINST'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database -q || true
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -q /usr/share/icons/hicolor || true
|
||||
fi
|
||||
exit 0
|
||||
POSTINST
|
||||
chmod 0755 "$pkgroot/DEBIAN/postinst"
|
||||
|
||||
# Triggers so Debian updates caches
|
||||
cat > "$pkgroot/DEBIAN/triggers" <<'TRIGGERS'
|
||||
interest-noawait /usr/share/applications
|
||||
interest-noawait /usr/share/icons/hicolor
|
||||
TRIGGERS
|
||||
|
||||
# Build the deb
|
||||
deb_out="YTSage-v${version}-${deb_arch}.deb"
|
||||
dpkg-deb --build "$pkgroot" "$deb_out"
|
||||
|
||||
mkdir -p artifacts
|
||||
mv "$deb_out" artifacts/
|
||||
echo "Built native DEB: artifacts/$deb_out"
|
||||
|
||||
- name: Build Flatpak Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
|
||||
# Install flatpak-builder
|
||||
sudo apt-get install -y flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
# Define manifest ID
|
||||
APP_ID="io.github.oop7.YTSage"
|
||||
|
||||
# Create a specific desktop file for Flatpak
|
||||
# - Exec must be 'ytsage' (in path), not /usr/bin/ytsage
|
||||
# - Icon must match the App ID (io.github.oop7.YTSage)
|
||||
cat > flatpak.desktop <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=YTSage
|
||||
Comment=YouTube downloader
|
||||
Exec=ytsage
|
||||
Icon=$APP_ID
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Network;Utility;
|
||||
StartupWMClass=YTSage
|
||||
EOF
|
||||
|
||||
# Create manifest manually
|
||||
cat > ytsage_flatpak.json <<EOF
|
||||
{
|
||||
"app-id": "$APP_ID",
|
||||
"runtime": "org.kde.Platform",
|
||||
"runtime-version": "6.10",
|
||||
"sdk": "org.kde.Sdk",
|
||||
"command": "ytsage",
|
||||
"finish-args": [
|
||||
"--share=ipc",
|
||||
"--socket=x11",
|
||||
"--socket=wayland",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--share=network",
|
||||
"--filesystem=host",
|
||||
"--env=YTDLP_APP_BIN_PATH=/var/data/yt-dlp",
|
||||
"--env=DENO_APP_BIN_PATH=/var/data/deno"
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"name": "ytsage",
|
||||
"buildsystem": "simple",
|
||||
"build-commands": [
|
||||
"mkdir -p /app/bin /app/share/ytsage",
|
||||
"cp -r dist/ytsage-v${version}-*/* /app/share/ytsage/",
|
||||
"ln -s /app/share/ytsage/ytsage /app/bin/ytsage",
|
||||
"install -D flatpak.desktop /app/share/applications/$APP_ID.desktop",
|
||||
"install -D branding/icons/icon.png /app/share/icons/hicolor/128x128/apps/$APP_ID.png"
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"type": "dir",
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Build the Flatpak
|
||||
# Note: In a real environment, building from source is preferred.
|
||||
# Here we are "bundling" the already built binaries from the previous step (cx_Freeze) for simplicity in CI.
|
||||
# This requires the 'dist/' folder to be populated by the previous 'Create cx_Freeze setup script' + build steps.
|
||||
# Wait, the previous steps built into 'build/exe.linux-...' not 'dist/'. Let's find it.
|
||||
|
||||
build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1)
|
||||
if [ -z "$build_dir" ]; then
|
||||
echo "Error: build/exe.* directory not found for Flatpak build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Move build dir to a fixed location for the manifest source to catch
|
||||
mkdir -p dist/ytsage-v${version}-flatpak
|
||||
cp -r "$build_dir"/* "dist/ytsage-v${version}-flatpak/"
|
||||
|
||||
# Install runtime/sdk
|
||||
flatpak install -y --user flathub org.kde.Platform//6.10 org.kde.Sdk//6.10
|
||||
|
||||
# Build
|
||||
flatpak-builder --user --install-deps-from=flathub --repo=repo --force-clean build-flatpak ytsage_flatpak.json
|
||||
|
||||
# Bundle
|
||||
flatpak build-bundle repo artifacts/YTSage-v${version}-x86_64.flatpak $APP_ID
|
||||
echo "Built Flatpak: artifacts/YTSage-v${version}-x86_64.flatpak"
|
||||
|
||||
- name: Package and prepare release artifacts (.AppImage, .rpm, .deb)
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
mkdir -p artifacts
|
||||
|
||||
# Copy AppImage
|
||||
appimage_src=$(ls dist/ytsage-v*.AppImage 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$appimage_src" ]; then
|
||||
cp "$appimage_src" "artifacts/YTSage-v${version}-${ARCH}.AppImage"
|
||||
echo "Copied AppImage: $appimage_src -> artifacts/YTSage-v${version}-${ARCH}.AppImage"
|
||||
else
|
||||
echo "Warning: No .AppImage found in dist/"
|
||||
fi
|
||||
|
||||
# Copy RPM (normalize name)
|
||||
rpm_src=$(ls dist/ytsage-*.rpm 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$rpm_src" ]; then
|
||||
cp "$rpm_src" "artifacts/YTSage-v${version}-${ARCH}.rpm"
|
||||
echo "Copied RPM: $rpm_src -> artifacts/YTSage-v${version}-${ARCH}.rpm"
|
||||
else
|
||||
echo "Warning: No .rpm found in dist/"
|
||||
fi
|
||||
|
||||
# DEB is already staged in artifacts by the native packaging step
|
||||
ls -1 artifacts/*.deb 2>/dev/null || echo "Warning: No .deb found in artifacts/"
|
||||
|
||||
# Check for Flatpak
|
||||
ls -1 artifacts/*.flatpak 2>/dev/null || echo "Warning: No .flatpak found in artifacts/"
|
||||
|
||||
echo "Final artifacts:"
|
||||
ls -lh artifacts || true
|
||||
|
||||
- name: Create/Update draft release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.get_version.outputs.VERSION }}
|
||||
name: YTSage v${{ steps.get_version.outputs.VERSION }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
append_body: true
|
||||
fail_on_unmatched_files: false
|
||||
body: |
|
||||
# YTSage v${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
**Release Date**: ${{ github.event.head_commit.timestamp }}
|
||||
files: |
|
||||
artifacts/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,322 @@
|
||||
name: Build macOS Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.13'
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ inputs.version || github.event.inputs.version }}"
|
||||
echo "Extracted version: $version"
|
||||
echo "VERSION=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Cache Python dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
venv
|
||||
~/.cache/pip
|
||||
~/Library/Caches/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Create virtual environment and install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install --no-cache-dir .
|
||||
pip install --no-cache-dir cx_Freeze dmgbuild
|
||||
|
||||
- name: Prepare build variables
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
echo "VERSION=$version" >> "$GITHUB_ENV"
|
||||
arch="$(uname -m)" # arm64 or x86_64
|
||||
echo "ARCH=$arch" >> "$GITHUB_ENV"
|
||||
if [ "$arch" = "arm64" ]; then
|
||||
echo "ARCH_SUFFIX=arm64" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "ARCH_SUFFIX=x64" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "Prepared build variables for version: $version on $(uname -a)"
|
||||
|
||||
- name: Create cx_Freeze setup script
|
||||
shell: bash
|
||||
run: |
|
||||
# Create entry point script
|
||||
cat > ytsage_entry.py <<'ENTRY'
|
||||
from ytsage.main import main
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
ENTRY
|
||||
|
||||
cat > setup_cxfreeze.py <<'PY'
|
||||
import os
|
||||
from cx_Freeze import setup, Executable
|
||||
|
||||
version = os.environ.get("VERSION", "0.0.0")
|
||||
|
||||
build_exe_options = dict(
|
||||
optimize=2,
|
||||
silent=True,
|
||||
packages=[
|
||||
"ytsage",
|
||||
"PySide6.QtCore",
|
||||
"PySide6.QtGui",
|
||||
"PySide6.QtWidgets",
|
||||
"PySide6.QtMultimedia",
|
||||
"PySide6.QtNetwork",
|
||||
"PySide6.QtDBus",
|
||||
"requests",
|
||||
"PIL",
|
||||
"packaging",
|
||||
"markdown",
|
||||
"loguru",
|
||||
"setuptools",
|
||||
],
|
||||
zip_include_packages=[
|
||||
"PySide6",
|
||||
"shiboken6",
|
||||
"requests",
|
||||
"PIL",
|
||||
"packaging",
|
||||
],
|
||||
excludes=[
|
||||
"PySide6.QtBluetooth",
|
||||
"PySide6.QtOpenGL",
|
||||
"PySide6.QtPrintSupport",
|
||||
"PySide6.QtSvg",
|
||||
"PySide6.QtTest",
|
||||
"PySide6.QtXml",
|
||||
"PySide6.QtSql",
|
||||
"PySide6.QtHelp",
|
||||
"PySide6.QtQml",
|
||||
"PySide6.QtQuick",
|
||||
"PySide6.QtWebEngineCore",
|
||||
"PIL.ImageDraw",
|
||||
"PIL.ImageFont",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"wx",
|
||||
"pandas",
|
||||
"tkinter",
|
||||
"yt_dlp",
|
||||
"unittest",
|
||||
"test",
|
||||
"tests",
|
||||
"pydoc",
|
||||
"doctest",
|
||||
"email",
|
||||
],
|
||||
include_files=[
|
||||
("ytsage/assets/Icon", "lib/assets/Icon"),
|
||||
("ytsage/assets/sound", "lib/assets/sound"),
|
||||
("ytsage/languages", "lib/languages"),
|
||||
("branding/icons", "lib/assets/branding/icons"),
|
||||
],
|
||||
)
|
||||
|
||||
executables = [
|
||||
Executable(
|
||||
script="ytsage_entry.py",
|
||||
target_name=f"YTSage-v{version}",
|
||||
icon="branding/icons/icon.icns",
|
||||
)
|
||||
]
|
||||
|
||||
setup(
|
||||
name="YTSage",
|
||||
version=version,
|
||||
description="YTSage",
|
||||
options={
|
||||
"build_exe": build_exe_options,
|
||||
"bdist_mac": {
|
||||
"iconfile": "branding/icons/icon.icns",
|
||||
"bundle_name": f"YTSage-v{version}",
|
||||
},
|
||||
"bdist_dmg": {
|
||||
"volume_label": f"YTSage v{version}",
|
||||
"applications_shortcut": True,
|
||||
"format": "UDZO",
|
||||
"filesystem": "HFS+",
|
||||
"default_view": "icon-view",
|
||||
},
|
||||
},
|
||||
executables=executables,
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Build .app bundle (bdist_mac)
|
||||
shell: bash
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python -OO setup_cxfreeze.py bdist_mac
|
||||
|
||||
- name: Trim unnecessary files from App bundle
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
|
||||
# Locate the built .app
|
||||
app_path=""
|
||||
for cand in "dist/YTSage-v${version}.app" "build/dist/YTSage-v${version}.app" "build/YTSage-v${version}.app"; do
|
||||
if [ -d "$cand" ]; then app_path="$cand"; break; fi
|
||||
done
|
||||
if [ -z "$app_path" ]; then
|
||||
app_path=$(ls -d dist/*.app build/dist/*.app build/*.app 2>/dev/null | head -n1 || true)
|
||||
fi
|
||||
|
||||
if [ -n "$app_path" ]; then
|
||||
echo "Processing App Bundle at: $app_path"
|
||||
|
||||
# Simplified discovery of library directories
|
||||
# We search for 'lib' folders and then verify if they look like the python library folder
|
||||
find "$app_path/Contents" -type d -name "lib" | while read -r lib_dir; do
|
||||
# Check if this lib dir is a python library dir (contains asyncio or PySide6 or ytsage)
|
||||
if [ -d "$lib_dir/asyncio" ] || [ -d "$lib_dir/PySide6" ] || [ -d "$lib_dir/ytsage" ]; then
|
||||
echo "Cleaning Library Directory: $lib_dir"
|
||||
|
||||
# 1. Remove screenshots
|
||||
rm -rf "$lib_dir/assets/branding/screenshots"
|
||||
|
||||
# 2. Remove unused Qt translations (Recursively find 'translations' folder inside PySide6)
|
||||
find "$lib_dir" -type d -path "*/PySide6/*/translations" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$lib_dir" -type d -path "*/PySide6/translations" -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# 3. Remove unused Qt plugins
|
||||
# Determine plugins path
|
||||
plugins_path=""
|
||||
if [ -d "$lib_dir/PySide6/plugins" ]; then plugins_path="$lib_dir/PySide6/plugins"; fi
|
||||
if [ -d "$lib_dir/PySide6/Qt/plugins" ]; then plugins_path="$lib_dir/PySide6/Qt/plugins"; fi
|
||||
|
||||
if [ -n "$plugins_path" ]; then
|
||||
echo "Found plugins at: $plugins_path"
|
||||
for plugin in designer pdf svg sql help qml quick webengine bluetooth opengl printsupport test xml; do
|
||||
rm -rf "$plugins_path/$plugin"
|
||||
done
|
||||
|
||||
# Specific cleanups
|
||||
find "$plugins_path" -name "libqpdf.*" -delete
|
||||
fi
|
||||
|
||||
# 4. Remove bloat Qt libraries / Frameworks
|
||||
# Iterate specifically over unwanted Qt modules
|
||||
# Matches both 'QtQml' (file) and 'QtQml.abi3.so' etc
|
||||
for bloat in QtQml QtQmlMeta QtQmlModels QtQmlWorkerScript QtQuick QtQuickControls2 QtQuickTemplates2 QtWebEngine QtWebEngineCore QtVirtualKeyboard QtVirtualKeyboardQml QtOpenGL QtOpenGLWidgets QtPdf QtPdfWidgets; do
|
||||
find "$lib_dir" -maxdepth 2 -name "$bloat" -delete
|
||||
find "$lib_dir" -maxdepth 2 -name "${bloat}.*" -delete
|
||||
find "$lib_dir" -maxdepth 2 -name "*$bloat*" -delete
|
||||
done
|
||||
|
||||
# 5. Remove build tools and unused python modules
|
||||
for tool in setuptools wheel pkg_resources _distutils_hack curses _pyrepl; do
|
||||
rm -rf "$lib_dir/$tool"
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Final sanity verification
|
||||
echo "Trim complete. Remaining contents of lib (first 2 levels):"
|
||||
find "$app_path/Contents" -type d -name "lib" -exec ls -R {} \; | head -n 50 || true
|
||||
|
||||
else
|
||||
echo "Error: Could not find .app bundle to trim!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package and prepare release artifacts (.app.zip and .dmg)
|
||||
shell: bash
|
||||
run: |
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
echo "Preparing artifacts for version: $version"
|
||||
mkdir -p artifacts
|
||||
|
||||
# Find the .app bundle
|
||||
app_path=""
|
||||
for cand in "dist/YTSage-v${version}.app" "build/dist/YTSage-v${version}.app" "build/YTSage-v${version}.app"; do
|
||||
if [ -d "$cand" ]; then app_path="$cand"; break; fi
|
||||
done
|
||||
if [ -z "$app_path" ]; then
|
||||
app_path=$(ls -d dist/*.app build/dist/*.app build/*.app 2>/dev/null | head -n1 || true)
|
||||
fi
|
||||
|
||||
if [ -n "$app_path" ] && [ -d "$app_path" ]; then
|
||||
app_base="$(basename "$app_path")"
|
||||
app_parent="$(dirname "$app_path")"
|
||||
|
||||
# Create ZIP
|
||||
(cd "$app_parent" && zip -r "${GITHUB_WORKSPACE}/artifacts/YTSage-v${version}-${ARCH_SUFFIX}.app.zip" "$app_base")
|
||||
echo "Created: artifacts/YTSage-v${version}-${ARCH_SUFFIX}.app.zip"
|
||||
|
||||
# Create DMG (Manual)
|
||||
echo "Creating DMG from $app_path..."
|
||||
mkdir -p dmg_stage
|
||||
cp -R "$app_path" "dmg_stage/"
|
||||
ln -s /Applications "dmg_stage/Applications"
|
||||
|
||||
hdiutil create -volname "YTSage v${version}" -srcfolder "dmg_stage" -ov -format UDZO "artifacts/YTSage-v${version}-${ARCH_SUFFIX}.dmg"
|
||||
echo "Created: artifacts/YTSage-v${version}-${ARCH_SUFFIX}.dmg"
|
||||
|
||||
rm -rf dmg_stage
|
||||
else
|
||||
echo "Error: .app bundle not found in dist/ or build/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Final artifacts:"
|
||||
ls -lh artifacts || true
|
||||
|
||||
- name: Create/Update draft release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.get_version.outputs.VERSION }}
|
||||
name: YTSage v${{ steps.get_version.outputs.VERSION }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
append_body: true
|
||||
fail_on_unmatched_files: false
|
||||
body: |
|
||||
# YTSage v${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
**Release Date**: ${{ github.event.head_commit.timestamp }}
|
||||
files: |
|
||||
artifacts/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Build PyPI Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-pypi:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
|
||||
- name: Build PyPI package
|
||||
run: |
|
||||
python build_release.py
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ inputs.version || github.event.inputs.version }}
|
||||
name: YTSage v${{ inputs.version || github.event.inputs.version }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
files: |
|
||||
dist/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,499 @@
|
||||
name: Build Windows Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.13'
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = "${{ inputs.version || github.event.inputs.version }}"
|
||||
Write-Host "Extracted version: $version"
|
||||
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Cache Python dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
venv
|
||||
~\AppData\Local\pip\Cache
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Create virtual environment and install dependencies
|
||||
shell: powershell
|
||||
run: |
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
pip install --no-cache-dir .
|
||||
pip install --no-cache-dir cx_Freeze
|
||||
|
||||
- name: Prepare build variables
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
Write-Host "Prepared build variables for version: $version"
|
||||
|
||||
- name: Create cx_Freeze setup script
|
||||
shell: powershell
|
||||
run: |
|
||||
# Create entry point script
|
||||
$entryContent = @'
|
||||
from ytsage.main import main
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'@
|
||||
Set-Content -Path "ytsage_entry.py" -Value $entryContent
|
||||
|
||||
# Create setup script for cx_Freeze
|
||||
New-Item -Path "setup_cxfreeze.py" -ItemType File -Force
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value "import os"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value "from cx_Freeze import setup, Executable"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ""
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value 'version = os.environ.get("VERSION", "0.0.0")'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ""
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value "build_exe_options = dict("
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " optimize=2,"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " silent=True,"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " packages=["
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "ytsage",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtCore",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtGui",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtWidgets",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtMultimedia",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtNetwork",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "requests",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "packaging",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "markdown",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "loguru",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "setuptools",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " ],"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " zip_include_packages=["
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "shiboken6",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "requests",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "packaging",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " ],"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " excludes=["
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtBluetooth",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtOpenGL",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtPrintSupport",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtSvg",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtTest",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtXml",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtSql",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtHelp",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtQml",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtQuick",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtWebEngineCore",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL.ImageDraw",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL.ImageFont",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "numpy",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "scipy",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "wx",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "pandas",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "tkinter",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "yt_dlp",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "unittest",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "pydoc",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "doctest",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "email",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "test",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' "tests",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " ],"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " include_files=["
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' ("ytsage/assets/Icon", "lib/assets/Icon"),'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' ("ytsage/assets/sound", "lib/assets/sound"),'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' ("ytsage/languages", "lib/languages"),'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' ("branding/icons", "lib/assets/branding/icons"),'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " ],"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ")"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ""
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value "executables = ["
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " Executable("
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' script="ytsage_entry.py",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' target_name=f"YTSage-v{version}.exe",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' base="gui",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' icon="branding/icons/YTSage.ico",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " )"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value "]"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ""
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value "setup("
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' name="YTSage",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " version=version,"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' description="YTSage",'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ' options={"build_exe": build_exe_options},'
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value " executables=executables,"
|
||||
Add-Content -Path "setup_cxfreeze.py" -Value ")"
|
||||
|
||||
- name: Build Standard Version (ZIP)
|
||||
shell: powershell
|
||||
run: |
|
||||
.\venv\Scripts\Activate.ps1
|
||||
$version = "$env:VERSION"
|
||||
Write-Host "Building Standard Version v$version ..."
|
||||
|
||||
# Clean previous build artifacts
|
||||
if (Test-Path "build\exe.*") { Remove-Item "build\exe.*" -Recurse -Force }
|
||||
if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force }
|
||||
if (Test-Path "dist") { Remove-Item "dist" -Recurse -Force }
|
||||
|
||||
# Build executable using setup script with extra optimization
|
||||
python -OO setup_cxfreeze.py build_exe --build-exe "dist\YTSage"
|
||||
|
||||
- name: Trim unnecessary files from Standard build
|
||||
shell: powershell
|
||||
run: |
|
||||
$distDir = "dist\YTSage"
|
||||
$libDir = "$distDir\lib"
|
||||
if (Test-Path $distDir) {
|
||||
# Remove screenshots
|
||||
if (Test-Path "$libDir\assets\branding\screenshots") {
|
||||
Remove-Item "$libDir\assets\branding\screenshots" -Recurse -Force
|
||||
Write-Host "Removed screenshots folder"
|
||||
}
|
||||
# Remove debug PDB files
|
||||
Get-ChildItem -Path $distDir -Recurse -Filter "*.pdb" | Remove-Item -Force
|
||||
Write-Host "Removed PDB debug files"
|
||||
|
||||
# Remove unused Qt translations (entire folder)
|
||||
if (Test-Path "$libDir\PySide6\translations") {
|
||||
Remove-Item "$libDir\PySide6\translations" -Recurse -Force
|
||||
Write-Host "Removed Qt translations"
|
||||
}
|
||||
|
||||
# Remove unused Qt plugins (based on your excludes; adjust if needed)
|
||||
# Note: qpdf and qsvg plugins are removed specifically
|
||||
$unusedPlugins = @("designer", "pdf", "svg", "sql", "help", "qml", "quick", "webengine", "bluetooth", "opengl", "printsupport", "test", "xml")
|
||||
foreach ($plugin in $unusedPlugins) {
|
||||
if (Test-Path "$libDir\PySide6\plugins\$plugin") {
|
||||
Remove-Item "$libDir\PySide6\plugins\$plugin" -Recurse -Force
|
||||
Write-Host "Removed unused plugin folder: $plugin"
|
||||
}
|
||||
}
|
||||
# Explicitly remove virtualkeyboard input context if it exists
|
||||
if (Test-Path "$libDir\PySide6\plugins\platforminputcontexts") {
|
||||
Remove-Item "$libDir\PySide6\plugins\platforminputcontexts" -Recurse -Force
|
||||
Write-Host "Removed plugin: platforminputcontexts"
|
||||
}
|
||||
# Explicitly remove specific image formats
|
||||
foreach ($fmt in @("qpdf.dll", "qsvg.dll")) {
|
||||
if (Test-Path "$libDir\PySide6\plugins\imageformats\$fmt") {
|
||||
Remove-Item "$libDir\PySide6\plugins\imageformats\$fmt" -Force
|
||||
Write-Host "Removed plugin: $fmt"
|
||||
}
|
||||
}
|
||||
# Explicitly remove specific icon engines
|
||||
if (Test-Path "$libDir\PySide6\plugins\iconengines\qsvgicon.dll") {
|
||||
Remove-Item "$libDir\PySide6\plugins\iconengines\qsvgicon.dll" -Force
|
||||
Write-Host "Removed plugin: qsvgicon.dll"
|
||||
}
|
||||
|
||||
# Remove any duplicate or unnecessary DLLs (e.g., Qt6Qml, Qt6Quick, Qt6OpenGL from root lib)
|
||||
$bloatDlls = @("Qt6Web*", "Qt6Pdf*", "Qt6Qml*", "Qt6Quick*", "Qt6VirtualKeyboard*", "Qt6OpenGL*")
|
||||
foreach ($pattern in $bloatDlls) {
|
||||
# Check in root lib
|
||||
Get-ChildItem -Path $libDir -Filter $pattern -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
# Check in PySide6 folder
|
||||
if (Test-Path "$libDir\PySide6") {
|
||||
Get-ChildItem -Path "$libDir\PySide6" -Filter $pattern -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
}
|
||||
}
|
||||
Write-Host "Removed unnecessary Qt DLLs"
|
||||
|
||||
# Remove build tools and unused python modules
|
||||
$uselessFolders = @("setuptools", "wheel", "pkg_resources", "_distutils_hack", "curses", "_pyrepl")
|
||||
foreach ($folder in $uselessFolders) {
|
||||
if (Test-Path "$libDir\$folder") {
|
||||
Remove-Item "$libDir\$folder" -Recurse -Force
|
||||
Write-Host "Removed unused module: $folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Setup FFmpeg for bundle
|
||||
shell: powershell
|
||||
run: |
|
||||
Write-Host "Setting up FFmpeg for bundled version..."
|
||||
|
||||
# Create ffmpeg directory
|
||||
New-Item -ItemType Directory -Path "ffmpeg-temp" -Force
|
||||
|
||||
# Download FFmpeg
|
||||
$ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
||||
Write-Host "Downloading FFmpeg from: $ffmpegUrl"
|
||||
Invoke-WebRequest -Uri $ffmpegUrl -OutFile "ffmpeg-temp\ffmpeg.zip"
|
||||
|
||||
# Extract FFmpeg
|
||||
Expand-Archive -Path "ffmpeg-temp\ffmpeg.zip" -DestinationPath "ffmpeg-temp" -Force
|
||||
|
||||
# Find the extracted directory (GyanD ffmpeg has specific naming)
|
||||
$ffmpegDir = Get-ChildItem -Path "ffmpeg-temp" -Directory | Where-Object { $_.Name -like "ffmpeg-*" } | Select-Object -First 1
|
||||
|
||||
if ($ffmpegDir) {
|
||||
# Set environment variable for the build
|
||||
$ffmpegBinPath = Join-Path $ffmpegDir.FullName "bin"
|
||||
Write-Host "FFmpeg binaries found at: $ffmpegBinPath"
|
||||
echo "FFMPEG_PATH=$ffmpegBinPath" >> $env:GITHUB_ENV
|
||||
|
||||
# Verify files exist (only ffmpeg and ffprobe are needed)
|
||||
$ffmpegExe = Join-Path $ffmpegBinPath "ffmpeg.exe"
|
||||
$ffprobeExe = Join-Path $ffmpegBinPath "ffprobe.exe"
|
||||
|
||||
if (Test-Path $ffmpegExe) {
|
||||
Write-Host "[OK] ffmpeg.exe found"
|
||||
} else {
|
||||
Write-Host "[ERROR] ffmpeg.exe missing"
|
||||
}
|
||||
if (Test-Path $ffprobeExe) {
|
||||
Write-Host "[OK] ffprobe.exe found"
|
||||
} else {
|
||||
Write-Host "[ERROR] ffprobe.exe missing"
|
||||
}
|
||||
} else {
|
||||
Write-Host "Error: Could not find FFmpeg directory after extraction"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build FFmpeg Version (ZIP)
|
||||
shell: powershell
|
||||
run: |
|
||||
.\venv\Scripts\Activate.ps1
|
||||
$version = "$env:VERSION"
|
||||
Write-Host "Building FFmpeg Version v$version ..."
|
||||
|
||||
# Clean previous build artifacts
|
||||
if (Test-Path "build\exe.*") { Remove-Item "build\exe.*" -Recurse -Force }
|
||||
if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force }
|
||||
|
||||
# Create modified setup script for FFmpeg version
|
||||
(Get-Content "setup_cxfreeze.py") -replace 'YTSage-v\{version\}\.exe', 'YTSage-v{version}-ffmpeg.exe' | Set-Content "setup_cxfreeze_ffmpeg.py"
|
||||
|
||||
# Build executable with FFmpeg using setup script
|
||||
python -OO setup_cxfreeze_ffmpeg.py build_exe --build-exe "dist\YTSage-FFmpeg"
|
||||
|
||||
# Copy FFmpeg binaries into dist folder post-build (more reliable than CLI include)
|
||||
# Note: ffplay.exe is excluded as it's not needed by the application
|
||||
if ($env:FFMPEG_PATH) {
|
||||
$destDir = "dist\YTSage-FFmpeg"
|
||||
$ffmpegExe = Join-Path $env:FFMPEG_PATH "ffmpeg.exe"
|
||||
$ffprobeExe = Join-Path $env:FFMPEG_PATH "ffprobe.exe"
|
||||
|
||||
foreach ($file in @($ffmpegExe, $ffprobeExe)) {
|
||||
if (Test-Path $file) {
|
||||
$name = Split-Path $file -Leaf
|
||||
Copy-Item $file -Destination (Join-Path $destDir $name) -Force
|
||||
Write-Host "Copied $name into $destDir"
|
||||
} else {
|
||||
Write-Host "Warning: FFmpeg binary not found: $file"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Trim unnecessary files from FFmpeg build
|
||||
shell: powershell
|
||||
run: |
|
||||
$distDir = "dist\YTSage-FFmpeg"
|
||||
$libDir = "$distDir\lib"
|
||||
if (Test-Path $distDir) {
|
||||
# Remove screenshots
|
||||
if (Test-Path "$libDir\assets\branding\screenshots") {
|
||||
Remove-Item "$libDir\assets\branding\screenshots" -Recurse -Force
|
||||
Write-Host "Removed screenshots folder"
|
||||
}
|
||||
# Remove debug PDB files
|
||||
Get-ChildItem -Path $distDir -Recurse -Filter "*.pdb" | Remove-Item -Force
|
||||
Write-Host "Removed PDB debug files"
|
||||
|
||||
# Remove unused Qt translations (entire folder)
|
||||
if (Test-Path "$libDir\PySide6\translations") {
|
||||
Remove-Item "$libDir\PySide6\translations" -Recurse -Force
|
||||
Write-Host "Removed Qt translations"
|
||||
}
|
||||
|
||||
# Remove unused Qt plugins (based on your excludes; adjust if needed)
|
||||
# Note: qpdf and qsvg plugins are removed specifically
|
||||
$unusedPlugins = @("designer", "pdf", "svg", "sql", "help", "qml", "quick", "webengine", "bluetooth", "opengl", "printsupport", "test", "xml")
|
||||
foreach ($plugin in $unusedPlugins) {
|
||||
if (Test-Path "$libDir\PySide6\plugins\$plugin") {
|
||||
Remove-Item "$libDir\PySide6\plugins\$plugin" -Recurse -Force
|
||||
Write-Host "Removed unused plugin folder: $plugin"
|
||||
}
|
||||
}
|
||||
# Explicitly remove virtualkeyboard input context if it exists
|
||||
if (Test-Path "$libDir\PySide6\plugins\platforminputcontexts") {
|
||||
Remove-Item "$libDir\PySide6\plugins\platforminputcontexts" -Recurse -Force
|
||||
Write-Host "Removed plugin: platforminputcontexts"
|
||||
}
|
||||
# Explicitly remove specific image formats
|
||||
foreach ($fmt in @("qpdf.dll", "qsvg.dll")) {
|
||||
if (Test-Path "$libDir\PySide6\plugins\imageformats\$fmt") {
|
||||
Remove-Item "$libDir\PySide6\plugins\imageformats\$fmt" -Force
|
||||
Write-Host "Removed plugin: $fmt"
|
||||
}
|
||||
}
|
||||
# Explicitly remove specific icon engines
|
||||
if (Test-Path "$libDir\PySide6\plugins\iconengines\qsvgicon.dll") {
|
||||
Remove-Item "$libDir\PySide6\plugins\iconengines\qsvgicon.dll" -Force
|
||||
Write-Host "Removed plugin: qsvgicon.dll"
|
||||
}
|
||||
|
||||
# Remove any duplicate or unnecessary DLLs (e.g., Qt6Qml, Qt6Quick, Qt6OpenGL from root lib)
|
||||
$bloatDlls = @("Qt6Web*", "Qt6Pdf*", "Qt6Qml*", "Qt6Quick*", "Qt6VirtualKeyboard*", "Qt6OpenGL*")
|
||||
foreach ($pattern in $bloatDlls) {
|
||||
# Check in root lib
|
||||
Get-ChildItem -Path $libDir -Filter $pattern -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
# Check in PySide6 folder
|
||||
if (Test-Path "$libDir\PySide6") {
|
||||
Get-ChildItem -Path "$libDir\PySide6" -Filter $pattern -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
}
|
||||
}
|
||||
Write-Host "Removed unnecessary Qt DLLs"
|
||||
|
||||
# Remove build tools and unused python modules
|
||||
$uselessFolders = @("setuptools", "wheel", "pkg_resources", "_distutils_hack", "curses", "_pyrepl")
|
||||
foreach ($folder in $uselessFolders) {
|
||||
if (Test-Path "$libDir\$folder") {
|
||||
Remove-Item "$libDir\$folder" -Recurse -Force
|
||||
Write-Host "Removed unused module: $folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Package and prepare release artifacts (ZIPs)
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||
|
||||
Write-Host "Preparing ZIP artifacts for version: $version"
|
||||
New-Item -ItemType Directory -Path "artifacts" -Force
|
||||
|
||||
# List dist directories
|
||||
Write-Host "Files in dist directory:"
|
||||
if (Test-Path "dist") {
|
||||
Get-ChildItem -Path "dist" -Recurse | ForEach-Object { Write-Host " $($_.FullName)" }
|
||||
} else {
|
||||
Write-Host " No dist directory found!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Create Standard ZIP
|
||||
$standardDist = Join-Path (Get-Location) "dist\YTSage"
|
||||
if (Test-Path $standardDist) {
|
||||
$stdZip = "artifacts\YTSage-v$version-portable.zip"
|
||||
if (Test-Path $stdZip) { Remove-Item $stdZip -Force }
|
||||
Compress-Archive -Path "$standardDist\*" -DestinationPath $stdZip -CompressionLevel Optimal
|
||||
Write-Host "Created: $(Resolve-Path $stdZip)"
|
||||
} else {
|
||||
Write-Host "Warning: Standard dist folder not found at $standardDist"
|
||||
}
|
||||
|
||||
# Create FFmpeg ZIP
|
||||
$ffmpegDist = Join-Path (Get-Location) "dist\YTSage-FFmpeg"
|
||||
if (Test-Path $ffmpegDist) {
|
||||
$ffZip = "artifacts\YTSage-v$version-ffmpeg-portable.zip"
|
||||
if (Test-Path $ffZip) { Remove-Item $ffZip -Force }
|
||||
Compress-Archive -Path "$ffmpegDist\*" -DestinationPath $ffZip -CompressionLevel Optimal
|
||||
Write-Host "Created: $(Resolve-Path $ffZip)"
|
||||
} else {
|
||||
Write-Host "Warning: FFmpeg dist folder not found at $ffmpegDist"
|
||||
}
|
||||
|
||||
# List all artifacts
|
||||
Write-Host "Final artifacts:"
|
||||
if (Test-Path "artifacts") {
|
||||
$artifactFiles = Get-ChildItem artifacts
|
||||
if ($artifactFiles) {
|
||||
$artifactFiles | ForEach-Object { Write-Host " $($_.Name)" }
|
||||
} else {
|
||||
Write-Host " No artifacts created!"
|
||||
}
|
||||
} else {
|
||||
Write-Host " Artifacts directory not found!"
|
||||
}
|
||||
|
||||
- name: Create Installer (Inno Setup)
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||
|
||||
# Install Inno Setup
|
||||
choco install innosetup --no-progress
|
||||
|
||||
# Add matches generally C:\Program Files (x86)\Inno Setup 6
|
||||
$env:Path = "C:\Program Files (x86)\Inno Setup 6;$env:Path"
|
||||
|
||||
# Compile
|
||||
$exeName = "YTSage-v$version.exe"
|
||||
Write-Host "Compiling installer for $exeName..."
|
||||
|
||||
# Note: Setup-windows.iss is in setup-scripts/
|
||||
# OutputDir is ..\artifacts (relative to script) -> repo/artifacts
|
||||
# SourceDir is ..\dist\YTSage (relative to script) -> repo/dist/YTSage
|
||||
|
||||
iscc /DMyAppVersion="$version" /DMyAppExeName="$exeName" /DSourceDir="..\dist\YTSage" "setup-scripts\Setup-windows.iss"
|
||||
|
||||
# Compile FFmpeg Installer
|
||||
$exeNameFFmpeg = "YTSage-v$version-ffmpeg.exe"
|
||||
Write-Host "Compiling FFmpeg installer for $exeNameFFmpeg..."
|
||||
iscc /DMyAppVersion="$version" /DMyAppExeName="$exeNameFFmpeg" /DSourceDir="..\dist\YTSage-FFmpeg" "setup-scripts\Setup-windows-ffmpeg.iss"
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Installer compilation successful."
|
||||
} else {
|
||||
Write-Host "Installer compilation failed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.get_version.outputs.VERSION }}
|
||||
name: YTSage v${{ steps.get_version.outputs.VERSION }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
body: |
|
||||
# YTSage v${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
**Release Date**: ${{ github.event.head_commit.timestamp }}
|
||||
files: |
|
||||
artifacts/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Create All Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version name for the release (e.g., 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release-windows:
|
||||
uses: ./.github/workflows/build-windows.yml
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
secrets: inherit
|
||||
|
||||
release-linux:
|
||||
uses: ./.github/workflows/build-linux.yml
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
secrets: inherit
|
||||
|
||||
release-macos:
|
||||
uses: ./.github/workflows/build-macos.yml
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
secrets: inherit
|
||||
|
||||
release-pypi:
|
||||
uses: ./.github/workflows/build-pypi.yml
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,34 @@
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Local configuration
|
||||
*.ini
|
||||
*.key
|
||||
*.env
|
||||
|
||||
# Packaging artifacts
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
*.deb
|
||||
|
||||
# Logs and misc
|
||||
*.log
|
||||
|
||||
# OS and editor-specific
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# yt-dlp temporary/partial files
|
||||
*.part
|
||||
*.ytdl
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Test artifacts
|
||||
*.test
|
||||
*.tmp
|
||||
@@ -0,0 +1,230 @@
|
||||
# Changelog
|
||||
|
||||
Kept as the work happens, not assembled at release time. Format is
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
||||
[SemVer](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
**The `vX.Y.Z` tags already on this repository are upstream YTSage's**, inherited
|
||||
with its history. SageTube's own versions start below and are the ones this file
|
||||
records.
|
||||
|
||||
## 5.5.2 — 2026-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The account feed loaded but never appeared.** Refresh reported "60 videos in
|
||||
feed" above a "No subscriptions yet" message and an empty page: the empty-state
|
||||
hint hides the grid so it can sit centred, and the account path set its entries
|
||||
without re-running that check — so the grid stayed hidden from startup. The
|
||||
local feed was unaffected, which is why it went unnoticed.
|
||||
- The empty-state message was written for local subscriptions and was wrong
|
||||
advice in account mode, where local subscriptions are not used at all. It now
|
||||
says to press Refresh instead of telling you to go and subscribe to something.
|
||||
- The Subscriptions panel is hidden in account mode rather than sitting empty
|
||||
beside a full feed, implying something failed.
|
||||
|
||||
## 5.5.1 — 2026-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The app could start fullscreen with no way out.** Fullscreen became a state
|
||||
of the main window in 5.5.0, and the window's geometry is saved on exit — so
|
||||
quitting from fullscreen brought the app back fullscreen, with no title bar,
|
||||
no close button, and nothing bound to leave it (the player's F and Escape only
|
||||
work while the player has focus, and it need not even be the visible tab).
|
||||
Fullscreen is now never restored at startup, leaving it is done before the
|
||||
geometry is saved, and **F11 toggles fullscreen and Escape leaves it from
|
||||
anywhere in the window**.
|
||||
- **"Sign in with cookies" and the account button did nothing.** Both open the
|
||||
cookie dialog on its Cookies tab, which meant calling `setCurrentIndex` on
|
||||
the dialog's tab widget — a `SmoothTabWidget`, which despite standing in for
|
||||
a `QTabWidget` implemented only `set_current_index`. The call raised, so the
|
||||
dialog never appeared. `SmoothTabWidget` now provides the Qt-compatible API
|
||||
it claimed to have (`setCurrentIndex`, `count`, `widget`, `indexOf`,
|
||||
`tabText`, `setTabText`).
|
||||
|
||||
## 5.5.0 — 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- **The YouTube account is findable.** Signing in with browser cookies was
|
||||
reachable only via Downloads → *Custom Options* → *Login with Cookies* — the
|
||||
last tab — while the features that need it (the Feed's account mode, Search,
|
||||
Browse) are other tabs with no route to it, and the method that opens that
|
||||
dialog on the right tab had no caller at all. There is now an account button
|
||||
in the corner of the tab bar, visible from everywhere, showing whether
|
||||
cookies are in use and which browser they came from. The Feed offers the same
|
||||
thing beside its account mode instead of just greying it out, and now says
|
||||
why it is unavailable.
|
||||
- **Cookie changes take effect immediately.** The player read them once, when
|
||||
it was built, so signing in only reached playback after restarting the app.
|
||||
It now reloads at the current position.
|
||||
- **The player responds to the keyboard and the mouse.** It had a key handler
|
||||
for Space, F and Escape that could never fire — nothing in the player set a
|
||||
focus policy, so focus always landed on a child button or slider, which
|
||||
swallowed the keys. Now: Space/K play-pause, ←/→ ∓5s, J/L ∓10s, Shift+←/→
|
||||
∓1s, `,`/`.` frame step, ↑/↓ volume, M mute, `[`/`]` speed with Backspace to
|
||||
reset, C subtitles, F fullscreen, Escape to leave, 0–9 to jump by tenths,
|
||||
Home/End, N/P for next and previous. Click pauses, double-click goes
|
||||
fullscreen, the wheel changes volume and Ctrl+wheel seeks, and right-click
|
||||
opens a menu built from the same table as the shortcuts. The bindings are
|
||||
scoped to the player, so typing in the Search box or the URL field is
|
||||
unaffected.
|
||||
- **Previous, next and mute buttons**, and a buffering indicator — a stalled
|
||||
stream previously showed a frozen frame for 25 seconds with nothing on
|
||||
screen before the automatic retry.
|
||||
- In fullscreen the controls and pointer fade out after a few idle seconds and
|
||||
return on any movement. In a window they never hide, so the layout does not
|
||||
jump.
|
||||
- **Real icons.** The app had no icon system: transport buttons used the
|
||||
platform style's dark monochrome glyphs painted on saturated red, and
|
||||
everything else called an icon was an emoji baked into the English strings.
|
||||
A drawn SVG set is now rendered through QtSvg and recoloured at load — Qt
|
||||
stylesheets cannot recolour a `QIcon`, which is exactly why the old ones were
|
||||
unreadable. Buttons across the Downloads tab gained icons too.
|
||||
- **Tooltips and accessible names on every button.** Three widgets in the whole
|
||||
application had a tooltip. An application-level polish filter now fills them
|
||||
in as each button is first shown, and swaps placeholder emoji for icons —
|
||||
which covers dialogs owned by upstream without editing them.
|
||||
- **Skip this version** in the update dialog. The skipped version is never
|
||||
offered again; later ones still are.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The tab order is Feed → Search → Browse → Downloads → Watch**, each with an
|
||||
icon, and the app opens on the Feed. Watch was first, which is where the
|
||||
other tabs send you rather than somewhere you start.
|
||||
- **Opening the Feed refreshes it**, but only channels not seen for 30 minutes
|
||||
(`feed.auto_refresh_on_open_minutes`, 0 to switch off), at most eight per
|
||||
visit, once per interval per session, and after a short delay. A refresh is
|
||||
one yt-dlp subprocess per subscribed channel, so an unthrottled one would be
|
||||
a lot of them. The cached feed still appears instantly; manual Refresh always
|
||||
does everything.
|
||||
- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default
|
||||
`auto`), each ending in software decoding, so a machine with broken GPU
|
||||
interop plays rather than showing a black frame. This does **not** silence
|
||||
`Cannot load libcuda.so.1` — that comes from the driver stack below mpv and
|
||||
appears with `hwdec=no` too.
|
||||
- **The update check now looks at SageTube's own releases.** It queried PyPI's
|
||||
`ytsage` package for the version and `oop7/YTSage` for the changelog, then
|
||||
linked to upstream's downloads — a different program's release stream. It now
|
||||
reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the
|
||||
"Download update" button opens this repository's release page.
|
||||
- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags
|
||||
(`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be
|
||||
offered one.
|
||||
- The check now runs at most once a day rather than on every start.
|
||||
- The About dialog and the update settings say SageTube rather than YTSage. The
|
||||
"Based on YTSage by oop7" attribution link stays — it is the MIT credit.
|
||||
- Configs now carry `config_version`. A file written before 5.4.0 — including one
|
||||
inherited from an upstream YTSage install — has its stored `check_app_updates`
|
||||
cleared once, so SageTube's own default applies rather than a setting that
|
||||
pointed at another project's releases.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A non-English interface no longer shows raw keys.** Every string SageTube
|
||||
added — the tab names, the feed, cards, watch, browse, search, player and
|
||||
account text — exists only in `en.json`, because the thirteen translated files
|
||||
predate the fork. `get_text` returned the key itself, so the tabs literally
|
||||
read `main_tabs.watch`. Those namespaces are now in the embedded English
|
||||
fallback, so a missing translation reads as English rather than as a dotted
|
||||
key. Translating the language files remains a separate job.
|
||||
- The error log filled with hundreds of `after creating texture: OpenGL error
|
||||
INVALID_ENUM` lines per session. They come from the GL driver, playback
|
||||
continues regardless, and they buried the errors that do matter, so they are
|
||||
logged at debug level now.
|
||||
- The player's cookie and proxy options were joined with commas and never
|
||||
escaped, so a cookie path or a proxy URL containing one silently truncated
|
||||
the option and produced a bogus one. The geo-bypass proxy was honoured by the
|
||||
downloader but never passed to the player at all.
|
||||
- **The rest of the interface is themed.** `StyleSheet.MAIN` styled the window,
|
||||
inputs, buttons and tables and nothing else, so the main tab bar, combo
|
||||
boxes, sliders, lists, menus, splitters, tooltips and the horizontal
|
||||
scrollbar were drawn by the platform — the tab bar with white text forced
|
||||
onto system-coloured tabs, tooltips in the system's light style on a black
|
||||
app. All of them now match. `QFrame#tabContent` finally has the rule the tab
|
||||
widget's own comment promised.
|
||||
- Checkboxes were round, which reads as a radio button — "one of these" rather
|
||||
than "on or off". They are square with a tick.
|
||||
- Clicking any button shifted its label two pixels down and right, and clipped
|
||||
the artwork on fixed-width icon buttons: the pressed style changed the
|
||||
padding. The background change alone reads as pressed.
|
||||
- The window icon shipped only at 48px and was upscaled everywhere; the 256px
|
||||
master now ships beside it. Its fallback was the platform's download arrow
|
||||
standing in for the application's identity.
|
||||
- **The feed grid no longer flickers or loses your scroll position.** It was
|
||||
cleared and rebuilt from scratch after every channel finished, re-reading
|
||||
every thumbnail from disk each time; finished channels are now merged in and
|
||||
existing cards left alone. The docstring's claim that it "fills
|
||||
incrementally" is finally true.
|
||||
- Card layout items accumulated: each `Load more` re-added cards the layout
|
||||
already held, and the resize check compared against `columnCount()`, which
|
||||
never shrinks — so it relaid out on every single resize event.
|
||||
- Feed failures were invisible. The error was written into the same label the
|
||||
next successful channel immediately overwrote; the count is now reported once
|
||||
when the refresh ends.
|
||||
- Switching the feed to account mode left the local videos on screen, so one
|
||||
mode's contents appeared to be the other's.
|
||||
- Leaving the Feed tab or closing the window cancels a running refresh.
|
||||
`cancel()` existed and had no callers, so a sweep kept running and the window
|
||||
could be closed while its thread was live.
|
||||
- Browse's Subscribe button never said "Unsubscribe", although pressing it
|
||||
toggles; its only feedback was a status line on a different tab.
|
||||
- "Play all" quietly queued just the loaded page and claimed to be everything.
|
||||
- The feed sorted by publish time and fetch time in the same expression, so the
|
||||
most recently refreshed channel floated to the top regardless of how old its
|
||||
videos were.
|
||||
- **The player no longer crashes the app.** Qt destroys and recreates a
|
||||
widget's OpenGL context whenever it moves to another top-level window, and
|
||||
calls `initializeGL()` again. libmpv allows one render context per handle, so
|
||||
the second creation failed — and the failure path dropped the last reference
|
||||
to the *first* context while libmpv still held a function pointer into it.
|
||||
Python then collected the callback trampoline under libmpv's feet and the
|
||||
next frame notification jumped into freed memory. `initializeGL()` now tears
|
||||
down first so recreation is clean, teardown clears the callback and frees
|
||||
with the GL context current, and it is wired to
|
||||
`QOpenGLContext.aboutToBeDestroyed` so it runs before the context goes away
|
||||
rather than never. Verified over ten forced context destroy/recreate cycles.
|
||||
- **Fullscreen no longer reparents the video.** It took the player out of its
|
||||
layout and into a new top-level window, destroying the OpenGL context twice
|
||||
per toggle — the most direct route to the crash above — and on the way back
|
||||
re-added the panel at the end of the splitter, so the video reappeared beside
|
||||
the queue with the pane sizes lost. The main window now goes fullscreen and
|
||||
the chrome around the video is hidden instead. Nothing is reparented.
|
||||
- **Switching tabs no longer risks the player.** The cross-fade grabbed a
|
||||
screenshot of the outgoing page; on an OpenGL surface that forces a
|
||||
framebuffer readback and returns black, so the "fade" was a black slab and
|
||||
the readback endangered the context. Pages containing the video now switch
|
||||
without the fade.
|
||||
- Dialogs dimmed instead of blurred while the player exists, for the same
|
||||
reason — every dialog in the app screenshotted the whole window.
|
||||
- The saved volume never reached mpv: the slider set its restored value before
|
||||
its change signal was connected, so playback always started at 100.
|
||||
- **The app no longer offers upstream YTSage's releases as its own updates.**
|
||||
`__version__` and `pyproject.toml` had been left at the scaffolded `0.1.0`
|
||||
while the released tag was `v5.4.0`, so every comparison against upstream's
|
||||
published version reported an update. Both now read `5.4.0`, the single
|
||||
source of truth for the window title, the About dialog and the update check.
|
||||
- `ConfigManager` merged a stored config over the defaults **shallowly**, so a
|
||||
file written by an older build replaced whole nested objects: a stored
|
||||
`"player"` or `"feed"` containing only some keys silently discarded every
|
||||
default added since. The merge is now recursive. Keys present only in the
|
||||
stored file are preserved, so downgrading cannot destroy settings.
|
||||
- Opening *Custom Options* and clicking OK re-enabled the update checker, because
|
||||
the settings tab read a missing `check_app_updates` as enabled and then
|
||||
persisted that reading unconditionally.
|
||||
|
||||
## 5.4.0 — 2026-08-08
|
||||
|
||||
### Changed
|
||||
|
||||
- The documentation left the repository: `docs/BUILDING.md`, `docs/UPSTREAM.md`
|
||||
and `docs/UPSTREAM_README.md` are now the
|
||||
[wiki](https://git.houmeres.sk/Houmeres/SageTube/wiki).
|
||||
- **The history was deliberately not rewritten.** Every other repository in this
|
||||
org had its documentation removed from every commit on 2026-08-08. SageTube is
|
||||
a fork that still merges from `oop7/YTSage`, and a rewrite changes every commit
|
||||
id — which would end that. The files leave in an ordinary commit instead.
|
||||
- `.github/` and `readme-translations/` are upstream's and were left alone, so
|
||||
the next merge does not conflict over them.
|
||||
@@ -1,18 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Houmeres
|
||||
Copyright (c) 2024 oop7 (YTSage)
|
||||
Copyright (c) 2026 Houmeres (SageTube)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
include README.md
|
||||
include LICENSE
|
||||
include pyproject.toml
|
||||
recursive-include readme-translations *
|
||||
recursive-include ytsage/assets *
|
||||
recursive-include ytsage/languages *
|
||||
@@ -1,2 +1,46 @@
|
||||
# SageTube
|
||||
|
||||
A watch-first YouTube client for the desktop. Search, browse channels and
|
||||
playlists, follow subscriptions, and stream videos in an embedded mpv player —
|
||||
with the full yt-dlp download feature set inherited from
|
||||
[YTSage](https://github.com/oop7/YTSage).
|
||||
|
||||
## Features
|
||||
|
||||
**Watching**
|
||||
- 📺 Embedded mpv player (Wayland/X11/Windows/macOS) with quality selector,
|
||||
speed, volume, subtitles, fullscreen
|
||||
- 🔍 In-app YouTube search with thumbnail card grid
|
||||
- 📂 Channel browsing (Videos / Shorts / Live) and playlist views
|
||||
- 🔔 Local subscriptions — no Google account needed — with an aggregated feed;
|
||||
optional real account feed via browser cookies
|
||||
- ⏯️ Watch history with resume positions and a persistent play queue
|
||||
- ⬇️ One click from any video card into the downloader
|
||||
|
||||
**Downloading** (from YTSage)
|
||||
- Format table, audio extraction, subtitles, SponsorBlock, chapters,
|
||||
playlists, trimming, speed limits, proxies, cookies, custom yt-dlp commands,
|
||||
download history — see the [upstream README](https://git.houmeres.sk/Houmeres/SageTube/wiki/Upstream-README)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
sagetube
|
||||
```
|
||||
|
||||
Requires **libmpv** for playback (Arch: `pacman -S mpv`) — see
|
||||
[Building](https://git.houmeres.sk/Houmeres/SageTube/wiki/Building). Without it the app works as a downloader.
|
||||
|
||||
## Fork notes
|
||||
|
||||
SageTube is a fork of [YTSage](https://github.com/oop7/YTSage) by
|
||||
[oop7](https://github.com/oop7) (MIT). The internal package keeps the
|
||||
`ytsage` name so upstream fixes remain mergeable — see
|
||||
[Tracking upstream](https://git.houmeres.sk/Houmeres/SageTube/wiki/Tracking-upstream). The fork also fixes a number of
|
||||
upstream bugs (unsafe partial-file cleanup, unverified binary updates,
|
||||
thread-safety issues, PATH corruption on Windows — see the git log).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 725 KiB |
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 578 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 160" role="img" aria-labelledby="title" fill="none">
|
||||
<title id="title">YTSage Logo</title>
|
||||
<defs>
|
||||
<!-- Match the hero gradient: white to accent red (#dc2626) -->
|
||||
<linearGradient id="ytGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background transparent rectangle for easier clicking (optional) -->
|
||||
<rect width="700" height="160" fill="transparent" />
|
||||
<text x="50%" y="50%"
|
||||
font-family="Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif"
|
||||
font-size="120" font-weight="900"
|
||||
text-anchor="middle" dominant-baseline="central"
|
||||
fill="url(#ytGradient)">YTSage</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 826 B |
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def build_release():
|
||||
print("Starting Production Build...")
|
||||
|
||||
# 1. Backup local README
|
||||
if os.path.exists("README.md"):
|
||||
shutil.copy2("README.md", "README.md.bak")
|
||||
print("Backed up README.md")
|
||||
|
||||
try:
|
||||
# 2. Prepare PyPI README
|
||||
with open("README.md", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Base URL for assets
|
||||
base_url = "https://github.com/oop7/YTSage/raw/main/branding/"
|
||||
|
||||
# Function to fix paths matches by regex
|
||||
def path_fixer(match):
|
||||
full_match = match.group(0)
|
||||
# Convert backslashes to forward slashes for URL compatibility
|
||||
fixed = full_match.replace("\\", "/")
|
||||
# Prepend the absolute URL
|
||||
return fixed.replace("branding/", base_url)
|
||||
|
||||
# Regex: matches "branding" followed by backslash or slash, then characters until quote, closing paren, or space
|
||||
# This captures paths like: branding\screenshots\main.png
|
||||
pattern = r"branding[\\/][^\"')\s]+"
|
||||
new_content = re.sub(pattern, path_fixer, content)
|
||||
|
||||
with open("README.md", "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print("Modified README.md for PyPI (Absolute URLs)")
|
||||
|
||||
# 3. Clean previous builds
|
||||
for folder in ["dist", "build", "ytsage.egg-info"]:
|
||||
if os.path.exists(folder):
|
||||
shutil.rmtree(folder)
|
||||
|
||||
# 4. Run Build
|
||||
print("Building Wheel...")
|
||||
subprocess.check_call([sys.executable, "-m", "build"])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during build: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
finally:
|
||||
# 5. Restore README
|
||||
if os.path.exists("README.md.bak"):
|
||||
# Force move back, overwriting the modified one
|
||||
shutil.move("README.md.bak", "README.md")
|
||||
print("Restored original README.md")
|
||||
|
||||
print("Build Complete. Artifacts are in the /dist folder.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
build_release()
|
||||
@@ -0,0 +1,72 @@
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sagetube"
|
||||
version = "5.5.2"
|
||||
description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage."
|
||||
authors = [
|
||||
{ name = "Houmeres", email = "admin@ecoposta.sk" },
|
||||
{ name = "oop7 (YTSage upstream)", email = "oop7_support@proton.me" },
|
||||
]
|
||||
dependencies = [
|
||||
"PySide6>=6.10.1",
|
||||
"requests>=2.32.5",
|
||||
"pillow>=12.0.0",
|
||||
"packaging>=25.0",
|
||||
"markdown>=3.10",
|
||||
"loguru>=0.7.3",
|
||||
"setuptools>=80.9.0",
|
||||
"python-mpv>=1.0.7",
|
||||
]
|
||||
requires-python = ">=3.10,<3.15"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
'Programming Language :: Python :: 3.14',
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Operating System :: OS Independent",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: MacOS",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Multimedia :: Sound/Audio",
|
||||
"Topic :: Desktop Environment",
|
||||
"Environment :: X11 Applications :: Qt",
|
||||
"Environment :: Win32 (MS Windows)",
|
||||
"Environment :: MacOS X"
|
||||
]
|
||||
|
||||
keywords = ["youtube", "downloader", "video", "audio", "PySide6", "yt-dlp", "GUI"]
|
||||
|
||||
|
||||
[project.scripts]
|
||||
sagetube = "ytsage.main:main"
|
||||
ytsage = "ytsage.main:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.houmeres.sk/Houmeres/SageTube"
|
||||
Bug-Tracker = "https://git.houmeres.sk/Houmeres/SageTube/issues"
|
||||
Upstream = "https://github.com/oop7/YTSage"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["ytsage*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
ytsage = [
|
||||
"assets/Icon/icon.png",
|
||||
"assets/Icon/icon-256.png",
|
||||
"assets/sound/notification.mp3",
|
||||
"languages/*.json",
|
||||
]
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**برنامج تحميل من يوتيوب عصري بواجهة PySide6 أنيقة.**
|
||||
قم بتحميل الفيديوهات بأي جودة، استخراج الصوت، جلب الترجمات، والمزيد.
|
||||
|
||||
### 🌍 لغات README
|
||||
|
||||
الإنجليزية: [EN](../README.md)
|
||||
| العربية: [AR](README.ar.md)
|
||||
| الألمانية: [DE](README.de.md)
|
||||
| الإسبانية: [ES](README.es.md)
|
||||
| الفرنسية: [FR](README.fr.md)
|
||||
| الهندية: [HI](README.hi.md)
|
||||
| الإندونيسية: [ID](README.id.md)
|
||||
| الإيطالية: [IT](README.it.md)
|
||||
| اليابانية: [JA](README.ja.md)
|
||||
| البولندية: [PL](README.pl.md)
|
||||
| البرتغالية: [PT](README.pt.md)
|
||||
| الروسية: [RU](README.ru.md)
|
||||
| التركية: [TR](README.tr.md)
|
||||
| الصينية: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">التثبيت</a> •
|
||||
<a href="#features">المميزات</a> •
|
||||
<a href="#usage">الاستخدام</a> •
|
||||
<a href="#screenshots">لقطات الشاشة</a> •
|
||||
<a href="#troubleshooting">استكشاف الأخطاء</a> •
|
||||
<a href="#sponsor">الدعم</a> •
|
||||
<a href="#contributing">المساهمة</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ytsage"></a>
|
||||
## ❓ لماذا YTSage؟
|
||||
|
||||
تم تصميم YTSage للمستخدمين الذين يريدون **أداة تحميل يوتيوب بسيطة لكن قوية**. على عكس الأدوات الأخرى، فإنه يوفر:
|
||||
|
||||
- واجهة PySide6 عصرية ونظيفة
|
||||
- تحميل بنقرة واحدة للفيديو والصوت والترجمات
|
||||
- ميزات متقدمة مثل SponsorBlock، دمج الترجمات، واختيار قوائم التشغيل
|
||||
- وضع عام (Generic Mode) اختياري للمواقع التي يدعمها yt-dlp بخلاف يوتيوب
|
||||
- دعم منصات متعددة وتثبيت سهل
|
||||
|
||||
<a id="features"></a>
|
||||
## ✨ المميزات
|
||||
|
||||
<div align="center">
|
||||
|
||||
| الميزات الأساسية | الميزات المتقدمة | ميزات إضافية |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 جدول الصيغ | 🚫 تكامل SponsorBlock | 🎞️ عرض FPS/HDR |
|
||||
| 🎵 استخراج الصوت | 📝 اختيار ودمج الترجمات | 🔄 تحديث تلقائي لـ yt-dlp |
|
||||
| ✨ واجهة مستخدم بسيطة | 💾 حفظ الوصف والصورة المصغرة | 🛠️ اكتشاف FFmpeg/yt-dlp/Deno |
|
||||
| 📋 دعم ومحدد قوائم التشغيل | 🚀 محدد السرعة | ⚙️ أوامر مخصصة |
|
||||
| 📑 دمج الفصول | ✂️ قص أجزاء الفيديو | 🍪 تسجيل الدخول بالكوكيز |
|
||||
| 📜 سجل التحميلات | 🔄 اختيار قناة الإصدار | 🌐 دعم البروكسي |
|
||||
| 🎚️ تحويل صيغ الصوت | 🎬 إعدادات صيغ الفيديو | 🆙 تبويب تحديث مدمج |
|
||||
| 🌍 الوضع العام | 🔊 تطبيع الصوت (EBU R128) | 🌍 دعم 14 لغة |
|
||||
| 💾 تصدير قوائم التشغيل | ⚙️ الجودة والترجمات الافتراضية | |
|
||||
</div>
|
||||
|
||||
<a id="installation"></a>
|
||||
## 🚀 التثبيت
|
||||
|
||||
### ⚡ التثبيت السريع (موصى به)
|
||||
|
||||
تثبيت YTSage عبر PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 تحديث نسخة مثبتة</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
ثم قم بتشغيل التطبيق:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 حزم جاهزة للتشغيل
|
||||
|
||||
> [👉 تحميل أحدث إصدار](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| الصيغة | الوصف |
|
||||
|--------|-------------|
|
||||
|  | مثبت قياسي |
|
||||
|  | مع FFmpeg مدمج |
|
||||
|  | نسخة محمولة، لا تحتاج لتثبيت |
|
||||
|  | محمولة مع FFmpeg، مضغوطة |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ خطوات التثبيت</summary>
|
||||
|
||||
1. **مثبت EXE (`.exe`)**: انقر نقرًا مزدوجًا على الملف واتبع معالج التثبيت.
|
||||
2. **النسخة المحمولة (`.zip`)**: استخرج الملف في أي مكان وشغل `ytsage.exe`.
|
||||
3. **FFmpeg المدمج**: اختر النسخة التي تحتوي على FFmpeg إذا لم يكن مثبتًا على نظامك.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| الصيغة | الوصف |
|
||||
|--------|-------------|
|
||||
|  | حزمة Debian |
|
||||
|  | AppImage، محمولة |
|
||||
|  | حزمة RPM |
|
||||
|  | Flatpak Bundle |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ خطوات التثبيت</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # لإصلاح الاعتمادات الناقصة إذا لزم الأمر
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: اتبع التعليمات على Flathub أو شغل:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| الصيغة | الوصف |
|
||||
|--------|-------------|
|
||||
|  | تطبيق مضغوط لـ Apple Silicon |
|
||||
|  | صورة قرص لـ Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ خطوات التثبيت</summary>
|
||||
|
||||
- **مثبت DMG (`.dmg`)**: انقر نقرًا مزدوجًا لفتح القرص، ثم اسحب `YTSage.app` إلى مجلد Applications.
|
||||
- **تطبيق مضغوط (`.zip`)**: استخرج الملف وانقل `YTSage.app` إلى مجلد Applications.
|
||||
|
||||
*ملاحظة: إذا واجهت خطأ "التطبيق تالف"، راجع قسم استكشاف الأخطاء لنظام macOS أدناه.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 التثبيت اليدوي من المصدر</summary>
|
||||
|
||||
### 1. نسخ المستودع
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. تثبيت الاعتمادات
|
||||
|
||||
#### ⚡ باستخدام uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 أو باستخدام pip العادي
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. تشغيل التطبيق
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
## 📸 لقطات الشاشة
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="إعدادات التحميل" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="تحميل قوائم التشغيل" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>إعدادات التحميل</em></td>
|
||||
<td align="center"><em>تحميل قوائم التشغيل</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="اختيار صيغة الصوت" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="خيارات مخصصة" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>صيغة الصوت</em></td>
|
||||
<td align="center"><em>خيارات مخصصة</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="usage"></a>
|
||||
## 📖 الاستخدام
|
||||
|
||||
<details>
|
||||
<summary>🎯 الاستخدام الأساسي</summary>
|
||||
|
||||
1. **شغل YTSage**
|
||||
2. **الصق رابط يوتيوب** (أو استخدم زر "Paste URL")
|
||||
3. **اضغط على "Analyze"**
|
||||
4. **اختر الصيغة:**
|
||||
- `Video` للتحميلات المرئية
|
||||
- `Audio Only` لاستخراج الصوت فقط
|
||||
5. **اختر الخيارات:**
|
||||
- تفعيل الترجمات واختيار اللغة
|
||||
- تفعيل دمج الترجمات
|
||||
- حفظ الصورة المصغرة
|
||||
- حذف المقاطع الإعلانية (Sponsor segments)
|
||||
- حفظ الوصف
|
||||
- دمج الفصول (Chapters)
|
||||
6. **اختر مجلد الحفظ**
|
||||
7. **اضغط على "Download"**
|
||||
|
||||
> 💡 مجلد التحميل الافتراضي هو مجلد "Downloads" للمستخدم.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 تحميل قائمة تشغيل (Playlist)</summary>
|
||||
|
||||
1. **الصق رابط قائمة التشغيل**
|
||||
2. **اضغط على "Analyze"**
|
||||
3. **اختر الفيديوهات من محدد القائمة (اختياري، يتم اختيار الكل تلقائيًا)**
|
||||
4. **اختر الصيغة/الجودة المطلوبة**
|
||||
5. **اضغط على "Download"**
|
||||
|
||||
> 💡 التطبيق يتعامل مع طابور التحميل تلقائيًا، ويمكنك تصدير مدخلات القائمة كملفات `.txt` أو `.csv` أو `.m3u` أو `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 الوضع العام للمواقع غير يوتيوب</summary>
|
||||
|
||||
استخدم الوضع العام (Generic Mode) عندما تريد أن يقبل YTSage روابط من المواقع التي يدعمها yt-dlp، مثل Dailymotion و CBC Gem و TikTok وغيرها.
|
||||
|
||||
كيفية الاستخدام:
|
||||
|
||||
1. افتح `Download Settings`.
|
||||
2. فعل `Generic Mode`.
|
||||
3. الصق رابط فيديو أو قائمة تشغيل مدعومة غير يوتيوب.
|
||||
4. اضغط على `Analyze`.
|
||||
5. اختر الصيغة وحمل كالمعتاد.
|
||||
|
||||
ملاحظات:
|
||||
|
||||
- الوضع العام يغير فقط التحقق من الرابط داخل YTSage. يجب أن يكون الموقع مدعومًا من نسخة yt-dlp المثبتة لديك.
|
||||
- بعض المواقع تتطلب كوكيز، تسجيل دخول، بروكسي أو وسائط yt-dlp إضافية حسب نوع الموقع.
|
||||
- إذا فشل موقع ما، حدث yt-dlp أولاً من تبويب التحديث المدمج قبل الإبلاغ عن المشكلة.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 خيارات الميديا والتحميل</summary>
|
||||
|
||||
- **خيارات الترجمة:** تصفية اللغات وتضمين الترجمات في ملف الفيديو.
|
||||
- **دمج الترجمات:** دمج الترجمات داخل ملف الفيديو لتكون ترجمة مدمجة (Hardcoded).
|
||||
- **حفظ الوصف:** حفظ وصف الفيديو كملف نصي.
|
||||
- **حفظ الصورة المصغرة:** حفظ صورة الفيديو كملف صورة.
|
||||
- **دمج الفصول:** تضمين علامات الفصول كبيانات وصفية لمشغلات الفيديو المتوافقة.
|
||||
- **حذف مقاطع الرعاة:** حذف الأجزاء الإعلانية من الفيديو باستخدام SponsorBlock.
|
||||
- **قص الفيديو:** تحميل أجزاء محددة فقط من الفيديو عن طريق تحديد أوقات البداية والنهاية بصيغة `HH:MM:SS`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ إعدادات الإخراج والملفات</summary>
|
||||
|
||||
- **محدد السرعة:** تحديد سرعة التحميل، مثلاً `500K` لـ 500 كيلوبايت/ثانية.
|
||||
- **حفظ مسار التحميل:** يحفظ مسار التحميل الافتراضي للتحميلات المستقبلية. متاح في **Download Settings → Download Path**.
|
||||
- **دقة الفيديو الافتراضية:** حدد دقة الفيديو المفضلة للاختيار التلقائي (مثل 1080p أو 720p). متاح في **Download Settings → Default Video Resolution**.
|
||||
- **لغات الترجمة الافتراضية:** حدد لغات الترجمة للاختيار التلقائي (مفصولة بفواصل، مثل `ar,en`). متاح في **Download Settings → Default Subtitle Languages**.
|
||||
- **صيغة اسم الملف:** تخصيص صيغة اسم الملف باستخدام متغيرات مثل `%(title)s` و `%(uploader)s` و `%(playlist_index)s` و `%(resolution)s`. متاح في **Download Settings → Filename Format**.
|
||||
- **فرض صيغة الإخراج:** فرض تحميل الفيديو بصيغة محددة مثل `mp4` أو `webm` أو `mkv`. متاح في **Download Settings → Output Format Settings**.
|
||||
- **تحويل صيغ الصوت:** تحويل تحميلات الصوت فقط للصيغ المفضلة مثل `AAC` أو `MP3` أو `FLAC` أو `WAV` أو `Opus` أو `M4A` أو `Vorbis` أو `Best`. متاح في **Download Settings → Audio Format Settings**.
|
||||
- **تطبيع الصوت:** توحيد مستوى الصوت لتحميلات الصوت فقط باستخدام EBU R128.
|
||||
- **الاتصالات المتزامنة:** زيادة سرعة التحميل بشكل كبير عن طريق تحميل الملف في أجزاء متعددة في وقت واحد. متاح في **Download Settings → General → Concurrent Connections** (القيمة الافتراضية 1، الموصى به 8-10 لتجنب حظر IP).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 الوصول والشبكة</summary>
|
||||
|
||||
- **تسجيل الدخول بالكوكيز:** سجل الدخول ليوتيوب باستخدام الكوكيز للوصول للمحتوى الخاص.
|
||||
كيفية الاستخدام:
|
||||
1. **موصى به:** استخدم خيار `Extract cookies from browser` المدمج في التطبيق، ثم اختر المتصفح والملف الشخصي.
|
||||
2. بدلاً من ذلك، استخرج الكوكيز يدويًا:
|
||||
أ. صدر الكوكيز من متصفحك باستخدام إضافة مثل [cookie-editor](https://github.com/moustachauve/cookie-editor)
|
||||
ب. انسخ الكوكيز بصيغة Netscape
|
||||
ج. أنشئ ملفًا باسم `cookies.txt` والصق الكوكيز فيه
|
||||
د. اختر ملف `cookies.txt` في التطبيق
|
||||
- **دعم البروكسي:** استخدم خادم بروكسي للتحميلات، مثلاً `http://<proxy-server>:<port>`
|
||||
- **الوضع العام:** يسمح لـ YTSage بتحليل والتحميل من المواقع غير يوتيوب التي يدعمها yt-dlp. فعله من **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ الأدوات والصيانة</summary>
|
||||
|
||||
- **أوامر مخصصة:** الوصول لميزات yt-dlp المتقدمة عبر وسائط موجه الأوامر.
|
||||
- **تبويب التحديث:** إدارة أدوات التحديث من مكان واحد في الخيارات المخصصة:
|
||||
- **تحديثات yt-dlp:** التحقق من التحديثات والتبديل بين القناة المستقرة (Stable) والليلية (Nightly).
|
||||
- **فاحص نسخة FFmpeg:** التحقق من نسخة FFmpeg وعرض أدلة التثبيت.
|
||||
- **تحديثات Deno:** التحقق وتحديث محرك Deno.
|
||||
- **اكتشاف FFmpeg/yt-dlp/Deno:** يكتشف تلقائيًا المسارات والنسخ لـ FFmpeg و yt-dlp و Deno من نافذة "About".
|
||||
- **سجل التحميلات:** عرض التحميلات السابقة مع الصور المصغرة والحالة من زر **History**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 الترجمة</summary>
|
||||
|
||||
يدعم YTSage **14 لغة** للوصول العالمي. اختر لغتك المفضلة من **Custom Options → Language**.
|
||||
|
||||
### اللغات المدعومة
|
||||
|
||||
| اللغة | الرمز | اللغة | الرمز |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 الإنجليزية | `en` | 🇪🇸 الإسبانية | `es` |
|
||||
| 🇸🇦 العربية | `ar` | 🇫🇷 الفرنسية | `fr` |
|
||||
| 🇩🇪 الألمانية | `de` | 🇮🇳 الهندية | `hi` |
|
||||
| 🇮🇩 الإندونيسية | `id` | 🇮🇹 الإيطالية | `it` |
|
||||
| 🇯🇵 اليابانية | `ja` | 🇵🇱 البولندية | `pl` |
|
||||
| 🇧🇷 البرتغالية | `pt` | 🇷🇺 الروسية | `ru` |
|
||||
| 🇹🇷 التركية | `tr` | 🇨🇳 الصينية | `zh` |
|
||||
|
||||
### تراجم README
|
||||
|
||||
| اللغة | الملف | اللغة | الملف |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 الإنجليزية | [README.md](../README.md) | 🇪🇸 الإسبانية | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 العربية | [README.ar.md](README.ar.md) | 🇫🇷 الفرنسية | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 الألمانية | [README.de.md](README.de.md) | 🇮🇳 الهندية | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 الإندونيسية | [README.id.md](README.id.md) | 🇮🇹 الإيطالية | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 اليابانية | [README.ja.md](README.ja.md) | 🇵🇱 البولندية | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 البرتغالية | [README.pt.md](README.pt.md) | 🇷🇺 الروسية | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 التركية | [README.tr.md](README.tr.md) | 🇨🇳 الصينية | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **هل تريد المساعدة في الترجمة؟** راجع قسم [المساهمة](#contributing) لمساعدتنا في إضافة المزيد من اللغات!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ استكشاف الأخطاء
|
||||
|
||||
<details>
|
||||
<summary>اضغط لعرض المشاكل الشائعة وحلولها</summary>
|
||||
|
||||
- **جدول الصيغ لا يظهر:** حدث yt-dlp لأحدث نسخة وانتقل للقناة الليلية (Nightly).
|
||||
- **فشل التحميل:** تحقق من اتصال الإنترنت وتأكد أن الفيديو متاح.
|
||||
- **أخطاء تحميل محددة:**
|
||||
- **فيديوهات خاصة:** استخدم الكوكيز للوصول للمحتوى الخاص.
|
||||
- **محتوى مقيد بالعمر:** سجل دخولك ليوتيوب لمشاهدة الفيديوهات المقيدة.
|
||||
- **فيديوهات محظورة جغرافيًا:** استخدم VPN لتجاوز القيود الإقليمية.
|
||||
- **فيديوهات محذوفة:** الفيديو لم يعد متاحًا على يوتيوب.
|
||||
- **البث المباشر (Live):** لا يمكن تحميل البث المباشر أثناء عرضه؛ انتظر حتى ينتهي.
|
||||
- **أخطاء الشبكة:** تحقق من اتصالك وحاول مرة أخرى.
|
||||
- **روابط غير صالحة:** تأكد من صحة الرابط وأنه من منصة مدعومة.
|
||||
- **محتوى Premium:** يتطلب اشتراك يوتيوب بريميوم.
|
||||
- **حظر حقوق الملكية:** المحتوى محظور بسبب قيود حقوق النشر.
|
||||
- **ملفات الفيديو والصوت منفصلة بعد التحميل:** يحدث هذا عند فقدان FFmpeg أو عدم اكتشافه. يتطلب YTSage برنامج FFmpeg لدمج مسارات الفيديو والصوت عالية الجودة.
|
||||
- **الحل:** تأكد من تثبيت FFmpeg وإضافته لـ PATH النظام. لمستخدمي ويندوز، الخيار الأسهل هو تحميل ملف `YTSage-v<version>-ffmpeg.exe` الذي يأتي مع FFmpeg مدمجًا.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ تنبيه Windows Defender / الأنتي فيروس
|
||||
|
||||
قد تصنف بعض برامج الحماية ملفات `.exe` كبرمجيات ضارة (False Positive). هذا **عيب معروف** في التطبيقات المحزمة.
|
||||
|
||||
**لماذا يحدث هذا:**
|
||||
- أساليب الكشف في مضادات الفيروسات قد تعتبر الملفات المحزمة مشبوهة خطأً.
|
||||
|
||||
**البدائل الآمنة:**
|
||||
- ✅ **استخدم تثبيت pip:** `pip install ytsage` (موصى به)
|
||||
- ✅ **البناء من المصدر:** باتباع هذا [الدليل](../.github/CI_CD_README.md)
|
||||
- ✅ **إضافة التطبيق للقائمة البيضاء** في برنامج الحماية لديك.
|
||||
|
||||
#### 🍎 macOS: "The app is damaged and can’t be opened"
|
||||
إذا واجهت هذا الخطأ على macOS Sonoma أو أحدث، يجب عليك إزالة سمة الحجر الصحي.
|
||||
|
||||
1. **افتح الـ Terminal** (يمكنك البحث عنه عبر Spotlight).
|
||||
2. **اكتب الأمر التالي** ولكن **لا تضغط** Enter بعد. تأكد من وجود مسافة في النهاية:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **اسحب ملف `YTSage.app`** من نافذة Finder وأفلته في نافذة الـ Terminal. سيقوم بلصق المسار الصحيح تلقائيًا.
|
||||
4. **اضغط Enter** لتنفيذ الأمر.
|
||||
5. **حاول فتح YTSage.app مجددًا.** يجب أن يعمل الآن بشكل صحيح.
|
||||
|
||||
---
|
||||
|
||||
#### **مواقع الإعدادات (للمتقدمين)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 الدعم
|
||||
|
||||
إذا وفر لك YTSage الوقت، يرجى التفكير في دعم المشروع. يساعد الدعم في تغطية وقت التطوير، الاختبار على المنصات المختلفة، والتحسينات المستقبلية.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- رابط الدعم متاح أيضًا داخل التطبيق عبر نافذة "About".
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contributing"></a>
|
||||
## 👥 المساهمة
|
||||
|
||||
نرحب بالمساهمات! إليك كيف يمكنك المساعدة:
|
||||
|
||||
1. 🍴 عمل Fork للمستودع
|
||||
2. 🌿 إنشاء فرع جديد للميزة:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 حفظ التغييرات:
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 رفع التغييرات:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 فتح Pull Request
|
||||
|
||||
### 🌍 المساهمة في الترجمة
|
||||
|
||||
- حدث ملف README المترجم (مثل `readme-translations/README.ar.md`)
|
||||
- حافظ على تزامن نصوص التطبيق بتعديل `ytsage/languages/<code>.json`
|
||||
- إذا كانت لغتك غير موجودة، ابدأ من `README.md` وأنشئ ملفًا جديدًا باسم `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 هيكل المشروع</summary>
|
||||
|
||||
## YTSage - هيكل المشروع
|
||||
|
||||
يوضح هذا المستند التنظيم الهيكلي لمجلدات YTSage.
|
||||
|
||||
### 📁 هيكل المشروع
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # إعدادات GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # نماذج التذاكر
|
||||
│ │ └── 🐛-bug-report.md # نموذج تقرير الأخطاء
|
||||
│ ├─── 📁 workflows/ # سير عمل GitHub Actions
|
||||
│ │ ├── build-linux.yml # بناء نسخة لينكس
|
||||
│ │ ├── build-macos.yml # بناء نسخة ماك
|
||||
│ │ │── build-windows.yml # بناء نسخة ويندوز
|
||||
| | └── release-all.yml # سير عمل الإصدارات
|
||||
│ └── 📄 CI_CD_README.md # توثيق CI/CD
|
||||
├── 📁 branding/ # أصول العلامة التجارية (لقطات شاشة، SVG)
|
||||
│ ├── 📁 icons/ # أيقونات التطبيق
|
||||
│ ├── 📁 screenshots/ # لقطات شاشة للتوثيق
|
||||
│ └── 📁 svg/ # أصول بصيغة SVG
|
||||
├── 📄 LICENSE # ملف الرخصة
|
||||
├── 📄 pyproject.toml # بيانات المشروع والاعتمادات
|
||||
├── 📄 README.md # التوثيق الرئيسي
|
||||
├── 📄 requirements.txt # اعتمادات بايثون (للمطورين)
|
||||
└── 📁 ytsage/ # مجلد الأكواد المصدرية
|
||||
├── 📁 assets/ # الأصول اللازمة للتشغيل
|
||||
│ ├── 📁 Icon/ # أيقونات التطبيق
|
||||
│ └── 📁 sound/ # ملفات صوتية
|
||||
├── 📁 languages/ # ملفات الترجمة
|
||||
│ ├── 📄 ar.json # الترجمة العربية
|
||||
│ ├── 📄 de.json # الترجمة الألمانية
|
||||
│ ├── 📄 en.json # الترجمة الإنجليزية
|
||||
│ └── ... # لغات أخرى
|
||||
├── 📁 core/ # المنطق البرمجي الأساسي
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # تكامل Deno
|
||||
│ ├── 📄 ytsage_downloader.py # وظائف التحميل
|
||||
│ ├── 📄 ytsage_ffmpeg.py # تكامل FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # وظائف مساعدة
|
||||
│ └── 📄 ytsage_yt_dlp.py # تكامل yt-dlp
|
||||
├── 📁 gui/ # مكونات واجهة المستخدم
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # نافذة التطبيق الرئيسية
|
||||
│ └── 📁 ytsage_gui_dialogs/ # كلاسات النوافذ الحوارية
|
||||
├── 📁 utils/ # موديلات مساعدة
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # مدير الإعدادات
|
||||
│ └── 📄 ytsage_logger.py # أداة تسجيل السجلات
|
||||
├── 📄 __init__.py # نقطة دخول الحزمة
|
||||
└── 📄 main.py # سكربت التشغيل الرئيسي
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ سجل النجوم
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 الرخصة
|
||||
|
||||
هذا المشروع مرخص بموجب رخصة MIT - راجع ملف [LICENSE](../LICENSE) لمزيد من التفاصيل.
|
||||
|
||||
## 🙏 شكر وتقدير
|
||||
|
||||
<details>
|
||||
<summary>عرض الشكر والتقدير</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>شكر كبير لكل من ساهم في هذا المشروع بفتح تذكرة لاقتراح تحسين أو الإبلاغ عن خطأ.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">المكونات الأساسية</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>محرك التحميل</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>معالجة الوسائط</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>بيئة التشغيل لتكامل yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">المكتبات وإطارات العمل</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>إطار عمل الواجهة الرسومية</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>معالجة الصور</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>طلبات HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>إدارة الإصدارات والحزم</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>معالجة Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>تسجيل السجلات</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">الأصول والمساهمون</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>صوت التنبيه</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>مساهم بالكود</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ إخلاء مسؤولية
|
||||
|
||||
تُستخدم هذه الأداة للاستخدام الشخصي فقط. يرجى احترام شروط خدمة يوتيوب وحقوق منشئي المحتوى.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
صنع بـ ❤️ بواسطة [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Ein moderner YouTube-Downloader mit einer eleganten PySide6-Benutzeroberfläche.**
|
||||
Downloaden Sie Videos in jeder Qualität, extrahieren Sie Audio, rufen Sie Untertitel ab und vieles mehr.
|
||||
|
||||
### 🌍 README-Sprachen
|
||||
|
||||
Englisch: [EN](../README.md)
|
||||
| Arabisch: [AR](README.ar.md)
|
||||
| Deutsch: [DE](README.de.md)
|
||||
| Spanisch: [ES](README.es.md)
|
||||
| Französisch: [FR](README.fr.md)
|
||||
| Hindi: [HI](README.hi.md)
|
||||
| Indonesisch: [ID](README.id.md)
|
||||
| Italienisch: [IT](README.it.md)
|
||||
| Japanisch: [JA](README.ja.md)
|
||||
| Polnisch: [PL](README.pl.md)
|
||||
| Portugiesisch: [PT](README.pt.md)
|
||||
| Russisch: [RU](README.ru.md)
|
||||
| Türkisch: [TR](README.tr.md)
|
||||
| Chinesisch: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#features">Funktionen</a> •
|
||||
<a href="#usage">Bedienung</a> •
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#troubleshooting">Fehlerbehebung</a> •
|
||||
<a href="#sponsor">Sponsoren</a> •
|
||||
<a href="#contributing">Mitwirken</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ytsage"></a>
|
||||
## ❓ Warum YTSage?
|
||||
|
||||
YTSage wurde für Benutzer entwickelt, die einen **einfachen, aber leistungsstarken YouTube-Downloader** suchen. Im Gegensatz zu anderen Tools bietet es:
|
||||
|
||||
- Eine moderne und übersichtliche PySide6-Oberfläche
|
||||
- Ein-Klick-Downloads für Video, Audio und Untertitel
|
||||
- Erweiterte Funktionen wie SponsorBlock, Zusammenführen von Untertiteln und Playlist-Auswahl
|
||||
- Optionaler generischer Modus (Generic Mode) für von yt-dlp unterstützte Seiten außerhalb von YouTube
|
||||
- Plattformübergreifende Unterstützung und einfache Installation
|
||||
|
||||
<a id="features"></a>
|
||||
## ✨ Funktionen
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Basisfunktionen | Erweiterte Funktionen | Zusätzliche Funktionen |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Formattabelle | 🚫 SponsorBlock-Integration | 🎞️ FPS/HDR-Anzeige |
|
||||
| 🎵 Audio-Extraktion | 📝 Untertitel-Auswahl & Merging | 🔄 Automatisches yt-dlp Update |
|
||||
| ✨ Einfache Benutzeroberfläche | 💾 Beschreibung & Thumbnail speichern | 🛠️ FFmpeg/yt-dlp/Deno Erkennung |
|
||||
| 📋 Playlist-Support & Selector | 🚀 Geschwindigkeitsbegrenzer | ⚙️ Benutzerdefinierte Befehle |
|
||||
| 📑 Kapitel-Integration | ✂️ Videoabschnitte zuschneiden | 🍪 Cookie-Login |
|
||||
| 📜 Download-Historie | 🔄 Release-Kanal Auswahl | 🌐 Proxy-Support |
|
||||
| 🎚️ Audioformat-Konvertierung | 🎬 Videoformat-Einstellungen | 🆙 Integrierter Update-Tab |
|
||||
| 🌍 Generischer Modus | 🔊 Audio-Normalisierung (EBU R128) | 🌍 In 14 Sprachen lokalisiert |
|
||||
| 💾 Playlist-Export | ⚙️ Standardqualität & Untertitel | |
|
||||
</div>
|
||||
|
||||
<a id="installation"></a>
|
||||
## 🚀 Installation
|
||||
|
||||
### ⚡ Schnelle Installation (Empfohlen)
|
||||
|
||||
Installieren Sie YTSage über PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Bestehende Installation aktualisieren</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Starten Sie dann die Anwendung:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Vorgefertigte ausführbare Dateien
|
||||
|
||||
> [👉 Neuestes Release herunterladen](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Format | Beschreibung |
|
||||
|--------|-------------|
|
||||
|  | Standard-Installer |
|
||||
|  | Mit integriertem FFmpeg |
|
||||
|  | Portable Version, keine Installation erforderlich |
|
||||
|  | Portable Version mit FFmpeg, gepackt |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installationsschritte</summary>
|
||||
|
||||
1. **EXE-Installer (`.exe`)**: Doppelklicken Sie auf die Datei und folgen Sie dem Installationsassistenten.
|
||||
2. **Portable Version (`.zip`)**: Entpacken Sie das Archiv an den gewünschten Ort und starten Sie `ytsage.exe`.
|
||||
3. **Integriertes FFmpeg**: Wählen Sie die Versionen mit integriertem FFmpeg, falls FFmpeg nicht bereits auf Ihrem System installiert ist.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Format | Beschreibung |
|
||||
|--------|-------------|
|
||||
|  | Debian-Paket |
|
||||
|  | AppImage, portabel |
|
||||
|  | RPM-Paket |
|
||||
|  | Flatpak Bundle |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installationsschritte</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Repariert fehlende Abhängigkeiten, falls nötig
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Folgen Sie den Anweisungen auf Flathub oder führen Sie aus:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Format | Beschreibung |
|
||||
|--------|-------------|
|
||||
|  | Gepackte App für Apple Silicon |
|
||||
|  | Disk Image Installer für Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installationsschritte</summary>
|
||||
|
||||
- **DMG-Installer (`.dmg`)**: Doppelklick zum Mounten, dann `YTSage.app` in den Programme-Ordner ziehen.
|
||||
- **App-Archiv (`.zip`)**: Zip entpacken und `YTSage.app` in den Programme-Ordner verschieben.
|
||||
|
||||
*Hinweis: Wenn Sie die Fehlermeldung "App ist beschädigt" erhalten, lesen Sie bitte den Abschnitt zur Fehlerbehebung für macOS weiter unten.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Manuelle Installation aus dem Quellcode</summary>
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Abhängigkeiten installieren
|
||||
|
||||
#### ⚡ Mit uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Oder mit Standard-pip
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Anwendung starten
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Download-Einstellungen" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Playlist-Download" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Download-Einstellungen</em></td>
|
||||
<td align="center"><em>Playlist-Download</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Audioformat-Auswahl" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Benutzerdefinierte Optionen" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Audioformat</em></td>
|
||||
<td align="center"><em>Benutzerdefinierte Optionen</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="usage"></a>
|
||||
## 📖 Bedienung
|
||||
|
||||
<details>
|
||||
<summary>🎯 Grundlegende Bedienung</summary>
|
||||
|
||||
1. **YTSage starten**
|
||||
2. **YouTube-URL einfügen** (oder die Schaltfläche "Paste URL" verwenden)
|
||||
3. **Auf "Analyze" klicken**
|
||||
4. **Format auswählen:**
|
||||
- `Video` für Video-Downloads
|
||||
- `Audio Only` für Audio-Extraktion
|
||||
5. **Optionen wählen:**
|
||||
- Untertitel aktivieren & Sprache auswählen
|
||||
- Untertitel-Merging aktivieren
|
||||
- Thumbnail speichern
|
||||
- Sponsoren-Segmente entfernen
|
||||
- Beschreibung speichern
|
||||
- Kapitel integrieren
|
||||
6. **Ausgabeverzeichnis wählen**
|
||||
7. **Auf "Download" klicken**
|
||||
|
||||
> 💡 Das Standard-Download-Verzeichnis ist der "Downloads"-Ordner des Benutzers.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Playlist-Download</summary>
|
||||
|
||||
1. **Playlist-URL einfügen**
|
||||
2. **Auf "Analyze" klicken**
|
||||
3. **Videos aus dem Playlist-Selector auswählen (optional, standardmäßig alle)**
|
||||
4. **Gewünschtes Format/Qualität wählen**
|
||||
5. **Auf "Download" klicken**
|
||||
|
||||
> 💡 Die Anwendung verwaltet die Download-Warteschlange automatisch, und Sie können Playlist-Einträge als `.txt`, `.csv`, `.m3u` oder `.json` exportieren.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Generischer Modus für Nicht-YouTube-Seiten</summary>
|
||||
|
||||
Verwenden Sie den generischen Modus (Generic Mode), wenn YTSage URLs von anderen von yt-dlp unterstützten Seiten wie Dailymotion, CBC Gem, TikTok und anderen akzeptieren soll.
|
||||
|
||||
So verwenden Sie ihn:
|
||||
|
||||
1. Öffnen Sie `Download Settings`.
|
||||
2. Aktivieren Sie `Generic Mode`.
|
||||
3. Fügen Sie eine unterstützte Video- oder Playlist-URL ein, die nicht von YouTube stammt.
|
||||
4. Klicken Sie auf `Analyze`.
|
||||
5. Wählen Sie ein Format und laden Sie es wie gewohnt herunter.
|
||||
|
||||
Hinweise:
|
||||
|
||||
- Der generische Modus ändert nur die URL-Validierung innerhalb von YTSage. Die Zielseite muss weiterhin von Ihrer installierten yt-dlp-Version unterstützt werden.
|
||||
- Einige Seiten erfordern Cookies, Login, Proxy oder zusätzliche yt-dlp-Argumente je nach Extractor.
|
||||
- Wenn eine Seite fehlschlägt, aktualisieren Sie zuerst yt-dlp über den integrierten Update-Tab, bevor Sie das Problem melden.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Medien- & Download-Optionen</summary>
|
||||
|
||||
- **Untertitel-Optionen:** Sprachen filtern und Untertitel in die Videodatei einbetten.
|
||||
- **Untertitel-Merging:** Mergt Untertitel in die Videodatei für fest eingebrannte (hardcoded) Untertitel.
|
||||
- **Beschreibung speichern:** Speichert die Videobeschreibung als Textdatei.
|
||||
- **Thumbnail speichern:** Speichert das Video-Thumbnail als Bilddatei.
|
||||
- **Kapitel integrieren:** Bettet Kapitelmarken als Metadaten für kompatible Videoplayer ein.
|
||||
- **Sponsoren-Segmente entfernen:** Entfernt gesponserte Abschnitte aus dem Video mithilfe von SponsorBlock.
|
||||
- **Video zuschneiden:** Laden Sie nur bestimmte Teile eines Videos herunter, indem Sie Zeitbereiche im Format `HH:MM:SS` angeben.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Ausgabe- & Dateieinstellungen</summary>
|
||||
|
||||
- **Geschwindigkeitsbegrenzer:** Begrenzt die Download-Geschwindigkeit, z. B. `500K` für 500 KB/s.
|
||||
- **Download-Pfad speichern:** Speichert den Standard-Download-Pfad für zukünftige Downloads. Verfügbar unter **Download Settings → Download Path**.
|
||||
- **Standard-Videoauflösung:** Legen Sie Ihre bevorzugte Videoauflösung für die automatische Auswahl fest (z. B. 1080p, 720p). Verfügbar unter **Download Settings → Default Video Resolution**.
|
||||
- **Standard-Untertitelsprachen:** Legen Sie Standard-Untertitelsprachen für die automatische Auswahl fest (kommagetrennt, z. B. `de,en`). Verfügbar unter **Download Settings → Default Subtitle Languages**.
|
||||
- **Dateinamen-Format:** Passen Sie das Format des Ausgabedateinamen mit Variablen wie `%(title)s`, `%(uploader)s`, `%(playlist_index)s` und `%(resolution)s` an. Verfügbar unter **Download Settings → Filename Format**.
|
||||
- **Ausgabeformat erzwingen:** Erzwingt Video-Downloads in ein bestimmtes Containerformat wie `mp4`, `webm` oder `mkv`. Verfügbar unter **Download Settings → Output Format Settings**.
|
||||
- **Audioformat-Konvertierung:** Konvertiert reine Audio-Downloads in bevorzugte Formate wie `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis` oder `Best`. Verfügbar unter **Download Settings → Audio Format Settings**.
|
||||
- **Audio-Normalisierung:** Standardisiert die Lautstärke für reine Audio-Downloads unter Verwendung von EBU R128.
|
||||
- **Gleichzeitige Verbindungen:** Erhöhen Sie die Download-Geschwindigkeit erheblich, indem Sie Dateien in mehreren Fragmenten gleichzeitig herunterladen. Verfügbar unter **Download Settings → General → Concurrent Connections** (Standardmäßig 1, maximal 8-10 empfohlen, um IP-Sperren zu vermeiden).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Zugriff & Netzwerk</summary>
|
||||
|
||||
- **Cookie-Login:** Loggen Sie sich mit Cookies bei YouTube ein, um auf private Inhalte zuzugreifen.
|
||||
Verwendung:
|
||||
1. **Empfohlen:** Verwenden Sie die integrierte Option `Extract cookies from browser` in der App, wählen Sie dann Ihren Browser und optional ein Profil.
|
||||
2. Alternativ können Sie Cookies manuell extrahieren:
|
||||
a. Exportieren Sie Cookies aus Ihrem Browser mit einer Erweiterung wie [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Kopieren Sie die Cookies im Netscape-Format
|
||||
c. Erstellen Sie eine Datei namens `cookies.txt` und fügen Sie die Cookies ein
|
||||
d. Wählen Sie die Datei `cookies.txt` in der App aus
|
||||
- **Proxy-Support:** Verwenden Sie einen Proxy-Server für Downloads, z. B. `http://<proxy-server>:<port>`
|
||||
- **Generischer Modus:** Ermöglicht YTSage das Analysieren und Herunterladen von anderen von yt-dlp unterstützten Seiten. Aktivieren Sie dies unter **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Tools & Wartung</summary>
|
||||
|
||||
- **Benutzerdefinierte Befehle:** Greifen Sie auf erweiterte yt-dlp-Funktionen über Befehlszeilenargumente zu.
|
||||
- **Update-Tab:** Verwalten Sie integrierte Update-Tools an einem Ort in den benutzerdefinierten Optionen:
|
||||
- **yt-dlp Updates:** Suchen Sie nach Updates und wechseln Sie zwischen Stable- und Nightly-Release-Kanälen.
|
||||
- **FFmpeg-Versionsprüfung:** Überprüfen Sie Ihre FFmpeg-Version und öffnen Sie Installationsanleitungen.
|
||||
- **Deno-Updates:** Überprüfen und aktualisieren Sie die Deno-Laufzeitumgebung.
|
||||
- **FFmpeg/yt-dlp/Deno Erkennung:** Erkennt Pfade und Versionen von FFmpeg, yt-dlp und Deno automatisch aus dem About-Dialog.
|
||||
- **Download-Historie:** Zeigt vergangene Downloads mit Thumbnails und Status über die Schaltfläche **History** an.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Lokalisierung</summary>
|
||||
|
||||
YTSage unterstützt **14 Sprachen** für globale Barrierefreiheit. Wählen Sie Ihre bevorzugte Sprache unter **Custom Options → Language**.
|
||||
|
||||
### Unterstützte Sprachen
|
||||
|
||||
| Sprache | Code | Sprache | Code |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Englisch | `en` | 🇪🇸 Spanisch | `es` |
|
||||
| 🇸🇦 Arabisch | `ar` | 🇫🇷 Französisch | `fr` |
|
||||
| 🇩🇪 Deutsch | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonesisch | `id` | 🇮🇹 Italienisch | `it` |
|
||||
| 🇯🇵 Japanisch | `ja` | 🇵🇱 Polnisch | `pl` |
|
||||
| 🇧🇷 Portugiesisch | `pt` | 🇷🇺 Russisch | `ru` |
|
||||
| 🇹🇷 Türkisch | `tr` | 🇨🇳 Chinesisch | `zh` |
|
||||
|
||||
### README-Übersetzungen
|
||||
|
||||
| Sprache | Datei | Sprache | Datei |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Englisch | [README.md](../README.md) | 🇪🇸 Spanisch | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Arabisch | [README.ar.md](README.ar.md) | 🇫🇷 Französisch | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Deutsch | [README.de.md](README.de.md) | 🇮🇳 Hindi | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Indonesisch | [README.id.md](README.id.md) | 🇮🇹 Italienisch | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Japanisch | [README.ja.md](README.ja.md) | 🇵🇱 Polnisch | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Portugiesisch | [README.pt.md](README.pt.md) | 🇷🇺 Russisch | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Türkisch | [README.tr.md](README.tr.md) | 🇨🇳 Chinesisch | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Möchten Sie bei einer Übersetzung helfen?** Schauen Sie in den Abschnitt [Mitwirken](#contributing), um uns zu helfen, weitere Sprachen hinzuzufügen!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ Fehlerbehebung
|
||||
|
||||
<details>
|
||||
<summary>Klicken Sie hier, um häufige Probleme und Lösungen anzuzeigen</summary>
|
||||
|
||||
- **Formattabelle wird nicht angezeigt:** Aktualisieren Sie yt-dlp auf die neueste Version und wechseln Sie zu yt-dlp nightly.
|
||||
- **Download schlägt fehl:** Überprüfen Sie Ihre Internetverbindung und stellen Sie sicher, dass das Video verfügbar ist.
|
||||
- **Spezifische Download-Fehler:**
|
||||
- **Private Videos:** Verwenden Sie Cookie-Authentifizierung, um auf private Inhalte zuzugreifen.
|
||||
- **Videos mit Altersbeschränkung:** Loggen Sie sich in Ihr YouTube-Konto ein, um altersbeschränkte Videos anzusehen.
|
||||
- **Geoblockierte Videos:** Erwägen Sie die Verwendung eines VPN, um regionale Einschränkungen zu umgehen.
|
||||
- **Gelöschte Videos:** Das Video ist auf YouTube nicht mehr verfügbar.
|
||||
- **Livestreams:** Livestreams können während der Übertragung nicht heruntergeladen werden; warten Sie, bis der Stream beendet ist.
|
||||
- **Netzwerkfehler:** Überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.
|
||||
- **Ungültige URLs:** Stellen Sie sicher, dass die URL korrekt ist und von einer unterstützten Plattform stammt.
|
||||
- **Premium-Inhalte:** Erfordert ein YouTube-Premium-Abonnement.
|
||||
- **Urheberrechtssperren:** Der Inhalt ist aufgrund von Urheberrechtsbeschränkungen gesperrt.
|
||||
- **Video- und Audiodateien sind nach dem Download getrennt:** Dies passiert, wenn FFmpeg fehlt oder nicht erkannt wird. YTSage benötigt FFmpeg, um hochwertige Video- und Audiostreams zusammenzuführen.
|
||||
- **Lösung:** Stellen Sie sicher, dass FFmpeg installiert und im PATH Ihres Systems zugänglich ist. Für Windows-Benutzer ist die einfachste Option, die Datei `YTSage-v<version>-ffmpeg.exe` herunterzuladen, die FFmpeg integriert hat.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Windows Defender / Antivirus Warnung
|
||||
|
||||
Einige Antivirenprogramme können `.exe`-Dateien als Fehlalarm (False Positive) markieren. Dies ist eine **bekannte Einschränkung** von gepackten Anwendungen.
|
||||
|
||||
**Warum das passiert:**
|
||||
- Antiviren-Heuristiken können gepackte ausführbare Dateien fälschlicherweise als verdächtig identifizieren.
|
||||
|
||||
**Sichere Alternativen:**
|
||||
- ✅ **Verwenden Sie die pip-Installation:** `pip install ytsage` (Empfohlen)
|
||||
- ✅ **Aus dem Quellcode bauen**: Folgen Sie diesem [Leitfaden](../.github/CI_CD_README.md)
|
||||
- ✅ **App auf die Whitelist setzen** in Ihrer Antiviren-Software.
|
||||
|
||||
#### 🍎 macOS: "Die App ist beschädigt und kann nicht geöffnet werden"
|
||||
Wenn dieser Fehler unter macOS Sonoma oder neuer auftritt, müssen Sie das Quarantäne-Attribut entfernen.
|
||||
|
||||
1. **Öffnen Sie das Terminal** (Sie finden es über Spotlight).
|
||||
2. **Geben Sie den folgenden Befehl ein**, aber drücken Sie noch **nicht** Enter. Stellen Sie sicher, dass am Ende ein Leerzeichen steht:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Ziehen Sie die Datei `YTSage.app`** aus Ihrem Finder-Fenster direkt in das Terminal-Fenster. Dadurch wird der korrekte Dateipfad automatisch eingefügt.
|
||||
4. **Drücken Sie Enter**, um den Befehl auszuführen.
|
||||
5. **Versuchen Sie erneut, YTSage.app zu öffnen.** Sie sollte nun korrekt starten.
|
||||
|
||||
---
|
||||
|
||||
#### **Speicherorte der Konfiguration (Erweitert)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Sponsoren
|
||||
|
||||
Wenn YTSage Ihnen Zeit spart, ziehen Sie bitte in Erwägung, das Projekt zu sponsern. Sponsoring hilft, die Entwicklungszeit, Tests auf allen Plattformen und zukünftige Verbesserungen zu finanzieren.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Der Sponsoring-Link ist auch direkt in der App über den About-Dialog verfügbar.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contributing"></a>
|
||||
## 👥 Mitwirken
|
||||
|
||||
Beiträge sind herzlich willkommen! So können Sie helfen:
|
||||
|
||||
1. 🍴 Forken Sie das Repository
|
||||
2. 🌿 Erstellen Sie einen Feature-Branch:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 Committen Sie Ihre Änderungen:
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 Pushen Sie in den Branch:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 Öffnen Sie einen Pull Request
|
||||
|
||||
### 🌍 Zu Übersetzungen beitragen
|
||||
|
||||
- Aktualisieren Sie die entsprechende lokalisierte README-Datei (z. B. `readme-translations/README.de.md`)
|
||||
- Halten Sie die App-Strings synchron, indem Sie `ytsage/languages/<code>.json` bearbeiten
|
||||
- Wenn Ihre Sprache fehlt, beginnen Sie mit der `README.md` und erstellen Sie `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Projektstruktur</summary>
|
||||
|
||||
## YTSage - Projektstruktur
|
||||
|
||||
Dieses Dokument beschreibt die organisierte Ordnerstruktur von YTSage.
|
||||
|
||||
### 📁 Projektstruktur
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # GitHub-Konfiguration
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Ticket-Vorlagen
|
||||
│ │ └── 🐛-bug-report.md # Bug-Report-Vorlage
|
||||
│ ├─── 📁 workflows/ # GitHub Actions Workflows
|
||||
│ │ ├── build-linux.yml # Linux Build Workflow
|
||||
│ │ ├── build-macos.yml # macOS Build Workflow
|
||||
│ │ │── build-windows.yml # Windows Build Workflow
|
||||
| | └── release-all.yml # Master Release Workflow
|
||||
│ └── 📄 CI_CD_README.md # CI/CD-Dokumentation
|
||||
├── 📁 branding/ # Branding-Assets (Screenshots, SVGs)
|
||||
│ ├── 📁 icons/ # App-Icons
|
||||
│ ├── 📁 screenshots/ # Screenshots für die Dokumentation
|
||||
│ └── 📁 svg/ # SVG-Assets
|
||||
├── 📄 LICENSE # Lizenzdatei
|
||||
├── 📄 pyproject.toml # Projekt-Metadaten und Abhängigkeiten
|
||||
├── 📄 README.md # Projekt-Dokumentation
|
||||
├── 📄 requirements.txt # Python-Abhängigkeiten (dev)
|
||||
└── 📁 ytsage/ # Quellcode-Paket
|
||||
├── 📁 assets/ # Laufzeit-Assets
|
||||
│ ├── 📁 Icon/ # App-Icons
|
||||
│ └── 📁 sound/ # Audiodateien
|
||||
├── 📁 languages/ # Lokalisierungsdateien
|
||||
│ ├── 📄 ar.json # Arabische Übersetzung
|
||||
│ ├── 📄 de.json # Deutsche Übersetzung
|
||||
│ ├── 📄 en.json # Englische Übersetzung
|
||||
│ └── ... # Weitere Sprachen
|
||||
├── 📁 core/ # Hauptgeschäftslogik
|
||||
│ ├── 📄 __init__.py # Core-Paket-Initialisierung
|
||||
│ ├── 📄 ytsage_deno.py # Deno-Integration
|
||||
│ ├── 📄 ytsage_downloader.py # Download-Funktionalität
|
||||
│ ├── 📄 ytsage_ffmpeg.py # FFmpeg-Integration
|
||||
│ ├── 📄 ytsage_utils.py # Hilfsfunktionen
|
||||
│ └── 📄 ytsage_yt_dlp.py # yt-dlp-Integration
|
||||
├── 📁 gui/ # Benutzeroberflächen-Komponenten
|
||||
│ ├── 📄 __init__.py # GUI-Paket-Initialisierung
|
||||
│ ├── 📄 ytsage_gui_main.py # Hauptfenster der App
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Dialog-Klassen
|
||||
├── 📁 utils/ # Hilfsmodule
|
||||
│ ├── 📄 __init__.py # Utils-Paket-Initialisierung
|
||||
│ ├── 📄 ytsage_config_manager.py # Konfigurationsverwaltung
|
||||
│ └── 📄 ytsage_logger.py # Logging-Tool
|
||||
├── 📄 __init__.py # Paket-Einstiegspunkt
|
||||
└── 📄 main.py # Hauptskript zur Ausführung
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Star History
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Lizenz
|
||||
|
||||
Dieses Projekt steht unter der MIT-Lizenz – weitere Details finden Sie in der Datei [LICENSE](../LICENSE).
|
||||
|
||||
## 🙏 Danksagungen
|
||||
|
||||
<details>
|
||||
<summary>Danksagungen anzeigen</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Ein großes Dankeschön an alle, die zu diesem Projekt beigetragen haben, indem sie Tickets für Verbesserungsvorschläge oder Bug-Reports geöffnet haben.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Kernkomponenten</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Download-Engine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Medienverarbeitung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime für yt-dlp Integration</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Bibliotheken & Frameworks</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI-Framework</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Bildverarbeitung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>HTTP-Anfragen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Versionsverwaltung & Packaging</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Markdown-Rendering</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logging</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Assets & Mitwirkende</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>Benachrichtigungston</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Code-Contributor</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Haftungsausschluss
|
||||
|
||||
Dieses Tool ist nur für den persönlichen Gebrauch bestimmt. Bitte respektieren Sie die Nutzungsbedingungen von YouTube und die Rechte der Content-Ersteller.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Erstellt mit ❤️ von [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Un moderno descargador de YouTube con una interfaz PySide6 elegante.**
|
||||
Descarga videos en cualquier calidad, extrae audio, obtén subtítulos y más.
|
||||
|
||||
### 🌍 Idiomas del README
|
||||
|
||||
Inglés: [EN](../README.md)
|
||||
| Árabe: [AR](README.ar.md)
|
||||
| Alemán: [DE](README.de.md)
|
||||
| Español: [ES](README.es.md)
|
||||
| Francés: [FR](README.fr.md)
|
||||
| Hindi: [HI](README.hi.md)
|
||||
| Indonesio: [ID](README.id.md)
|
||||
| Italiano: [IT](README.it.md)
|
||||
| Japonés: [JA](README.ja.md)
|
||||
| Polaco: [PL](README.pl.md)
|
||||
| Portugués: [PT](README.pt.md)
|
||||
| Ruso: [RU](README.ru.md)
|
||||
| Turco: [TR](README.tr.md)
|
||||
| Chino: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">Instalación</a> •
|
||||
<a href="#features">Características</a> •
|
||||
<a href="#usage">Uso</a> •
|
||||
<a href="#screenshots">Capturas de pantalla</a> •
|
||||
<a href="#troubleshooting">Solución de problemas</a> •
|
||||
<a href="#sponsor">Patrocinar</a> •
|
||||
<a href="#contributing">Contribuir</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ytsage"></a>
|
||||
## ❓ ¿Por qué YTSage?
|
||||
|
||||
YTSage está diseñado para usuarios que desean un **descargador de YouTube simple pero potente**. A diferencia de otras herramientas, ofrece:
|
||||
|
||||
- Interfaz PySide6 moderna y limpia
|
||||
- Descarga con un solo clic para video, audio y subtítulos
|
||||
- Funciones avanzadas como SponsorBlock, fusión de subtítulos y selección de listas de reproducción
|
||||
- Modo genérico (Generic Mode) opcional para sitios compatibles con yt-dlp más allá de YouTube
|
||||
- Soporte multiplataforma e instalación sencilla
|
||||
|
||||
<a id="features"></a>
|
||||
## ✨ Características
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Características básicas | Características avanzadas | Características adicionales |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Tabla de formatos | 🚫 Integración de SponsorBlock | 🎞️ Pantalla FPS/HDR |
|
||||
| 🎵 Extracción de audio | 📝 Selección y fusión de subtítulos | 🔄 Actualización automática de yt-dlp |
|
||||
| ✨ Interfaz de usuario simple | 💾 Guarda descripción y miniatura | 🛠️ Detección de FFmpeg/yt-dlp/Deno |
|
||||
| 📋 Soporte y selector de listas de reproducción | 🚀 Limitador de velocidad | ⚙️ Comandos personalizados |
|
||||
| 📑 Integración de capítulos | ✂️ Recorte de secciones de video | 🍪 Inicio de sesión con cookies |
|
||||
| 📜 Historial de descargas | 🔄 Selección de canal de lanzamiento | 🌐 Soporte de proxy |
|
||||
| 🎚️ Conversión de formato de audio | 🎬 Configuración de formato de video | 🆙 Pestaña de actualización integrada |
|
||||
| 🌍 Modo genérico | 🔊 Normalización de audio (EBU R128) | 🌍 Localización en 14 idiomas |
|
||||
| 💾 Exportación de listas de reproducción | ⚙️ Calidad y subtítulos predeterminados | |
|
||||
</div>
|
||||
|
||||
<a id="installation"></a>
|
||||
## 🚀 Instalación
|
||||
|
||||
### ⚡ Instalación rápida (Recomendado)
|
||||
|
||||
Instala YTSage vía PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Actualizar instalación existente</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Luego, ejecuta la aplicación:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Ejecutables precompilados
|
||||
|
||||
> [👉 Descargar el último lanzamiento](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Formato | Descripción |
|
||||
|--------|-------------|
|
||||
|  | Instalador estándar |
|
||||
|  | Con FFmpeg incorporado |
|
||||
|  | Versión portable, no requiere instalación |
|
||||
|  | Portable con FFmpeg, comprimido |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Pasos de instalación</summary>
|
||||
|
||||
1. **Instalador EXE (`.exe`)**: Haz doble clic en el archivo y sigue el asistente de configuración.
|
||||
2. **Versión portable (`.zip`)**: Extrae el archivo en el lugar deseado y ejecuta `ytsage.exe`.
|
||||
3. **FFmpeg incorporado**: Elige las versiones con FFmpeg incorporado si no tienes FFmpeg instalado en tu sistema.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Formato | Descripción |
|
||||
|--------|-------------|
|
||||
|  | Paquete Debian |
|
||||
|  | AppImage, portable |
|
||||
|  | Paquete RPM |
|
||||
|  | Flatpak Bundle |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Pasos de instalación</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Repara dependencias faltantes si es necesario
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Sigue las instrucciones en Flathub o ejecuta:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Formato | Descripción |
|
||||
|--------|-------------|
|
||||
|  | Aplicación comprimida para Apple Silicon |
|
||||
|  | Instalador de imagen de disco para Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Pasos de instalación</summary>
|
||||
|
||||
- **Instalador DMG (`.dmg`)**: Haz doble clic para montar, luego arrastra `YTSage.app` a tu carpeta de Aplicaciones.
|
||||
- **Archivo de aplicación (`.zip`)**: Extrae el zip y mueve `YTSage.app` a tu carpeta de Aplicaciones.
|
||||
|
||||
*Nota: Si encuentras el error "La aplicación está dañada", consulta la sección de solución de problemas de macOS a continuación.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Instalación manual desde el código fuente</summary>
|
||||
|
||||
### 1. Clonar el repositorio
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Instalar dependencias
|
||||
|
||||
#### ⚡ Con uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 O con pip estándar
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Ejecutar la aplicación
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
## 📸 Capturas de pantalla
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Ajustes de descarga" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Descarga de lista de reproducción" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Ajustes de descarga</em></td>
|
||||
<td align="center"><em>Descarga de lista de reproducción</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Selección de formato de audio" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Opción personalizada" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Formato de audio</em></td>
|
||||
<td align="center"><em>Opción personalizada</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="usage"></a>
|
||||
## 📖 Uso
|
||||
|
||||
<details>
|
||||
<summary>🎯 Uso básico</summary>
|
||||
|
||||
1. **Lanza YTSage**
|
||||
2. **Pega la URL de YouTube** (o usa el botón "Paste URL")
|
||||
3. **Haz clic en "Analyze"**
|
||||
4. **Selecciona el formato:**
|
||||
- `Video` para descargas de video
|
||||
- `Audio Only` para extracción de audio
|
||||
5. **Elige opciones:**
|
||||
- Activar subtítulos y elegir idioma
|
||||
- Activar fusión de subtítulos
|
||||
- Guardar miniatura
|
||||
- Eliminar segmentos patrocinados
|
||||
- Guardar descripción
|
||||
- Integrar capítulos
|
||||
6. **Selecciona directorio de salida**
|
||||
7. **Haz clic en "Download"**
|
||||
|
||||
> 💡 El directorio de descarga predeterminado es la carpeta "Downloads" del usuario.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Descarga de listas de reproducción</summary>
|
||||
|
||||
1. **Pega la URL de la lista de reproducción**
|
||||
2. **Haz clic en "Analyze"**
|
||||
3. **Selecciona videos del selector de listas (opcional, todos por defecto)**
|
||||
4. **Elige formato/calidad deseada**
|
||||
5. **Haz clic en "Download"**
|
||||
|
||||
> 💡 La aplicación gestiona las colas de descarga automáticamente, y puedes exportar entradas de la lista como archivos `.txt`, `.csv`, `.m3u` o `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Modo genérico para sitios que no son YouTube</summary>
|
||||
|
||||
Usa el modo genérico (Generic Mode) cuando desees que YTSage acepte URLs de sitios compatibles con yt-dlp, como Dailymotion, CBC Gem, TikTok y otros.
|
||||
|
||||
Cómo usarlo:
|
||||
|
||||
1. Abre `Download Settings`.
|
||||
2. Activa `Generic Mode`.
|
||||
3. Pega una URL de video o lista de reproducción compatible que no sea de YouTube.
|
||||
4. Haz clic en `Analyze`.
|
||||
5. Elige el formato y descarga como de costumbre.
|
||||
|
||||
Notas:
|
||||
|
||||
- El modo genérico solo cambia la validación de la URL dentro de YTSage. El sitio debe ser compatible con tu versión instalada de yt-dlp.
|
||||
- Algunos sitios requieren cookies, inicio de sesión, proxy o argumentos adicionales de yt-dlp dependiendo del extractor.
|
||||
- Si un sitio falla, actualiza primero yt-dlp desde la pestaña de actualización integrada antes de informar del problema.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Opciones de medios y descarga</summary>
|
||||
|
||||
- **Opciones de subtítulos:** Filtra idiomas e incrusta subtítulos en el archivo de video.
|
||||
- **Fusión de subtítulos:** Fusiona subtítulos en el archivo de video para subtítulos fijos (hardcoded).
|
||||
- **Guardar descripción:** Guarda la descripción del video como un archivo de texto.
|
||||
- **Guardar miniatura:** Guarda la miniatura del video como un archivo de imagen.
|
||||
- **Integrar capítulos:** Incluye marcas de capítulo como metadatos para reproductores de video compatibles.
|
||||
- **Eliminar segmentos patrocinados:** Elimina segmentos patrocinados del video usando SponsorBlock.
|
||||
- **Recortar video:** Descarga solo partes específicas de un video especificando rangos de tiempo en formato `HH:MM:SS`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Ajustes de salida y archivos</summary>
|
||||
|
||||
- **Limitador de velocidad:** Limita la velocidad de descarga, p. ej., `500K` para 500 KB/s.
|
||||
- **Guardar ruta de descarga:** Guarda la ruta de descarga predeterminada para futuras descargas. Disponible en **Download Settings → Download Path**.
|
||||
- **Resolución de video predeterminada:** Establece tu resolución de video preferida para selección automática (p. ej., 1080p, 720p). Disponible en **Download Settings → Default Video Resolution**.
|
||||
- **Idiomas de subtítulos predeterminados:** Establece idiomas de subtítulos para selección automática (separados por comas, p. ej., `es,en`). Disponible en **Download Settings → Default Subtitle Languages**.
|
||||
- **Formato de nombre de archivo:** Personaliza el formato del nombre de archivo usando variables como `%(title)s`, `%(uploader)s`, `%(playlist_index)s` y `%(resolution)s`. Disponible en **Download Settings → Filename Format**.
|
||||
- **Forzar formato de salida:** Fuerza las descargas de video a un formato de contenedor específico como `mp4`, `webm` o `mkv`. Disponible en **Download Settings → Output Format Settings**.
|
||||
- **Conversión de formato de audio:** Convierte descargas de solo audio a formatos preferidos como `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis` o `Best`. Disponible en **Download Settings → Audio Format Settings**.
|
||||
- **Normalización de audio:** Estandariza el volumen para descargas de solo audio usando EBU R128.
|
||||
- **Conexiones simultáneas:** Aumenta drásticamente la velocidad de descarga descargando archivos en múltiples fragmentos a la vez. Disponible en **Download Settings → General → Concurrent Connections** (1 por defecto, máximo recomendado 8-10 para evitar bloqueos por IP).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Acceso y red</summary>
|
||||
|
||||
- **Inicio de sesión con cookies:** Inicia sesión en YouTube usando cookies para acceder a contenido privado.
|
||||
Cómo usarlo:
|
||||
1. **Recomendado:** Usa la opción integrada `Extract cookies from browser` en la aplicación, luego selecciona tu navegador y opcionalmente un perfil.
|
||||
2. Alternativamente, extrae las cookies manualmente:
|
||||
a. Exporta cookies de tu navegador usando una extensión como [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Copia las cookies en formato Netscape
|
||||
c. Crea un archivo llamado `cookies.txt` y pega las cookies
|
||||
d. Selecciona el archivo `cookies.txt` en la aplicación
|
||||
- **Soporte de proxy:** Usa un servidor proxy para las descargas, p. ej., `http://<servidor-proxy>:<puerto>`
|
||||
- **Modo genérico:** Permite a YTSage analizar y descargar desde sitios que no son YouTube compatibles con yt-dlp. Actívalo en **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Herramientas y mantenimiento</summary>
|
||||
|
||||
- **Comandos personalizados:** Accede a funciones avanzadas de yt-dlp mediante argumentos de línea de comandos.
|
||||
- **Pestaña de actualización:** Gestiona herramientas de actualización desde un solo lugar en Opciones personalizadas:
|
||||
- **Actualizaciones de yt-dlp:** Busca actualizaciones y cambia entre canales estable y nightly.
|
||||
- **Verificador de versión de FFmpeg:** Verifica tu versión de FFmpeg y abre guías de instalación.
|
||||
- **Actualizaciones de Deno:** Verifica y actualiza el motor de ejecución Deno.
|
||||
- **Detección de FFmpeg/yt-dlp/Deno:** Detecta automáticamente rutas y versiones de FFmpeg, yt-dlp y Deno desde el diálogo Acerca de.
|
||||
- **Historial de descargas:** Visualiza descargas pasadas con miniaturas y estados desde el botón **History**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Localización</summary>
|
||||
|
||||
YTSage soporta **14 idiomas** para accesibilidad global. Selecciona tu idioma preferido en **Custom Options → Language**.
|
||||
|
||||
### Idiomas soportados
|
||||
|
||||
| Idioma | Código | Idioma | Código |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inglés | `en` | 🇪🇸 Español | `es` |
|
||||
| 🇸🇦 Árabe | `ar` | 🇫🇷 Francés | `fr` |
|
||||
| 🇩🇪 Alemán | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonesio | `id` | 🇮🇹 Italiano | `it` |
|
||||
| 🇯🇵 Japonés | `ja` | 🇵🇱 Polaco | `pl` |
|
||||
| 🇧🇷 Portugués | `pt` | 🇷🇺 Ruso | `ru` |
|
||||
| 🇹🇷 Turco | `tr` | 🇨🇳 Chino | `zh` |
|
||||
|
||||
### Traducciones del README
|
||||
|
||||
| Idioma | Archivo | Idioma | Archivo |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inglés | [README.md](../README.md) | 🇪🇸 Español | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Árabe | [README.ar.md](README.ar.md) | 🇫🇷 Francés | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Alemán | [README.de.md](README.de.md) | 🇮🇳 Hindi | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Indonesio | [README.id.md](README.id.md) | 🇮🇹 Italiano | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Japonés | [README.ja.md](README.ja.md) | 🇵🇱 Polaco | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Portugués | [README.pt.md](README.pt.md) | 🇷🇺 Ruso | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Turco | [README.tr.md](README.tr.md) | 🇨🇳 Chino | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **¿Quieres contribuir con una traducción?** ¡Consulta la sección de [Contribución](#contributing) para ayudarnos a añadir más idiomas!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ Solución de problemas
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para ver problemas comunes y soluciones</summary>
|
||||
|
||||
- **La tabla de formatos no aparece:** Actualiza yt-dlp a la última versión y cambia a yt-dlp nightly.
|
||||
- **Fallo de descarga:** Verifica tu conexión a internet y asegúrate de que el video esté disponible.
|
||||
- **Errores de descarga específicos:**
|
||||
- **Videos privados:** Usa autenticación por cookies para acceder a contenido privado.
|
||||
- **Contenido con restricción de edad:** Inicia sesión en tu cuenta de YouTube para ver videos restringidos.
|
||||
- **Videos bloqueados geográficamente:** Considera usar una VPN para saltar restricciones regionales.
|
||||
- **Videos eliminados:** El video ya no está disponible en YouTube.
|
||||
- **Transmisiones en vivo:** Los directos no se pueden descargar; espera a que la transmisión termine.
|
||||
- **Errores de red:** Verifica tu conexión a internet e inténtalo de nuevo.
|
||||
- **URLs no válidas:** Asegúrate de que la URL sea correcta y de una plataforma compatible.
|
||||
- **Contenido Premium:** Requiere suscripción a YouTube Premium.
|
||||
- **Bloqueos por copyright:** El contenido está bloqueado por restricciones de derechos de autor.
|
||||
- **Archivos de video y audio separados tras descarga:** Esto sucede cuando falta FFmpeg o no se detecta. YTSage requiere FFmpeg para fusionar flujos de video y audio de alta calidad.
|
||||
- **Solución:** Asegúrate de que FFmpeg esté instalado y accesible en el PATH de tu sistema. Para usuarios de Windows, la opción más fácil es descargar el archivo `YTSage-v<version>-ffmpeg.exe`, que incluye FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Advertencia de Windows Defender / Antivirus
|
||||
|
||||
Algunos programas antivirus pueden marcar archivos `.exe` como falsos positivos. Esta es una **limitación conocida** de las aplicaciones empaquetadas.
|
||||
|
||||
**Por qué sucede esto:**
|
||||
- La heurística de los antivirus puede identificar erróneamente ejecutables empaquetados como sospechosos.
|
||||
|
||||
**Alternativas seguras:**
|
||||
- ✅ **Usa la instalación de pip:** `pip install ytsage` (Recomendado)
|
||||
- ✅ **Construye desde el código fuente**: siguiendo esta [guía](../.github/CI_CD_README.md)
|
||||
- ✅ **Añade la aplicación a la lista blanca** en tu software antivirus.
|
||||
|
||||
#### 🍎 macOS: "La aplicación está dañada y no se puede abrir"
|
||||
Si ves este error en macOS Sonoma o más reciente, necesitas eliminar el atributo de cuarentena.
|
||||
|
||||
1. **Abre el Terminal** (puedes encontrarlo usando Spotlight).
|
||||
2. **Escribe el siguiente comando** pero **no presiones** Enter todavía. Asegúrate de incluir el espacio al final:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Arrastra el archivo `YTSage.app`** desde tu ventana de Finder y suéltalo directamente en la ventana del Terminal. Esto pegará automáticamente la ruta correcta del archivo.
|
||||
4. **Presiona Enter** para ejecutar el comando.
|
||||
5. **Intenta abrir YTSage.app de nuevo.** Ahora debería lanzarse correctamente.
|
||||
|
||||
---
|
||||
|
||||
#### **Ubicaciones de configuración (Avanzado)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Patrocinar
|
||||
|
||||
Si YTSage te ahorra tiempo, considera patrocinar el proyecto. El patrocinio ayuda a cubrir tiempo de desarrollo, pruebas en todas las plataformas y futuras mejoras.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- El enlace de patrocinio también está disponible directamente en la aplicación a través del diálogo Acerca de.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contributing"></a>
|
||||
## 👥 Contribuir
|
||||
|
||||
¡Agradecemos las contribuciones! Aquí tienes cómo puedes ayudar:
|
||||
|
||||
1. 🍴 Haz un Fork del repositorio
|
||||
2. 🌿 Crea tu rama de características:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 Haz commit de tus cambios:
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 Haz Push a la rama:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 Abre un Pull Request
|
||||
|
||||
### 🌍 Contribuir con traducciones
|
||||
|
||||
- Actualiza el archivo README localizado correspondiente (p. ej., `readme-translations/README.es.md`)
|
||||
- Mantén las cadenas de la aplicación sincronizadas editando `ytsage/languages/<code>.json`
|
||||
- Si falta tu idioma, comienza desde `README.md` y crea `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Estructura del proyecto</summary>
|
||||
|
||||
## YTSage - Estructura del proyecto
|
||||
|
||||
Este documento describe la estructura de carpetas organizada de YTSage.
|
||||
|
||||
### 📁 Estructura del proyecto
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # Configuración de GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Plantillas de problemas
|
||||
│ │ └── 🐛-bug-report.md # Plantilla de informe de errores
|
||||
│ ├─── 📁 workflows/ # Flujos de trabajo de GitHub Actions
|
||||
│ │ ├── build-linux.yml # Flujo de construcción para Linux
|
||||
│ │ ├── build-macos.yml # Flujo de construcción para macOS
|
||||
│ │ │── build-windows.yml # Flujo de construcción para Windows
|
||||
| | └── release-all.yml # Flujo de liberación maestra
|
||||
│ └── 📄 CI_CD_README.md # Documentación de CI/CD
|
||||
├── 📁 branding/ # Activos de marca (Capturas de pantalla, SVGs)
|
||||
│ ├── 📁 icons/ # Iconos de la aplicación
|
||||
│ ├── 📁 screenshots/ # Capturas de pantalla para documentación
|
||||
│ └── 📁 svg/ # Activos SVG
|
||||
├── 📄 LICENSE # Archivo de licencia
|
||||
├── 📄 pyproject.toml # Metadatos del proyecto y dependencias
|
||||
├── 📄 README.md # Documentación del proyecto
|
||||
├── 📄 requirements.txt # Dependencias de Python (dev)
|
||||
└── 📁 ytsage/ # Paquete de código fuente
|
||||
├── 📁 assets/ # Activos en tiempo de ejecución
|
||||
│ ├── 📁 Icon/ # Iconos de la aplicación
|
||||
│ └── 📁 sound/ # Archivos de audio
|
||||
├── 📁 languages/ # Archivos de localización
|
||||
│ ├── 📄 ar.json # Traducción al árabe
|
||||
│ ├── 📄 de.json # Traducción al alemán
|
||||
│ ├── 📄 en.json # Traducción al inglés
|
||||
│ └── ... # Otros idiomas
|
||||
├── 📁 core/ # Lógica de negocio principal
|
||||
│ ├── 📄 __init__.py # Init del paquete core
|
||||
│ ├── 📄 ytsage_deno.py # Integración con Deno
|
||||
│ ├── 📄 ytsage_downloader.py # Funcionalidad de descarga
|
||||
│ ├── 📄 ytsage_ffmpeg.py # Integración con FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # Funciones de utilidad
|
||||
│ └── 📄 ytsage_yt_dlp.py # Integración con yt-dlp
|
||||
├── 📁 gui/ # Componentes de interfaz de usuario
|
||||
│ ├── 📄 __init__.py # Init del paquete GUI
|
||||
│ ├── 📄 ytsage_gui_main.py # Ventana principal de la aplicación
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Clases de diálogos
|
||||
├── 📁 utils/ # Módulos de utilidad
|
||||
│ ├── 📄 __init__.py # Init del paquete utils
|
||||
│ ├── 📄 ytsage_config_manager.py # Gestión de configuración
|
||||
│ └── 📄 ytsage_logger.py # Utilidad de registro
|
||||
├── 📄 __init__.py # Punto de entrada del paquete
|
||||
└── 📄 main.py # Script de ejecución principal
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Historial de estrellas
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Licencia
|
||||
|
||||
Este proyecto está bajo la Licencia MIT - mira el archivo [LICENSE](../LICENSE) para más detalles.
|
||||
|
||||
## 🙏 Agradecimientos
|
||||
|
||||
<details>
|
||||
<summary>Ver agradecimientos</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Muchas gracias a todos los que han contribuido a este proyecto abriendo un problema para sugerir una mejora o informar de un error.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Componentes principales</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Motor de descarga</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Procesamiento de medios</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime para integración con yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Librerías y Frameworks</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>Framework de GUI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Procesamiento de imágenes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>Solicitudes HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Gestión de versiones y empaquetado</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Renderizado de Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logging</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Activos y Colaboradores</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>Sonido de notificación</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Colaborador de código</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Descargo de responsabilidad
|
||||
|
||||
Esta herramienta es solo para uso personal. Por favor, respeta los términos de servicio de YouTube y los derechos de los creadores de contenido.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Hecho con ❤️ por [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Un téléchargeur YouTube moderne avec une interface PySide6 épurée.**
|
||||
Téléchargez des vidéos dans n'importe quelle qualité, extrayez l'audio, récupérez les sous-titres, et plus encore.
|
||||
|
||||
### 🌍 Langues du README
|
||||
|
||||
Anglais : [EN](../README.md)
|
||||
| Arabe : [AR](README.ar.md)
|
||||
| Allemand : [DE](README.de.md)
|
||||
| Espagnol : [ES](README.es.md)
|
||||
| Français : [FR](README.fr.md)
|
||||
| Hindi : [HI](README.hi.md)
|
||||
| Indonésien : [ID](README.id.md)
|
||||
| Italien : [IT](README.it.md)
|
||||
| Japonais : [JA](README.ja.md)
|
||||
| Polonais : [PL](README.pl.md)
|
||||
| Portugais : [PT](README.pt.md)
|
||||
| Russe : [RU](README.ru.md)
|
||||
| Turc : [TR](README.tr.md)
|
||||
| Chinois : [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#features">Fonctionnalités</a> •
|
||||
<a href="#usage">Utilisation</a> •
|
||||
<a href="#screenshots">Captures d'écran</a> •
|
||||
<a href="#troubleshooting">Dépannage</a> •
|
||||
<a href="#sponsor">Sponsor</a> •
|
||||
<a href="#contributing">Contribution</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ytsage"></a>
|
||||
## ❓ Pourquoi YTSage ?
|
||||
|
||||
YTSage est conçu pour les utilisateurs qui recherchent un **téléchargeur YouTube simple mais puissant**. Contrairement à d'autres outils, il offre :
|
||||
|
||||
- Une interface PySide6 moderne et épurée
|
||||
- Téléchargements en un clic pour la vidéo, l'audio et les sous-titres
|
||||
- Fonctionnalités avancées comme SponsorBlock, la fusion des sous-titres et la sélection de playlists
|
||||
- Mode générique (Generic Mode) optionnel pour les sites pris en charge par yt-dlp au-delà de YouTube
|
||||
- Support multiplateforme et installation facile
|
||||
|
||||
<a id="features"></a>
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Fonctionnalités de base | Fonctionnalités avancées | Fonctionnalités supplémentaires |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Tableau des formats | 🚫 Intégration de SponsorBlock | 🎞️ Affichage FPS/HDR |
|
||||
| 🎵 Extraction audio | 📝 Sélection et fusion de sous-titres | 🔄 Mise à jour automatique de yt-dlp |
|
||||
| ✨ Interface utilisateur simple | 💾 Enregistrement de la description et de la miniature | 🛠️ Détection de FFmpeg/yt-dlp/Deno |
|
||||
| 📋 Support et sélecteur de playlists | 🚀 Limiteur de vitesse | ⚙️ Commandes personnalisées |
|
||||
| 📑 Intégration des chapitres | ✂️ Découpage de sections vidéo | 🍪 Connexion avec Cookies |
|
||||
| 📜 Historique des téléchargements | 🔄 Sélection du canal de version | 🌐 Support Proxy |
|
||||
| 🎚️ Conversion du format audio | 🎬 Paramètres de format vidéo | 🆙 Onglet de mise à jour intégré |
|
||||
| 🌍 Mode générique | 🔊 Normalisation audio (EBU R128) | 🌍 Localisation en 14 langues |
|
||||
| 💾 Exportation de playlists | ⚙️ Qualité et sous-titres par défaut | |
|
||||
</div>
|
||||
|
||||
<a id="installation"></a>
|
||||
## 🚀 Installation
|
||||
|
||||
### ⚡ Installation rapide (Recommandé)
|
||||
|
||||
Installez YTSage via PyPI :
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Mettre à jour une installation existante</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Lancez ensuite l'application :
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Exécutables pré-construits
|
||||
|
||||
> [👉 Télécharger la dernière version](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Installateur standard |
|
||||
|  | Avec FFmpeg inclus |
|
||||
|  | Version portable, aucune installation requise |
|
||||
|  | Portable avec FFmpeg, zippé |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Étapes d'installation</summary>
|
||||
|
||||
1. **Installateur EXE (`.exe`)** : Double-cliquez sur le fichier et suivez l'assistant de configuration.
|
||||
2. **Version portable (`.zip`)** : Extrayez l'archive vers l'emplacement souhaité et lancez `ytsage.exe`.
|
||||
3. **FFmpeg inclus** : Choisissez les versions avec FFmpeg inclus si vous n'avez pas FFmpeg installé sur votre système.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Paquet Debian |
|
||||
|  | AppImage, portable |
|
||||
|  | Paquet RPM |
|
||||
|  | Flatpak Bundle |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Étapes d'installation</summary>
|
||||
|
||||
- **DEB (`.deb`)** :
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Répare les dépendances manquantes si nécessaire
|
||||
```
|
||||
- **RPM (`.rpm`)** :
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)** :
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak** : Suivez les instructions sur Flathub ou lancez :
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Application zippée pour Apple Silicon |
|
||||
|  | Installateur d'image disque pour Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Étapes d'installation</summary>
|
||||
|
||||
- **Installateur DMG (`.dmg`)** : Double-cliquez pour monter, puis faites glisser `YTSage.app` dans votre dossier Applications.
|
||||
- **Archive d'application (`.zip`)** : Extrayez le zip et déplacez `YTSage.app` dans votre dossier Applications.
|
||||
|
||||
*Note : Si vous rencontrez une erreur "L'application est endommagée", consultez la section de dépannage macOS ci-dessous.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Installation manuelle à partir des sources</summary>
|
||||
|
||||
### 1. Cloner le dépôt
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Installer les dépendances
|
||||
|
||||
#### ⚡ Avec uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Ou avec pip standard
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Lancer l'application
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
## 📸 Captures d'écran
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Paramètres de téléchargement" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Téléchargement de playlist" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Paramètres de téléchargement</em></td>
|
||||
<td align="center"><em>Téléchargement de playlist</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Sélection du format audio" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Options personnalisées" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Format audio</em></td>
|
||||
<td align="center"><em>Options personnalisées</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="usage"></a>
|
||||
## 📖 Utilisation
|
||||
|
||||
<details>
|
||||
<summary>🎯 Utilisation de base</summary>
|
||||
|
||||
1. **Lancez YTSage**
|
||||
2. **Collez l'URL YouTube** (ou utilisez le bouton "Paste URL")
|
||||
3. **Cliquez sur "Analyze"**
|
||||
4. **Sélectionnez le format :**
|
||||
- `Video` pour les téléchargements vidéo
|
||||
- `Audio Only` pour l'extraction audio
|
||||
5. **Choisissez les options :**
|
||||
- Activer les sous-titres et sélectionner la langue
|
||||
- Activer la fusion des sous-titres
|
||||
- Enregistrer la miniature
|
||||
- Supprimer les segments sponsorisés
|
||||
- Enregistrer la description
|
||||
- Intégrer les chapitres
|
||||
6. **Sélectionnez le répertoire de sortie**
|
||||
7. **Cliquez sur "Download"**
|
||||
|
||||
> 💡 Le répertoire de téléchargement par défaut est le dossier "Téléchargements" de l'utilisateur.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Téléchargement de playlist</summary>
|
||||
|
||||
1. **Collez l'URL de la playlist**
|
||||
2. **Cliquez sur "Analyze"**
|
||||
3. **Sélectionnez les vidéos du sélecteur de playlist (optionnel, toutes par défaut)**
|
||||
4. **Choisissez le format/la qualité souhaitée**
|
||||
5. **Cliquez sur "Download"**
|
||||
|
||||
> 💡 L'application gère automatiquement la file d'attente de téléchargement, et vous pouvez exporter les entrées de la playlist au format `.txt`, `.csv`, `.m3u` ou `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Mode générique pour les sites non-YouTube</summary>
|
||||
|
||||
Utilisez le mode générique (Generic Mode) lorsque vous souhaitez que YTSage accepte des URL de sites pris en charge par yt-dlp, tels que Dailymotion, CBC Gem, TikTok, et d'autres.
|
||||
|
||||
Comment l'utiliser :
|
||||
|
||||
1. Ouvrez `Download Settings`.
|
||||
2. Activez `Generic Mode`.
|
||||
3. Collez une URL de vidéo ou de playlist prise en charge qui n'est pas YouTube.
|
||||
4. Cliquez sur `Analyze`.
|
||||
5. Choisissez un format et téléchargez comme d'habitude.
|
||||
|
||||
Notes :
|
||||
|
||||
- Le mode générique ne modifie que la validation de l'URL à l'intérieur de YTSage. Le site cible doit toujours être pris en charge par votre version installée de yt-dlp.
|
||||
- Certains sites nécessitent des cookies, une session de connexion, un proxy ou des arguments yt-dlp supplémentaires selon l'extracteur.
|
||||
- Si un site échoue, mettez d'abord à jour yt-dlp depuis l'onglet de mise à jour intégré avant de signaler le problème.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Options média et de téléchargement</summary>
|
||||
|
||||
- **Options de sous-titres :** Filtrer les langues et intégrer les sous-titres dans le fichier vidéo.
|
||||
- **Fusion de sous-titres :** Fusionner les sous-titres dans le fichier vidéo pour des sous-titres incrustés (hardcoded).
|
||||
- **Enregistrer la description :** Enregistrer la description de la vidéo sous forme de fichier texte.
|
||||
- **Enregistrer la miniature :** Enregistrer la miniature de la vidéo sous forme de fichier image.
|
||||
- **Intégrer les chapitres :** Intégrer les marqueurs de chapitres comme métadonnées pour les lecteurs vidéo compatibles.
|
||||
- **Supprimer les segments sponsorisés :** Supprimer les segments sponsorisés de la vidéo à l'aide de SponsorBlock.
|
||||
- **Découper la vidéo :** Téléchargez uniquement des parties spécifiques d'une vidéo en spécifiant des plages temporelles au format `HH:MM:SS`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Paramètres de sortie et de fichier</summary>
|
||||
|
||||
- **Limiteur de vitesse :** Limiter la vitesse de téléchargement, par exemple `500K` pour 500 Ko/s.
|
||||
- **Enregistrer le chemin de téléchargement :** Enregistre le chemin de téléchargement par défaut pour les futurs téléchargements. Disponible dans **Download Settings → Download Path**.
|
||||
- **Résolution vidéo par défaut :** Définissez votre résolution vidéo préférée par défaut pour la sélection automatique (ex : 1080p, 720p). Disponible dans **Download Settings → Default Video Resolution**.
|
||||
- **Langues de sous-titres par défaut :** Définissez les langues de sous-titres par défaut pour une sélection automatique (séparées par des virgules, ex : `fr,en`). Disponible dans **Download Settings → Default Subtitle Languages**.
|
||||
- **Format du nom de fichier de sortie :** Personnalisez le format du nom de fichier de sortie à l'aide de variables telles que `%(title)s`, `%(uploader)s`, `%(playlist_index)s` et `%(resolution)s`. Disponible dans **Download Settings → Filename Format**.
|
||||
- **Forcer le format de sortie :** Forcer les téléchargements vidéo dans un format de conteneur spécifique tel que `mp4`, `webm` ou `mkv`. Disponible dans **Download Settings → Output Format Settings**.
|
||||
- **Conversion du format audio :** Convertir les téléchargements audio uniquement dans les formats préférés tels que `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis` ou `Best`. Disponible dans **Download Settings → Audio Format Settings**.
|
||||
- **Normalisation audio :** Standardiser le volume pour les téléchargements audio uniquement à l'aide de l'EBU R128.
|
||||
- **Connexions simultanées :** Augmentez considérablement la vitesse de téléchargement en téléchargeant les fichiers en plusieurs fragments simultanément. Disponible dans **Download Settings → General → Concurrent Connections** (1 par défaut, le maximum recommandé est de 8 à 10 pour éviter la limitation par IP).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Accès et réseau</summary>
|
||||
|
||||
- **Connexion avec cookies :** Connectez-vous à YouTube à l'aide de cookies pour accéder au contenu privé.
|
||||
Comment l'utiliser :
|
||||
1. **Recommandé :** Utilisez l'option intégrée `Extract cookies from browser` dans l'application, puis sélectionnez votre navigateur et éventuellement un profil.
|
||||
2. Alternativement, extrayez les cookies manuellement :
|
||||
a. Exportez les cookies de votre navigateur à l'aide d'une extension comme [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Copiez les cookies au format Netscape
|
||||
c. Créez un fichier nommé `cookies.txt` et collez-y les cookies
|
||||
d. Sélectionnez le fichier `cookies.txt` dans l'application
|
||||
- **Support Proxy :** Utilisez un serveur proxy pour les téléchargements, par exemple `http://<proxy-server>:<port>`
|
||||
- **Mode générique :** Permet à YTSage d'analyser et de télécharger à partir de sites non-YouTube pris en charge par yt-dlp. Activez-le depuis **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Outils et maintenance</summary>
|
||||
|
||||
- **Commandes personnalisées :** Accédez aux fonctionnalités avancées de yt-dlp via des arguments de ligne de commande.
|
||||
- **Onglet de mise à jour :** Gérez les outils de mise à jour intégrés depuis un seul endroit dans Options personnalisées :
|
||||
- **Mises à jour de yt-dlp :** Vérifiez les mises à jour et basculez entre les canaux de version Stable et Nightly.
|
||||
- **Vérificateur de version FFmpeg :** Vérifiez votre version de FFmpeg et ouvrez les guides d'installation.
|
||||
- **Mises à jour de Deno :** Vérifiez et mettez à jour le moteur d'exécution Deno.
|
||||
- **Détection de FFmpeg/yt-dlp/Deno :** Détecte automatiquement les chemins et les versions de FFmpeg, yt-dlp et Deno à partir de la boîte de dialogue À propos.
|
||||
- **Historique des téléchargements :** Affichez les téléchargements passés avec les miniatures et les statuts depuis le bouton **History**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Localisation</summary>
|
||||
|
||||
YTSage prend en charge **14 langues** pour une accessibilité mondiale. Sélectionnez votre langue préférée dans **Custom Options → Language**.
|
||||
|
||||
### Langues prises en charge
|
||||
|
||||
| Langue | Code | Langue | Code |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Anglais | `en` | 🇪🇸 Espagnol | `es` |
|
||||
| 🇸🇦 Arabe | `ar` | 🇫🇷 Français | `fr` |
|
||||
| 🇩🇪 Allemand | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonésien | `id` | 🇮🇹 Italien | `it` |
|
||||
| 🇯🇵 Japonais | `ja` | 🇵🇱 Polonais | `pl` |
|
||||
| 🇧🇷 Portugais | `pt` | 🇷🇺 Russe | `ru` |
|
||||
| 🇹🇷 Turc | `tr` | 🇨🇳 Chinois | `zh` |
|
||||
|
||||
### Traductions du README
|
||||
|
||||
| Langue | Fichier | Langue | Fichier |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Anglais | [README.md](../README.md) | 🇪🇸 Espagnol | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Arabe | [README.ar.md](README.ar.md) | 🇫🇷 Français | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Allemand | [README.de.md](README.de.md) | 🇮🇳 Hindi | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Indonésien | [README.id.md](README.id.md) | 🇮🇹 Italien | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Japonais | [README.ja.md](README.ja.md) | 🇵🇱 Polonais | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Portugais | [README.pt.md](README.pt.md) | 🇷🇺 Russe | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Turc | [README.tr.md](README.tr.md) | 🇨🇳 Chinois | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Vous souhaitez contribuer à une traduction ?** Consultez la section [Contribution](#contributing) pour nous aider à ajouter d'autres langues !
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ Dépannage
|
||||
|
||||
<details>
|
||||
<summary>Cliquez pour voir les problèmes courants et les solutions</summary>
|
||||
|
||||
- **Le tableau des formats ne s'affiche pas :** Mettez à jour yt-dlp à la dernière version et passez à yt-dlp nightly.
|
||||
- **Échec du téléchargement :** Vérifiez votre connexion Internet et assurez-vous que la vidéo est disponible.
|
||||
- **Erreurs de téléchargement spécifiques :**
|
||||
- **Vidéos privées :** Utilisez l'authentification par cookies pour accéder au contenu privé.
|
||||
- **Contenu soumis à une limite d'âge :** Connectez-vous à votre compte YouTube pour visionner les vidéos avec limite d'âge.
|
||||
- **Vidéos géo-bloquées :** Envisagez d'utiliser un VPN pour contourner les restrictions régionales.
|
||||
- **Vidéos supprimées :** La vidéo n'est plus disponible sur YouTube.
|
||||
- **Directs (Live streams) :** Les directs ne peuvent pas être téléchargés ; attendez la fin de la diffusion.
|
||||
- **Erreurs réseau :** Vérifiez votre connexion Internet et réessayez.
|
||||
- **URL non valides :** Assurez-vous que l'URL est correcte et provient d'une plateforme prise en charge.
|
||||
- **Contenu Premium :** Nécessite un abonnement YouTube Premium.
|
||||
- **Blocages pour droits d'auteur :** Le contenu est bloqué en raison de restrictions de droits d'auteur.
|
||||
- **Fichiers vidéo et audio séparés après le téléchargement :** Cela se produit lorsque FFmpeg est manquant ou non détecté. YTSage nécessite FFmpeg pour fusionner les flux vidéo et audio de haute qualité.
|
||||
- **Solution :** Assurez-vous que FFmpeg est installé et accessible dans le PATH de votre système. Pour les utilisateurs Windows, l'option la plus simple est de télécharger le fichier `YTSage-v<version>-ffmpeg.exe`, qui est livré avec FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Avertissement Windows Defender / Antivirus
|
||||
|
||||
Certains logiciels antivirus peuvent signaler les fichiers `.exe` comme de faux positifs. Il s'agit d'une **limitation connue** des applications packagées.
|
||||
|
||||
**Pourquoi cela se produit :**
|
||||
- L'heuristique des antivirus peut identifier par erreur les exécutables packagés comme suspects.
|
||||
|
||||
**Alternatives sûres :**
|
||||
- ✅ **Utilisez l'installation pip :** `pip install ytsage` (recommandé)
|
||||
- ✅ **Compiler à partir des sources** : en suivant ce [guide](../.github/CI_CD_README.md)
|
||||
- ✅ **Mettre l'application en liste blanche** dans votre logiciel antivirus.
|
||||
|
||||
#### 🍎 macOS : "L'application est endommagée et ne peut pas être ouverte"
|
||||
Si vous voyez cette erreur sur macOS Sonoma ou une version plus récente, vous devez supprimer l'attribut de quarantaine.
|
||||
|
||||
1. **Ouvrez le Terminal** (vous pouvez le trouver en utilisant Spotlight).
|
||||
2. **Tapez la commande suivante** mais **n'appuyez pas** encore sur Entrée. Assurez-vous d'inclure l'espace à la fin :
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Faites glisser le fichier `YTSage.app`** depuis votre fenêtre Finder et déposez-le directement dans la fenêtre du Terminal. Cela collera automatiquement le chemin correct du fichier.
|
||||
4. **Appuyez sur Entrée** pour exécuter la commande.
|
||||
5. **Essayez d'ouvrir à nouveau YTSage.app.** Il devrait maintenant se lancer correctement.
|
||||
|
||||
---
|
||||
|
||||
#### **Emplacements de configuration (Avancé)**
|
||||
- **Windows :** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS :** `~/Library/Application Support/YTSage`
|
||||
- **Linux :** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Sponsor
|
||||
|
||||
Si YTSage vous fait gagner du temps, envisagez de sponsoriser le projet. Le parrainage aide à couvrir le temps de développement, les tests sur toutes les plateformes et les améliorations futures.
|
||||
|
||||
- GitHub Sponsors : https://github.com/sponsors/oop7
|
||||
- Le lien de parrainage est également disponible directement dans l'application via la boîte de dialogue À propos.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contributing"></a>
|
||||
## 👥 Contribution
|
||||
|
||||
Nous accueillons les contributions avec plaisir ! Voici comment vous pouvez aider :
|
||||
|
||||
1. 🍴 Forkez le dépôt
|
||||
2. 🌿 Créez votre branche de fonctionnalité :
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 Committez vos modifications :
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 Pushez vers la branche :
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 Ouvrez une Pull Request
|
||||
|
||||
### 🌍 Contribuer aux traductions
|
||||
|
||||
- Mettez à jour le fichier README localisé correspondant (par exemple `readme-translations/README.fr.md`)
|
||||
- Gardez les chaînes de l'application synchronisées en éditant `ytsage/languages/<code>.json`
|
||||
- Si votre langue est manquante, commencez par `README.md` et créez `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Structure du projet</summary>
|
||||
|
||||
## YTSage - Structure du projet
|
||||
|
||||
Ce document décrit la structure organisée des dossiers de YTSage.
|
||||
|
||||
### 📁 Structure du projet
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # Configuration GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Modèles de tickets
|
||||
│ │ └── 🐛-bug-report.md # Modèle de rapport de bug
|
||||
│ ├─── 📁 workflows/ # Workflows GitHub Actions
|
||||
│ │ ├── build-linux.yml # Workflow de build Linux
|
||||
│ │ ├── build-macos.yml # Workflow de build macOS
|
||||
│ │ │── build-windows.yml # Workflow de build Windows
|
||||
| | └── release-all.yml # Workflow de release master
|
||||
│ └── 📄 CI_CD_README.md # Documentation CI/CD
|
||||
├── 📁 branding/ # Actifs de marque (Captures d'écran, SVGs)
|
||||
│ ├── 📁 icons/ # Icônes de l'application
|
||||
│ ├── 📁 screenshots/ # Captures d'écran pour la documentation
|
||||
│ └── 📁 svg/ # Actifs SVG
|
||||
├── 📄 LICENSE # Fichier de licence
|
||||
├── 📄 pyproject.toml # Métadonnées du projet et dépendances
|
||||
├── 📄 README.md # Documentation du projet
|
||||
├── 📄 requirements.txt # Dépendances Python (dev)
|
||||
└── 📁 ytsage/ # Paquet source
|
||||
├── 📁 assets/ # Actifs d'exécution
|
||||
│ ├── 📁 Icon/ # Icônes de l'application
|
||||
│ └── 📁 sound/ # Fichiers audio
|
||||
├── 📁 languages/ # Fichiers de localisation
|
||||
│ ├── 📄 ar.json # Traduction arabe
|
||||
│ ├── 📄 de.json # Traduction allemande
|
||||
│ ├── 📄 en.json # Traduction anglaise
|
||||
│ └── ... # Autres langues
|
||||
├── 📁 core/ # Logique métier principale
|
||||
│ ├── 📄 __init__.py # Initialisation du paquet core
|
||||
│ ├── 📄 ytsage_deno.py # Intégration Deno
|
||||
│ ├── 📄 ytsage_downloader.py # Fonctionnalité de téléchargement
|
||||
│ ├── 📄 ytsage_ffmpeg.py # Intégration FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # Fonctions utilitaires
|
||||
│ └── 📄 ytsage_yt_dlp.py # Intégration yt-dlp
|
||||
├── 📁 gui/ # Composants de l'interface utilisateur
|
||||
│ ├── 📄 __init__.py # Initialisation du paquet GUI
|
||||
│ ├── 📄 ytsage_gui_main.py # Fenêtre principale de l'application
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Classes de dialogues
|
||||
├── 📁 utils/ # Modules utilitaires
|
||||
│ ├── 📄 __init__.py # Initialisation du paquet utils
|
||||
│ ├── 📄 ytsage_config_manager.py # Gestion de la configuration
|
||||
│ └── 📄 ytsage_logger.py # Utilitaires de log
|
||||
├── 📄 __init__.py # Point d'entrée du paquet
|
||||
└── 📄 main.py # Script d'exécution principal
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Historique des étoiles
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Licence
|
||||
|
||||
Ce projet est sous licence MIT - voir le fichier [LICENSE](../LICENSE) pour plus de détails.
|
||||
|
||||
## 🙏 Remerciements
|
||||
|
||||
<details>
|
||||
<summary>Afficher les remerciements</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Un grand merci à tous ceux qui ont contribué à ce projet en ouvrant un ticket pour suggérer une amélioration ou signaler un bug.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Composants de base</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Moteur de téléchargement</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Traitement média</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime pour l'intégration avec yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Bibliothèques et Frameworks</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>Framework GUI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Traitement d'images</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>Requêtes HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Gestion des versions et des paquets</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Rendu Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logging</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Actifs & Contributeurs</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>Son de notification</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Contributeur de code</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Clause de non-responsabilité
|
||||
|
||||
Cet outil est destiné à un usage personnel uniquement. Veuillez respecter les conditions d'utilisation de YouTube et les droits des créateurs de contenu.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Fait avec ❤️ par [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**एक आधुनिक YouTube डाउनलोडर, एक स्वच्छ PySide6 इंटरफ़ेस के साथ।**
|
||||
किसी भी गुणवत्ता में वीडियो डाउनलोड करें, ऑडियो निकालें, उपशीर्षक प्राप्त करें, और बहुत कुछ।
|
||||
|
||||
### 🌍 README भाषाएँ
|
||||
|
||||
अंग्रेज़ी: [EN](../README.md)
|
||||
| अरबी: [AR](README.ar.md)
|
||||
| जर्मन: [DE](README.de.md)
|
||||
| स्पेनिश: [ES](README.es.md)
|
||||
| फ्रेंच: [FR](README.fr.md)
|
||||
| हिंदी: [HI](README.hi.md)
|
||||
| इंडोनेशियाई: [ID](README.id.md)
|
||||
| इतालवी: [IT](README.it.md)
|
||||
| जापानी: [JA](README.ja.md)
|
||||
| पोलिश: [PL](README.pl.md)
|
||||
| पुर्तगाली: [PT](README.pt.md)
|
||||
| रूसी: [RU](README.ru.md)
|
||||
| तुर्की: [TR](README.tr.md)
|
||||
| चीनी: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">स्थापना</a> •
|
||||
<a href="#features">विशेषताएँ</a> •
|
||||
<a href="#usage">उपयोग</a> •
|
||||
<a href="#screenshots">स्क्रीनशॉट</a> •
|
||||
<a href="#troubleshooting">समस्या निवारण</a> •
|
||||
<a href="#sponsor">प्रायोजक</a> •
|
||||
<a href="#contributing">योगदान</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ytsage"></a>
|
||||
## ❓ YTSage क्यों?
|
||||
|
||||
YTSage उन उपयोगकर्ताओं के लिए डिज़ाइन किया गया है जो एक **सरल लेकिन शक्तिशाली YouTube डाउनलोडर** चाहते हैं। अन्य उपकरणों के विपरीत, यह प्रदान करता है:
|
||||
|
||||
- एक आधुनिक और स्वच्छ PySide6 इंटरफ़ेस
|
||||
- वीडियो, ऑडियो और उपशीर्षक के लिए वन-क्लिक डाउनलोड
|
||||
- SponsorBlock, उपशीर्षक मर्जिंग, और प्लेलिस्ट चयन जैसी उन्नत सुविधाएँ
|
||||
- YouTube से इतर yt-dlp द्वारा समर्थित साइटों के लिए वैकल्पिक जेनेरिक मोड
|
||||
- क्रॉस-प्लेटफ़ॉर्म समर्थन और आसान स्थापना
|
||||
|
||||
<a id="features"></a>
|
||||
## ✨ विशेषताएँ
|
||||
|
||||
<div align="center">
|
||||
|
||||
| बुनियादी विशेषताएँ | उन्नत विशेषताएँ | अतिरिक्त विशेषताएँ |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 प्रारूप तालिका | 🚫 SponsorBlock एकीकरण | 🎞️ FPS/HDR डिस्प्ले |
|
||||
| 🎵 ऑडियो निष्कर्षण | 📝 उपशीर्षक चयन और मर्जिंग | 🔄 ऑटो yt-dlp अपडेट |
|
||||
| ✨ सरल यूजर इंटरफेस | 💾 विवरण और थंबनेल सहेजें | 🛠️ FFmpeg/yt-dlp/Deno डिटेक्शन |
|
||||
| 📋 प्लेलिस्ट समर्थन और चयनकर्ता | 🚀 गति सीमित करने वाला | ⚙️ कस्टम कमांड |
|
||||
| 📑 अध्याय एकीकरण | ✂️ वीडियो अनुभाग ट्रिम करें | 🍪 कुकी लॉगिन |
|
||||
| 📜 डाउनलोड इतिहास | 🔄 रिलीज़ चैनल चयन | 🌐 प्रॉक्सी समर्थन |
|
||||
| 🎚️ ऑडियो प्रारूप रूपांतरण | 🎬 वीडियो प्रारूप सेटिंग्स | 🆙 एकीकृत अपडेट टैब |
|
||||
| 🌍 जेनेरिक मोड | 🔊 ऑडियो सामान्यीकरण (EBU R128) | 🌍 14 भाषाओं में स्थानीयकरण |
|
||||
| 💾 प्लेलिस्ट निर्यात | ⚙️ डिफ़ॉल्ट गुणवत्ता और उपशीर्षक | |
|
||||
</div>
|
||||
|
||||
<a id="installation"></a>
|
||||
## 🚀 स्थापना
|
||||
|
||||
### ⚡ त्वरित स्थापना (अनुशंसित)
|
||||
|
||||
PyPI के माध्यम से YTSage स्थापित करें:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 मौजूदा स्थापना को अपडेट करें</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
फिर एप्लिकेशन चलाएँ:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 प्री-बिल्ट एक्जीक्यूटेबल्स (एक्ज़ीक्यूटेबल्स)
|
||||
|
||||
> [👉 नवीनतम रिलीज़ डाउनलोड करें](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| प्रारूप | विवरण |
|
||||
|--------|-------------|
|
||||
|  | मानक इंस्टॉलर |
|
||||
|  | FFmpeg के साथ |
|
||||
|  | पोर्टेबल संस्करण, स्थापना की आवश्यकता नहीं है |
|
||||
|  | FFmpeg के साथ पोर्टेबल, ज़िप्ड |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ स्थापना चरण</summary>
|
||||
|
||||
1. **EXE इंस्टॉलर (`.exe`)**: फ़ाइल पर डबल-क्लिक करें और सेटअप विज़ार्ड का पालन करें।
|
||||
2. **पोर्टेबल संस्करण (`.zip`)**: संग्रह को इच्छित स्थान पर निकालें और `ytsage.exe` चलाएँ।
|
||||
3. **बिल्ट-इन FFmpeg**: यदि आपके सिस्टम पर FFmpeg स्थापित नहीं है, तो बिल्ट-इन FFmpeg वाले संस्करण चुनें।
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| प्रारूप | विवरण |
|
||||
|--------|-------------|
|
||||
|  | डेबियन पैकेज |
|
||||
|  | AppImage, पोर्टेबल |
|
||||
|  | RPM पैकेज |
|
||||
|  | Flatpak बंडल |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ स्थापना चरण</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # यदि आवश्यक हो तो गायब निर्भरताओं को ठीक करें
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Flathub पर निर्देशों का पालन करें या चलाएँ:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| प्रारूप | विवरण |
|
||||
|--------|-------------|
|
||||
|  | Apple Silicon के लिए ज़िप्ड ऐप |
|
||||
|  | Apple Silicon के लिए डिस्क इमेज इंस्टॉलर |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ स्थापना चरण</summary>
|
||||
|
||||
- **DMG इंस्टॉलर (`.dmg`)**: माउंट करने के लिए डबल-क्लिक करें, फिर `YTSage.app` को अपने एप्लिकेशन फ़ोल्डर में खींचें।
|
||||
- **ऐप आर्काइव (`.zip`)**: ज़िप निकालें और `YTSage.app` को अपने एप्लिकेशन फ़ोल्डर में ले जाएँ।
|
||||
|
||||
*नोट: यदि आपको "ऐप क्षतिग्रस्त है" त्रुटि मिलती है, तो नीचे macOS समस्या निवारण अनुभाग देखें।*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 स्रोत से मैन्युअल स्थापना</summary>
|
||||
|
||||
### 1. रिपॉजिटरी क्लोन करें
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. निर्भरताएँ स्थापित करें
|
||||
|
||||
#### ⚡ uv के साथ
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 या मानक pip के साथ
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. एप्लिकेशन चलाएँ
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
## 📸 स्क्रीनशॉट
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="डाउनलोड सेटिंग्स" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="प्लेलिस्ट डाउनलोड" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>डाउनलोड सेटिंग्स</em></td>
|
||||
<td align="center"><em>प्लेलिस्ट डाउनलोड</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="ऑडियो प्रारूप चयन" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="कस्टम विकल्प" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>ऑडियो प्रारूप</em></td>
|
||||
<td align="center"><em>कस्टम विकल्प</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="usage"></a>
|
||||
## 📖 उपयोग
|
||||
|
||||
<details>
|
||||
<summary>🎯 बुनियादी उपयोग</summary>
|
||||
|
||||
1. **YTSage लॉन्च करें**
|
||||
2. **YouTube URL पेस्ट करें** (या "Paste URL" बटन का उपयोग करें)
|
||||
3. **"Analyze" पर क्लिक करें**
|
||||
4. **प्रारूप चुनें:**
|
||||
- वीडियो डाउनलोड के लिए `Video`
|
||||
- ऑडियो निष्कर्षण के लिए `Audio Only`
|
||||
5. **विकल्प चुनें:**
|
||||
- उपशीर्षक सक्षम करें और भाषा चुनें
|
||||
- उपशीर्षक मर्जिंग सक्षम करें
|
||||
- थंबनेल सहेजें
|
||||
- प्रायोजित अनुभाग हटाएँ
|
||||
- विवरण सहेजें
|
||||
- अध्याय एम्बेड करें
|
||||
6. **आउटपुट निर्देशिका चुनें**
|
||||
7. **"Download" पर क्लिक करें**
|
||||
|
||||
> 💡 डिफ़ॉल्ट डाउनलोड निर्देशिका उपयोगकर्ता का "Downloads" फ़ोल्डर है।
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 प्लेलिस्ट डाउनलोड</summary>
|
||||
|
||||
1. **प्लेलिस्ट URL पेस्ट करें**
|
||||
2. **"Analyze" पर क्लिक करें**
|
||||
3. **प्लेलिस्ट चयनकर्ता से वीडियो चुनें (वैकल्पिक, डिफ़ॉल्ट रूप से सभी)**
|
||||
4. **वांछित प्रारूप/गुणवत्ता चुनें**
|
||||
5. **"Download" पर क्लिक करें**
|
||||
|
||||
> 💡 एप्लिकेशन डाउनलोड कतार को स्वचालित रूप से प्रबंधित करता है, और आप प्लेलिस्ट प्रविष्टियों को `.txt`, `.csv`, `.m3u`, या `.json` फ़ाइलों के रूप में निर्यात कर सकते हैं।
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 गैर-YouTube साइटों के लिए जेनेरिक मोड</summary>
|
||||
|
||||
जेनेरिक मोड (Generic Mode) का उपयोग तब करें जब आप चाहते हैं कि YTSage yt-dlp द्वारा समर्थित साइटों जैसे Dailymotion, CBC Gem, TikTok और अन्य से URL स्वीकार करे।
|
||||
|
||||
इसका उपयोग कैसे करें:
|
||||
|
||||
1. `Download Settings` खोलें।
|
||||
2. `Generic Mode` सक्षम करें।
|
||||
3. एक समर्थित वीडियो या प्लेलिस्ट URL पेस्ट करें जो YouTube नहीं है।
|
||||
4. `Analyze` पर क्लिक करें।
|
||||
5. एक प्रारूप चुनें और हमेशा की तरह डाउनलोड करें।
|
||||
|
||||
नोट्स:
|
||||
|
||||
- जेनेरिक मोड केवल YTSage के अंदर URL सत्यापन को बदलता है। लक्ष्य साइट अभी भी आपके स्थापित yt-dlp संस्करण द्वारा समर्थित होनी चाहिए।
|
||||
- कुछ साइटों को एक्सट्रैक्टर के आधार पर कुकीज़, लॉगिन, प्रॉक्सी या अतिरिक्त yt-dlp तर्कों की आवश्यकता होती है।
|
||||
- यदि कोई साइट विफल हो जाती है, तो समस्या की रिपोर्ट करने से पहले एकीकृत अपडेट टैब से yt-dlp को अपडेट करें।
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 मीडिया और डाउनलोड विकल्प</summary>
|
||||
|
||||
- **उपशीर्षक विकल्प:** भाषाओं को फ़िल्टर करें और उपशीर्षक को वीडियो फ़ाइल में एम्बेड करें।
|
||||
- **उपशीर्षक मर्जिंग:** हार्डकोडेड उपशीर्षक के लिए वीडियो फ़ाइल में उपशीर्षक मर्ज करें।
|
||||
- **विवरण सहेजें:** वीडियो विवरण को टेक्स्ट फ़ाइल के रूप में सहेजें।
|
||||
- **थंबनेल सहेजें:** वीडियो थंबनेल को छवि फ़ाइल के रूप में सहेजें।
|
||||
- **अध्याय एम्बेड करें:** संगत वीडियो प्लेयर के लिए मेटाडेटा के रूप में अध्याय मार्कर शामिल करें।
|
||||
- **प्रायोजित अनुभाग हटाएँ:** SponsorBlock का उपयोग करके वीडियो से प्रायोजित अनुभाग हटाएँ।
|
||||
- **वीडियो ट्रिम करें:** `HH:MM:SS` प्रारूप में समय सीमा निर्दिष्ट करके वीडियो के केवल विशिष्ट भागों को डाउनलोड करें।
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ आउटपुट और फ़ाइल सेटिंग्स</summary>
|
||||
|
||||
- **गति सीमित करने वाला:** डाउनलोड गति को सीमित करें, उदाहरण के लिए `500K` 500 KB/s के लिए।
|
||||
- **डाउनलोड पथ सहेजें:** भविष्य के डाउनलोड के लिए डिफ़ॉल्ट डाउनलोड पथ सहेजता है। **Download Settings → Download Path** में उपलब्ध है।
|
||||
- **डिफ़ॉल्ट वीडियो रिज़ॉल्यूशन:** स्वचालित चयन के लिए अपना पसंदीदा वीडियो रिज़ॉल्यूशन सेट करें (जैसे 1080p, 720p)। **Download Settings → Default Video Resolution** में उपलब्ध है।
|
||||
- **डिफ़ॉल्ट उपशीर्षक भाषाएँ:** स्वचालित चयन के लिए डिफ़ॉल्ट उपशीर्षक भाषाएँ सेट करें (अल्पविराम से अलग, जैसे `hi,en`)। **Download Settings → Default Subtitle Languages** में उपलब्ध है।
|
||||
- **फ़ाइल नाम प्रारूप:** `%(title)s`, `%(uploader)s`, `%(playlist_index)s` और `%(resolution)s` जैसे चरों का उपयोग करके आउटपुट फ़ाइल नाम प्रारूप को अनुकूलित करें। **Download Settings → Filename Format** में उपलब्ध है।
|
||||
- **आउटपुट प्रारूप बाध्य करें:** वीडियो डाउनलोड को `mp4`, `webm` या `mkv` जैसे विशिष्ट कंटेनर प्रारूप में बाध्य करें। **Download Settings → Output Format Settings** में उपलब्ध है।
|
||||
- **ऑडियो प्रारूप रूपांतरण:** केवल ऑडियो डाउनलोड को `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, या `Best` जैसे पसंदीदा प्रारूपों में परिवर्तित करें। **Download Settings → Audio Format Settings** में उपलब्ध है।
|
||||
- **ऑडियो सामान्यीकरण:** EBU R128 का उपयोग करके केवल ऑडियो डाउनलोड के लिए वॉल्यूम को मानकीकृत करें।
|
||||
- **एक साथ कनेक्शन:** एक साथ कई टुकड़ों में फ़ाइलें डाउनलोड करके डाउनलोड गति को काफी बढ़ाएँ। **Download Settings → General → Concurrent Connections** (डिफ़ॉल्ट 1, IP ब्लॉक से बचने के लिए अधिकतम 8-10 अनुशंसित) में उपलब्ध है।
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 एक्सेस और नेटवर्क</summary>
|
||||
|
||||
- **कुकी लॉगिन:** निजी सामग्री तक पहुँचने के लिए कुकीज़ का उपयोग करके YouTube में लॉगिन करें।
|
||||
उपयोग:
|
||||
1. **अनुशंसित:** ऐप में बिल्ट-इन `Extract cookies from browser` विकल्प का उपयोग करें, फिर अपना ब्राउज़र और वैकल्पिक रूप से प्रोफ़ाइल चुनें।
|
||||
2. वैकल्पिक रूप से, कुकीज़ मैन्युअल रूप से निकालें:
|
||||
a. [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file) जैसे एक्सटेंशन का उपयोग करके अपने ब्राउज़र से कुकीज़ निर्यात करें
|
||||
b. नेटस्केप प्रारूप में कुकीज़ कॉपी करें
|
||||
c. `cookies.txt` नामक फ़ाइल बनाएँ और कुकीज़ पेस्ट करें
|
||||
d. ऐप में `cookies.txt` फ़ाइल चुनें
|
||||
- **प्रॉक्सी समर्थन:** डाउनलोड के लिए प्रॉक्सी सर्वर का उपयोग करें, जैसे `http://<proxy-server>:<port>`
|
||||
- **जेनेरिक मोड:** YTSage को yt-dlp द्वारा समर्थित गैर-YouTube साइटों से विश्लेषण और डाउनलोड करने की अनुमति देता है। इसे **Download Settings → Generic Mode** से सक्षम करें।
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ उपकरण और रखरखाव</summary>
|
||||
|
||||
- **कस्टम कमांड:** कमांड-लाइन तर्कों के माध्यम से उन्नत yt-dlp सुविधाओं तक पहुँचें।
|
||||
- **अपडेट टैब:** कस्टम विकल्पों में एक ही स्थान से बिल्ट-इन अपडेट टूल प्रबंधित करें:
|
||||
- **yt-dlp अपडेट:** अपडेट के लिए जाँच करें और स्टेबल और नाइटली रिलीज़ चैनलों के बीच स्विच करें।
|
||||
- **FFmpeg संस्करण जाँचकर्ता:** अपने FFmpeg संस्करण को सत्यापित करें और स्थापना मार्गदर्शिकाएँ खोलें।
|
||||
- **Deno अपडेट:** Deno रनटाइम की जाँच करें और अपडेट करें।
|
||||
- **FFmpeg/yt-dlp/Deno डिटेक्शन:** अबाउट डायलॉग से FFmpeg, yt-dlp और Deno के पथ और संस्करण का स्वचालित रूप से पता लगाता है।
|
||||
- **डाउनलोड इतिहास:** **History** बटन से थंबनेल और स्थितियों के साथ पिछले डाउनलोड देखें।
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 स्थानीयकरण</summary>
|
||||
|
||||
YTSage वैश्विक पहुँच के लिए **14 भाषाओं** का समर्थन करता है। **Custom Options → Language** में अपनी पसंदीदा भाषा चुनें।
|
||||
|
||||
### समर्थित भाषाएँ
|
||||
|
||||
| भाषा | कोड | भाषा | कोड |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 अंग्रेज़ी | `en` | 🇪🇸 स्पेनिश | `es` |
|
||||
| 🇸🇦 अरबी | `ar` | 🇫🇷 फ्रेंच | `fr` |
|
||||
| 🇩🇪 जर्मन | `de` | 🇮🇳 हिंदी | `hi` |
|
||||
| 🇮🇩 इंडोनेशियाई | `id` | 🇮🇹 इतालवी | `it` |
|
||||
| 🇯🇵 जापानी | `ja` | 🇵🇱 पोलिश | `pl` |
|
||||
| 🇧🇷 पुर्तगाली | `pt` | 🇷🇺 रूसी | `ru` |
|
||||
| 🇹🇷 तुर्की | `tr` | 🇨🇳 चीनी | `zh` |
|
||||
|
||||
### README अनुवाद
|
||||
|
||||
| भाषा | फ़ाइल | भाषा | फ़ाइल |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 अंग्रेज़ी | [README.md](../README.md) | 🇪🇸 स्पेनिश | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 अरबी | [README.ar.md](README.ar.md) | 🇫🇷 फ्रेंच | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 जर्मन | [README.de.md](README.de.md) | 🇮🇳 हिंदी | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 इंडोनेशियाई | [README.id.md](README.id.md) | 🇮🇹 इतालवी | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 जापानी | [README.ja.md](README.ja.md) | 🇵🇱 पोलिश | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 पुर्तगाली | [README.pt.md](README.pt.md) | 🇷🇺 रूसी | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 तुर्की | [README.tr.md](README.tr.md) | 🇨🇳 चीनी | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **अनुवाद में मदद करना चाहते हैं?** अधिक भाषाओं को जोड़ने में हमारी सहायता करने के लिए [योगदान](#contributing) अनुभाग देखें!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ समस्या निवारण
|
||||
|
||||
<details>
|
||||
<summary>सामान्य समस्याओं और समाधानों को देखने के लिए क्लिक करें</summary>
|
||||
|
||||
- **प्रारूप तालिका दिखाई नहीं दे रही है:** yt-dlp को नवीनतम संस्करण में अपडेट करें और yt-dlp नाइटली पर स्विच करें।
|
||||
- **डाउनलोड विफल:** अपना इंटरनेट कनेक्शन जाँचें और सुनिश्चित करें कि वीडियो उपलब्ध है।
|
||||
- **विशिष्ट डाउनलोड त्रुटियाँ:**
|
||||
- **निजी वीडियो:** निजी सामग्री तक पहुँचने के लिए कुकी प्रमाणीकरण का उपयोग करें।
|
||||
- **आयु-प्रतिबंधित सामग्री:** आयु-प्रतिबंधित वीडियो देखने के लिए अपने YouTube खाते में लॉग इन करें।
|
||||
- **जियो-ब्लॉक किए गए वीडियो:** क्षेत्रीय प्रतिबंधों को दरकिनार करने के लिए VPN का उपयोग करने पर विचार करें।
|
||||
- **हटाए गए वीडियो:** वीडियो अब YouTube पर उपलब्ध नहीं है।
|
||||
- **लाइव स्ट्रीम:** प्रसारण के दौरान लाइव स्ट्रीम डाउनलोड नहीं की जा सकतीं; स्ट्रीम समाप्त होने तक प्रतीक्षा करें।
|
||||
- **नेटवर्क त्रुटियाँ:** अपना इंटरनेट कनेक्शन जाँचें और पुनः प्रयास करें।
|
||||
- **अमान्य URL:** सुनिश्चित करें कि URL सही है और समर्थित प्लेटफ़ॉर्म से है।
|
||||
- **प्रीमियम सामग्री:** YouTube प्रीमियम सदस्यता की आवश्यकता है।
|
||||
- **कॉपीराइट ब्लॉक:** कॉपीराइट प्रतिबंधों के कारण सामग्री अवरुद्ध है।
|
||||
- **डाउनलोड के बाद वीडियो और ऑडियो फ़ाइलें अलग हैं:** यह तब होता है जब FFmpeg गायब होता है या पता नहीं चल पाता है। उच्च गुणवत्ता वाले वीडियो और ऑडियो स्ट्रीम को मर्ज करने के लिए YTSage को FFmpeg की आवश्यकता होती है।
|
||||
- **समाधान:** सुनिश्चित करें कि FFmpeg स्थापित है और आपके सिस्टम PATH में सुलभ है। विंडोज उपयोगकर्ताओं के लिए, सबसे आसान विकल्प `YTSage-v<version>-ffmpeg.exe` फ़ाइल डाउनलोड करना है, जो FFmpeg के साथ आती है।
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Windows Defender / एंटीवायरस चेतावनी
|
||||
|
||||
कुछ एंटीवायरस सॉफ़्टवेयर `.exe` फ़ाइलों को गलत सकारात्मक (फॉल्स पॉजिटिव) के रूप में चिह्नित कर सकते हैं। यह पैक किए गए एप्लिकेशन की **ज्ञात सीमा** है।
|
||||
|
||||
**यह क्यों होता है:**
|
||||
- एंटीवायरस हिउरिस्टिक्स पैक किए गए एक्जीक्यूटेबल्स को संदिग्ध के रूप में गलत तरीके से पहचान सकते हैं।
|
||||
|
||||
**सुरक्षित विकल्प:**
|
||||
- ✅ **pip स्थापना का उपयोग करें:** `pip install ytsage` (अनुशंसित)
|
||||
- ✅ **स्रोत से बिल्ड करें**: इस [गाइड](../.github/CI_CD_README.md) का पालन करते हुए
|
||||
- ✅ **एप्लिकेशन को व्हाइटलिस्ट करें** अपने एंटीवायरस सॉफ़्टवेयर में।
|
||||
|
||||
#### 🍎 macOS: "ऐप क्षतिग्रस्त है और खोला नहीं जा सकता"
|
||||
यदि आप macOS Sonoma या नए पर यह त्रुटि देखते हैं, तो आपको क्वारंटाइन एट्रिब्यूट को हटाने की आवश्यकता है।
|
||||
|
||||
1. **टर्मिनल खोलें** (आप इसे Spotlight का उपयोग करके पा सकते हैं)।
|
||||
2. **निम्न कमांड टाइप करें** लेकिन अभी Enter **न दबाएँ**। सुनिश्चित करें कि अंत में स्थान शामिल है:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **अपनी फाइंडर विंडो से `YTSage.app` फ़ाइल खींचें** और इसे सीधे टर्मिनल विंडो में छोड़ें। यह स्वचालित रूप से सही फ़ाइल पथ पेस्ट कर देगा।
|
||||
4. **Enter दबाएँ** कमांड चलाने के लिए।
|
||||
5. **YTSage.app को फिर से खोलने का प्रयास करें।** इसे अब ठीक से लॉन्च होना चाहिए।
|
||||
|
||||
---
|
||||
|
||||
#### **कॉन्फ़िगरेशन स्थान (उन्नत)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 प्रायोजक
|
||||
|
||||
यदि YTSage आपका समय बचाता है, तो कृपया प्रोजेक्ट को प्रायोजित करने पर विचार करें। प्रायोजन विकास समय, सभी प्लेटफ़ॉर्म पर परीक्षण और भविष्य के सुधारों को कवर करने में मदद करता है।
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- प्रायोजन लिंक ऐप में अबाउट डायलॉग के माध्यम से सीधे उपलब्ध है।
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contributing"></a>
|
||||
## 👥 योगदान
|
||||
|
||||
हम योगदान का स्वागत करते हैं! यहाँ बताया गया है कि आप कैसे मदद कर सकते हैं:
|
||||
|
||||
1. 🍴 रिपॉजिटरी को फोर्क करें
|
||||
2. 🌿 अपनी फीचर शाखा बनाएँ:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 अपने बदलाव कमिट करें:
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 शाखा में पुश करें:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 एक पुल रिक्वेस्ट खोलें
|
||||
|
||||
### 🌍 अनुवाद में योगदान करें
|
||||
|
||||
- संबंधित स्थानीयकृत README फ़ाइल को अपडेट करें (जैसे `readme-translations/README.hi.md`)
|
||||
- `ytsage/languages/<code>.json` को संपादित करके ऐप स्ट्रिंग्स को सिंक में रखें
|
||||
- यदि आपकी भाषा गायब है, तो `README.md` से शुरू करें और `README.<code>.md` बनाएँ
|
||||
|
||||
<details>
|
||||
<summary>📂 प्रोजेक्ट संरचना</summary>
|
||||
|
||||
## YTSage - प्रोजेक्ट संरचना
|
||||
|
||||
यह दस्तावेज़ YTSage की संगठित फ़ोल्डर संरचना का वर्णन करता है।
|
||||
|
||||
### 📁 प्रोजेक्ट संरचना
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # GitHub कॉन्फ़िगरेशन
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # इश्यू टेम्पलेट
|
||||
│ │ └── 🐛-bug-report.md # बग रिपोर्ट टेम्पलेट
|
||||
│ ├─── 📁 workflows/ # GitHub Actions वर्कफ़्लो
|
||||
│ │ ├── build-linux.yml # लिनक्स बिल्ड वर्कफ़्लो
|
||||
│ │ ├── build-macos.yml # macOS बिल्ड वर्कफ़्लो
|
||||
│ │ │── build-windows.yml # विंडोज बिल्ड वर्कफ़्लो
|
||||
| | └── release-all.yml # मास्टर रिलीज़ वर्कफ़्लो
|
||||
│ └── 📄 CI_CD_README.md # CI/CD दस्तावेज़ीकरण
|
||||
├── 📁 branding/ # ब्रांडिंग एसेट्स (स्क्रीनशॉट, SVGs)
|
||||
│ ├── 📁 icons/ # ऐप आइकन
|
||||
│ ├── 📁 screenshots/ # दस्तावेज़ीकरण के लिए स्क्रीनशॉट
|
||||
│ └── 📁 svg/ # SVG एसेट्स
|
||||
├── 📄 LICENSE # लाइसेंस फ़ाइल
|
||||
├── 📄 pyproject.toml # प्रोजेक्ट मेटाडेटा और निर्भरताएँ
|
||||
├── 📄 README.md # प्रोजेक्ट दस्तावेज़ीकरण
|
||||
├── 📄 requirements.txt # पायथन निर्भरताएँ (dev)
|
||||
└── 📁 ytsage/ # स्रोत कोड पैकेज
|
||||
├── 📁 assets/ # रनटाइम एसेट्स
|
||||
│ ├── 📁 Icon/ # ऐप आइकन
|
||||
│ └── 📁 sound/ # ऑडियो फ़ाइलें
|
||||
├── 📁 languages/ # स्थानीयकरण फ़ाइलें
|
||||
│ ├── 📄 ar.json # अरबी अनुवाद
|
||||
│ ├── 📄 de.json # जर्मन अनुवाद
|
||||
│ ├── 📄 en.json # अंग्रेज़ी अनुवाद
|
||||
│ └── ... # अन्य भाषाएँ
|
||||
├── 📁 core/ # मुख्य व्यावसायिक तर्क
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Deno एकीकरण
|
||||
│ ├── 📄 ytsage_downloader.py # डाउनलोड कार्यक्षमता
|
||||
│ ├── 📄 ytsage_ffmpeg.py # FFmpeg एकीकरण
|
||||
│ ├── 📄 ytsage_utils.py # उपयोगिता कार्य
|
||||
│ └── 📄 ytsage_yt_dlp.py # yt-dlp एकीकरण
|
||||
├── 📁 gui/ # यूजर इंटरफेस घटक
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # ऐप की मुख्य विंडो
|
||||
│ └── 📁 ytsage_gui_dialogs/ # संवाद वर्ग
|
||||
├── 📁 utils/ # उपयोगिता मॉड्यूल
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # कॉन्फ़िगरेशन प्रबंधन
|
||||
│ └── 📄 ytsage_logger.py # लॉगिंग टूल
|
||||
├── 📄 __init__.py # पैकेज प्रविष्टि बिंदु
|
||||
└── 📄 main.py # मुख्य निष्पादन स्क्रिप्ट
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ स्टार इतिहास
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 लाइसेंस
|
||||
|
||||
यह प्रोजेक्ट MIT लाइसेंस के तहत है - विवरण के लिए [LICENSE](../LICENSE) फ़ाइल देखें।
|
||||
|
||||
## 🙏 धन्यवाद
|
||||
|
||||
<details>
|
||||
<summary>धन्यवाद प्रदर्शित करें</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>उन सभी को बहुत-बहुत धन्यवाद जिन्होंने सुधार का सुझाव देने या बग की रिपोर्ट करने के लिए इश्यू खोलकर इस प्रोजेक्ट में योगदान दिया है।</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">मुख्य घटक</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>डाउनलोड इंजन</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>मीडिया प्रसंस्करण</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>yt-dlp एकीकरण के लिए रनटाइम</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">पुस्तकालय और फ्रेमवर्क</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI फ्रेमवर्क</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>छवि प्रसंस्करण</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>HTTP अनुरोध</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>संस्करण प्रबंधन और पैकेजिंग</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Markdown रेंडरिंग</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>लॉगिंग</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">एसेट्स और योगदानकर्ता</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>अधिसूचना ध्वनि</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>कोड योगदानकर्ता</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ अस्वीकरण
|
||||
|
||||
यह उपकरण केवल व्यक्तिगत उपयोग के लिए है। कृपया YouTube की सेवा की शर्तों और सामग्री रचनाकारों के अधिकारों का सम्मान करें।
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
[oop7](https://github.com/oop7) द्वारा ❤️ के साथ बनाया गया
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Pengunduh YouTube modern dengan antarmuka PySide6 yang bersih.**
|
||||
Unduh video dalam kualitas apa pun, ekstrak audio, dapatkan subtitle, dan banyak lagi.
|
||||
|
||||
### 🌍 Bahasa README
|
||||
|
||||
Inggris: [EN](../README.md)
|
||||
| Arab: [AR](README.ar.md)
|
||||
| Jerman: [DE](README.de.md)
|
||||
| Spanyol: [ES](README.es.md)
|
||||
| Prancis: [FR](README.fr.md)
|
||||
| Hindi: [HI](README.hi.md)
|
||||
| Indonesia: [ID](README.id.md)
|
||||
| Italia: [IT](README.it.md)
|
||||
| Jepang: [JA](README.ja.md)
|
||||
| Polandia: [PL](README.pl.md)
|
||||
| Portugis: [PT](README.pt.md)
|
||||
| Rusia: [RU](README.ru.md)
|
||||
| Turki: [TR](README.tr.md)
|
||||
| Mandarin: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#instalasi">Instalasi</a> •
|
||||
<a href="#fitur">Fitur</a> •
|
||||
<a href="#penggunaan">Penggunaan</a> •
|
||||
<a href="#screenshot">Screenshot</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#sponsor">Sponsor</a> •
|
||||
<a href="#kontribusi">Kontribusi</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="mengapa-ytsage"></a>
|
||||
## ❓ Mengapa YTSage?
|
||||
|
||||
YTSage dirancang untuk pengguna yang menginginkan **pengunduh YouTube yang sederhana namun kuat**. Tidak seperti alat lainnya, ia menawarkan:
|
||||
|
||||
- Antarmuka PySide6 yang modern dan bersih
|
||||
- Unduh video, audio, dan subtitle sekali klik
|
||||
- Fitur canggih seperti SponsorBlock, penggabungan subtitle, dan pemilihan playlist
|
||||
- Mode Generik Opsional untuk situs di luar YouTube yang didukung oleh yt-dlp
|
||||
- Dukungan lintas platform dan instalasi mudah
|
||||
|
||||
<a id="fitur"></a>
|
||||
## ✨ Fitur
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Fitur Dasar | Fitur Lanjutan | Fitur Tambahan |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Tabel Format | 🚫 Integrasi SponsorBlock | 🎞️ Tampilan FPS/HDR |
|
||||
| 🎵 Ekstraksi Audio | 📝 Pemilihan & Penggabungan Subtitle | 🔄 Pembaruan yt-dlp Otomatis |
|
||||
| ✨ Antarmuka Pengguna Sederhana | 💾 Simpan Deskripsi & Thumbnail | 🛠️ Deteksi FFmpeg/yt-dlp/Deno |
|
||||
| 📋 Dukungan & Pemilih Playlist | 🚀 Pembatas Kecepatan | ⚙️ Perintah Kustom |
|
||||
| 📑 Integrasi Bab (Chapters) | ✂️ Potong Bagian Video | 🍪 Login Cookie |
|
||||
| 📜 Riwayat Unduhan | 🔄 Pilihan Saluran Rilis | 🌐 Dukungan Proxy |
|
||||
| 🎚️ Konversi Format Audio | 🎬 Pengaturan Format Video | 🆙 Tab Pembaruan Terintegrasi |
|
||||
| 🌍 Mode Generik | 🔊 Normalisasi Audio (EBU R128) | 🌍 Lokalisasi dalam 14 Bahasa |
|
||||
| 💾 Ekspor Playlist | ⚙️ Kualitas & Subtitle Default | |
|
||||
</div>
|
||||
|
||||
<a id="instalasi"></a>
|
||||
## 🚀 Instalasi
|
||||
|
||||
### ⚡ Instalasi Cepat (Direkomendasikan)
|
||||
|
||||
Instal YTSage melalui PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Perbarui Instalasi yang Ada</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Kemudian jalankan aplikasi:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Executable Siap Pakai (Executable)
|
||||
|
||||
> [👉 Unduh Rilis Terbaru](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Format | Deskripsi |
|
||||
|--------|-------------|
|
||||
|  | Installer Standar |
|
||||
|  | Dilengkapi dengan FFmpeg |
|
||||
|  | Versi Portabel, tidak perlu instalasi |
|
||||
|  | Portabel dengan FFmpeg, dikompresi (ZIP) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Langkah Instalasi</summary>
|
||||
|
||||
1. **Installer EXE (`.exe`)**: Klik dua kali pada file dan ikuti wizard pengaturan.
|
||||
2. **Versi Portabel (`.zip`)**: Ekstrak arsip ke lokasi yang diinginkan dan jalankan `ytsage.exe`.
|
||||
3. **FFmpeg Bawaan**: Jika Anda tidak memiliki FFmpeg di sistem Anda, pilih versi dengan FFmpeg bawaan.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Format | Deskripsi |
|
||||
|--------|-------------|
|
||||
|  | Paket Debian |
|
||||
|  | AppImage, Portabel |
|
||||
|  | Paket RPM |
|
||||
|  | Bundel Flatpak |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Langkah Instalasi</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Jika perlu perbaiki dependensi yang kurang
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Ikuti instruksi di Flathub atau jalankan:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Format | Deskripsi |
|
||||
|--------|-------------|
|
||||
|  | Aplikasi ZIP untuk Apple Silicon |
|
||||
|  | Installer Disk Image untuk Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Langkah Instalasi</summary>
|
||||
|
||||
- **Installer DMG (`.dmg`)**: Klik dua kali untuk memasang, lalu tarik `YTSage.app` ke folder Applications Anda.
|
||||
- **Arsip Aplikasi (`.zip`)**: Ekstrak ZIP dan pindahkan `YTSage.app` ke folder Applications Anda.
|
||||
|
||||
*Catatan: Jika Anda mendapatkan kesalahan "App is damaged", lihat bagian Troubleshooting macOS di bawah ini.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Instalasi Manual dari Sumber (Source)</summary>
|
||||
|
||||
### 1. Kloning Repositori
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Instal Dependensi
|
||||
|
||||
#### ⚡ Dengan uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Atau dengan pip standar
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Jalankan Aplikasi
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshot"></a>
|
||||
## 📸 Screenshot
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Pengaturan Unduhan" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Unduhan Playlist" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Pengaturan Unduhan</em></td>
|
||||
<td align="center"><em>Unduhan Playlist</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Pemilihan Format Audio" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Opsi Kustom" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Format Audio</em></td>
|
||||
<td align="center"><em>Opsi Kustom</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="penggunaan"></a>
|
||||
## 📖 Penggunaan
|
||||
|
||||
<details>
|
||||
<summary>🎯 Penggunaan Dasar</summary>
|
||||
|
||||
1. **Luncurkan YTSage**
|
||||
2. **Tempel URL YouTube** (atau gunakan tombol "Paste URL")
|
||||
3. **Klik "Analyze"**
|
||||
4. **Pilih Format:**
|
||||
- `Video` untuk unduhan video
|
||||
- `Audio Only` untuk ekstraksi audio
|
||||
5. **Pilih Opsi:**
|
||||
- Aktifkan subtitle dan pilih bahasa
|
||||
- Aktifkan penggabungan subtitle
|
||||
- Simpan thumbnail
|
||||
- Hapus bagian sponsor
|
||||
- Simpan deskripsi
|
||||
- Masukkan bab (chapters)
|
||||
6. **Pilih Direktori Output**
|
||||
7. **Klik "Download"**
|
||||
|
||||
> 💡 Direktori unduhan bawaan adalah folder "Downloads" pengguna.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Unduhan Playlist</summary>
|
||||
|
||||
1. **Tempel URL Playlist**
|
||||
2. **Klik "Analyze"**
|
||||
3. **Pilih video dari pemilih playlist (opsional, default semua)**
|
||||
4. **Pilih format/kualitas yang diinginkan**
|
||||
5. **Klik "Download"**
|
||||
|
||||
> 💡 Aplikasi secara otomatis mengelola antrean unduhan, dan Anda dapat mengekspor entri playlist sebagai file `.txt`, `.csv`, `.m3u`, atau `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Mode Generik untuk Situs Selain YouTube</summary>
|
||||
|
||||
Gunakan Mode Generik saat Anda ingin YTSage menerima URL dari situs yang didukung oleh yt-dlp seperti Dailymotion, CBC Gem, TikTok, dan lainnya.
|
||||
|
||||
Cara menggunakannya:
|
||||
|
||||
1. Buka `Download Settings`.
|
||||
2. Aktifkan `Generic Mode`.
|
||||
3. Tempel URL video atau playlist yang didukung selain YouTube.
|
||||
4. Klik `Analyze`.
|
||||
5. Pilih format dan unduh seperti biasa.
|
||||
|
||||
Catatan:
|
||||
|
||||
- Mode Generik hanya mengubah validasi URL di dalam YTSage. Situs target harus tetap didukung oleh versi yt-dlp yang Anda instal.
|
||||
- Beberapa situs memerlukan cookie, login, proxy, atau argumen yt-dlp tambahan tergantung pada ekstraktornya.
|
||||
- Jika suatu situs gagal, perbarui yt-dlp dari tab pembaruan terintegrasi sebelum melaporkan masalah.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Opsi Media & Unduhan</summary>
|
||||
|
||||
- **Opsi Subtitle:** Filter bahasa dan masukkan subtitle ke dalam file video.
|
||||
- **Penggabungan Subtitle:** Menggabungkan subtitle ke dalam file video untuk subtitle permanen (hardcoded).
|
||||
- **Simpan Deskripsi:** Simpan deskripsi video sebagai file teks.
|
||||
- **Simpan Thumbnail:** Simpan thumbnail video sebagai file gambar.
|
||||
- **Masukkan Bab (Chapters):** Sertakan penanda bab sebagai metadata untuk pemutar video yang kompatibel.
|
||||
- **Hapus Bagian Sponsor:** Gunakan SponsorBlock untuk menghapus segmen sponsor dari video.
|
||||
- **Potong Video:** Unduh hanya bagian tertentu dari video dengan menentukan rentang waktu dalam format `JJ:MM:DD`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Pengaturan Output & File</summary>
|
||||
|
||||
- **Pembatas Kecepatan:** Batasi kecepatan unduhan, misalnya `500K` untuk 500 KB/s.
|
||||
- **Simpan Jalur Unduhan:** Menyimpan jalur unduhan default untuk unduhan di masa mendatang. Tersedia di **Download Settings → Download Path**.
|
||||
- **Resolusi Video Default:** Atur resolusi video pilihan Anda untuk pemilihan otomatis (misalnya 1080p, 720p). Tersedia di **Download Settings → Default Video Resolution**.
|
||||
- **Bahasa Subtitle Default:** Atur bahasa subtitle default untuk pemilihan otomatis (dipisahkan koma, misalnya `id,en`). Tersedia di **Download Settings → Default Subtitle Languages**.
|
||||
- **Format Nama File:** Sesuaikan format nama file output menggunakan variabel seperti `%(title)s`, `%(uploader)s`, `%(playlist_index)s`, dan `%(resolution)s`. Tersedia di **Download Settings → Filename Format**.
|
||||
- **Paksa Format Output:** Paksa unduhan video ke format kontainer tertentu seperti `mp4`, `webm`, atau `mkv`. Tersedia di **Download Settings → Output Format Settings**.
|
||||
- **Konversi Format Audio:** Konversi unduhan audio saja ke format pilihan seperti `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, atau `Best`. Tersedia di **Download Settings → Audio Format Settings**.
|
||||
- **Normalisasi Audio:** Standarisasi volume untuk unduhan audio saja menggunakan EBU R128.
|
||||
- **Koneksi Serentak:** Tingkatkan kecepatan unduhan secara signifikan dengan mengunduh file dalam beberapa bagian secara bersamaan. Tersedia di **Download Settings → General → Concurrent Connections** (default 1, maksimal 8-10 direkomendasikan untuk menghindari blokir IP).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Akses & Jaringan</summary>
|
||||
|
||||
- **Login Cookie:** Masuk ke YouTube menggunakan cookie untuk mengakses konten pribadi.
|
||||
Penggunaan:
|
||||
1. **Direkomendasikan:** Gunakan opsi bawaan `Extract cookies from browser` di aplikasi, lalu pilih browser dan opsional profil Anda.
|
||||
2. Secara opsional, ekstrak cookie secara manual:
|
||||
a. Ekspor cookie dari browser Anda menggunakan ekstensi seperti [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Salin cookie dalam format Netscape
|
||||
c. Buat file bernama `cookies.txt` dan tempel cookie
|
||||
d. Pilih file `cookies.txt` di aplikasi
|
||||
- **Dukungan Proxy:** Gunakan server proxy untuk unduhan, misalnya `http://<server-proxy>:<port>`
|
||||
- **Mode Generik:** Izinkan YTSage untuk menganalisis dan mengunduh dari situs selain YouTube yang didukung oleh yt-dlp. Aktifkan dari **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Alat & Pemeliharaan</summary>
|
||||
|
||||
- **Perintah Kustom:** Akses fitur yt-dlp tingkat lanjut melalui argumen baris perintah.
|
||||
- **Tab Pembaruan:** Kelola alat pembaruan bawaan dari satu tempat di Opsi Kustom:
|
||||
- **Pembaruan yt-dlp:** Periksa pembaruan dan beralih antara saluran rilis Stable dan Nightly.
|
||||
- **Pemeriksa Versi FFmpeg:** Verifikasi versi FFmpeg Anda dan buka panduan instalasi.
|
||||
- **Pembaruan Deno:** Periksa dan perbarui runtime Deno.
|
||||
- **Deteksi FFmpeg/yt-dlp/Deno:** Secara otomatis mendeteksi jalur dan versi FFmpeg, yt-dlp, dan Deno dari dialog About.
|
||||
- **Riwayat Unduhan:** Lihat unduhan sebelumnya dengan thumbnail dan status dari tombol **History**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Lokalisasi</summary>
|
||||
|
||||
YTSage mendukung **14 bahasa** untuk jangkauan global. Pilih bahasa pilihan Anda di **Custom Options → Language**.
|
||||
|
||||
### Bahasa yang Didukung
|
||||
|
||||
| Bahasa | Kode | Bahasa | Kode |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inggris | `en` | 🇪🇸 Spanyol | `es` |
|
||||
| 🇸🇦 Arab | `ar` | 🇫🇷 Prancis | `fr` |
|
||||
| 🇩🇪 Jerman | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonesia | `id` | 🇮🇹 Italia | `it` |
|
||||
| 🇯🇵 Jepang | `ja` | 🇵🇱 Polandia | `pl` |
|
||||
| 🇧🇷 Portugis | `pt` | 🇷🇺 Rusia | `ru` |
|
||||
| 🇹🇷 Turki | `tr` | 🇨🇳 Mandarin | `zh` |
|
||||
|
||||
### Terjemahan README
|
||||
|
||||
| Bahasa | File | Bahasa | File |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inggris | [README.md](../README.md) | 🇪🇸 Spanyol | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Arab | [README.ar.md](README.ar.md) | 🇫🇷 Prancis | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Jerman | [README.de.md](README.de.md) | 🇮🇳 Hindi | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Indonesia | [README.id.md](README.id.md) | 🇮🇹 Italia | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Jepang | [README.ja.md](README.ja.md) | 🇵🇱 Polandia | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Portugis | [README.pt.md](README.pt.md) | 🇷🇺 Rusia | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Turki | [README.tr.md](README.tr.md) | 🇨🇳 Mandarin | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Ingin membantu menerjemahkan?** Lihat bagian [Kontribusi](#kontribusi) untuk membantu kami menambahkan lebih banyak bahasa!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary>Klik untuk melihat masalah umum dan solusinya</summary>
|
||||
|
||||
- **Tabel format tidak muncul:** Perbarui yt-dlp ke versi terbaru dan coba beralih ke yt-dlp Nightly.
|
||||
- **Unduhan gagal:** Periksa koneksi internet Anda dan pastikan video tersedia.
|
||||
- **Kesalahan Unduhan Spesifik:**
|
||||
- **Video Pribadi:** Gunakan autentikasi cookie untuk mengakses konten pribadi.
|
||||
- **Konten Dibatasi Usia:** Masuk ke akun YouTube Anda untuk melihat video yang dibatasi usia.
|
||||
- **Video yang Diblokir Geo:** Pertimbangkan menggunakan VPN untuk melewati batasan regional.
|
||||
- **Video Dihapus:** Video tidak lagi tersedia di YouTube.
|
||||
- **Live Stream:** Streaming langsung tidak dapat diunduh saat sedang disiarkan; tunggu hingga streaming selesai.
|
||||
- **Kesalahan Jaringan:** Periksa koneksi internet Anda dan coba lagi.
|
||||
- **URL Tidak Valid:** Pastikan URL benar dan berasal dari platform yang didukung.
|
||||
- **Konten Premium:** Memerlukan langganan YouTube Premium.
|
||||
- **Blokir Hak Cipta:** Konten diblokir karena pembatasan hak cipta.
|
||||
- **File video dan audio terpisah setelah diunduh:** Ini terjadi ketika FFmpeg hilang atau tidak terdeteksi. YTSage memerlukan FFmpeg untuk menggabungkan aliran video dan audio berkualitas tinggi.
|
||||
- **Solusi:** Pastikan FFmpeg terinstal dan dapat diakses di PATH sistem Anda. Untuk pengguna Windows, opsi termudah adalah mengunduh file `YTSage-v<version>-ffmpeg.exe`, yang dilengkapi dengan FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Peringatan Windows Defender / Antivirus
|
||||
|
||||
Beberapa perangkat lunak antivirus mungkin menandai file `.exe` sebagai positif palsu (false positive). Ini adalah **batasan umum** dari aplikasi yang dipaketkan.
|
||||
|
||||
**Mengapa ini terjadi:**
|
||||
- Heuristik antivirus mungkin salah mengidentifikasi executable yang dipaketkan sebagai mencurigakan.
|
||||
|
||||
**Opsi Aman:**
|
||||
- ✅ **Gunakan instalasi pip:** `pip install ytsage` (direkomendasikan)
|
||||
- ✅ **Build dari sumber**: Mengikuti [panduan](../.github/CI_CD_README.md) ini
|
||||
- ✅ **Whitelist aplikasi** di perangkat lunak antivirus Anda.
|
||||
|
||||
#### 🍎 macOS: "App is damaged and can’t be opened"
|
||||
Jika Anda melihat kesalahan ini di macOS Sonoma atau yang lebih baru, Anda perlu menghapus atribut karantina.
|
||||
|
||||
1. **Buka Terminal** (Anda dapat menemukannya menggunakan Spotlight).
|
||||
2. **Ketik perintah berikut** tetapi **JANGAN** tekan Enter dulu. Pastikan untuk menyertakan spasi di akhir:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Tarik file `YTSage.app` dari jendela Finder Anda** dan lepaskan langsung ke jendela Terminal. Ini akan secara otomatis menempelkan jalur file yang benar.
|
||||
4. **Tekan Enter** untuk menjalankan perintah.
|
||||
5. **Coba buka kembali YTSage.app.** Sekarang seharusnya dapat diluncurkan dengan benar.
|
||||
|
||||
---
|
||||
|
||||
#### **Lokasi Konfigurasi (Lanjutan)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Sponsor
|
||||
|
||||
Jika YTSage menghemat waktu Anda, pertimbangkan untuk mensponsori proyek ini. Sponsor membantu mencakup waktu pengembangan, pengujian di semua platform, dan peningkatan di masa mendatang.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Tautan sponsor tersedia langsung melalui dialog About di dalam aplikasi.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="kontribusi"></a>
|
||||
## 👥 Kontribusi
|
||||
|
||||
Kami menerima kontribusi! Berikut cara Anda dapat membantu:
|
||||
|
||||
1. 🍴 Fork repositori
|
||||
2. 🌿 Buat cabang fitur Anda:
|
||||
```bash
|
||||
git checkout -b feature/FiturLuarBiasa
|
||||
```
|
||||
3. 💾 Komit perubahan Anda:
|
||||
```bash
|
||||
git commit -m 'Tambah FiturLuarBiasa'
|
||||
```
|
||||
4. 📤 Push ke cabang:
|
||||
```bash
|
||||
git push origin feature/FiturLuarBiasa
|
||||
```
|
||||
5. 🔄 Buka Pull Request
|
||||
|
||||
### 🌍 Berkontribusi pada Terjemahan
|
||||
|
||||
- Perbarui file README lokal yang relevan (misalnya `readme-translations/README.id.md`)
|
||||
- Jaga agar string aplikasi tetap sinkron dengan mengedit `ytsage/languages/<code>.json`
|
||||
- Jika bahasa Anda belum ada, mulailah dari `README.md` dan buat `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Struktur Proyek</summary>
|
||||
|
||||
## YTSage - Struktur Proyek
|
||||
|
||||
Dokumen ini menjelaskan struktur folder yang terorganisir dari YTSage.
|
||||
|
||||
### 📁 Struktur Proyek
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # Konfigurasi GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Templat Issue
|
||||
│ │ └── 🐛-bug-report.md # Templat laporan bug
|
||||
│ ├─── 📁 workflows/ # Alur kerja GitHub Actions
|
||||
│ │ ├── build-linux.yml # Alur kerja build Linux
|
||||
│ │ ├── build-macos.yml # Alur kerja build macOS
|
||||
│ │ │── build-windows.yml # Alur kerja build Windows
|
||||
| | └── release-all.yml # Alur kerja rilis master
|
||||
│ └── 📄 CI_CD_README.md # Dokumentasi CI/CD
|
||||
├── 📁 branding/ # Aset branding (screenshot, SVG)
|
||||
│ ├── 📁 icons/ # Ikon aplikasi
|
||||
│ ├── 📁 screenshots/ # Screenshot untuk dokumentasi
|
||||
│ └── 📁 svg/ # Aset SVG
|
||||
├── 📄 LICENSE # File lisensi
|
||||
├── 📄 pyproject.toml # Metadata proyek dan dependensi
|
||||
├── 📄 README.md # Dokumentasi proyek
|
||||
├── 📄 requirements.txt # Dependensi Python (dev)
|
||||
└── 📁 ytsage/ # Paket kode sumber
|
||||
├── 📁 assets/ # Aset runtime
|
||||
│ ├── 📁 Icon/ # Ikon aplikasi
|
||||
│ └── 📁 sound/ # File audio
|
||||
├── 📁 languages/ # File lokalisasi
|
||||
│ ├── 📄 ar.json # Terjemahan Arab
|
||||
│ ├── 📄 de.json # Terjemahan Jerman
|
||||
│ ├── 📄 en.json # Terjemahan Inggris
|
||||
│ └── ... # Bahasa lainnya
|
||||
├── 📁 core/ # Logika bisnis inti
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Integrasi Deno
|
||||
│ ├── 📄 ytsage_downloader.py # Fungsionalitas pengunduhan
|
||||
│ ├── 📄 ytsage_ffmpeg.py # Integrasi FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # Fungsi utilitas
|
||||
│ └── 📄 ytsage_yt_dlp.py # Integrasi yt-dlp
|
||||
├── 📁 gui/ # Komponen antarmuka pengguna
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # Jendela utama aplikasi
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Kelas dialog
|
||||
├── 📁 utils/ # Modul utilitas
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # Manajemen konfigurasi
|
||||
│ └── 📄 ytsage_logger.py # Alat logging
|
||||
├── 📄 __init__.py # Titik masuk paket
|
||||
└── 📄 main.py # Skrip eksekusi utama
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Riwayat Bintang
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Lisensi
|
||||
|
||||
Proyek ini dilisensikan di bawah Lisensi MIT - lihat file [LICENSE](../LICENSE) untuk detailnya.
|
||||
|
||||
## 🙏 Terima Kasih
|
||||
|
||||
<details>
|
||||
<summary>Tampilkan Terima Kasih</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Terima kasih banyak kepada semua orang yang telah berkontribusi pada proyek ini dengan membuka masalah untuk menyarankan perbaikan atau melaporkan bug.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Komponen Utama</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Mesin Pengunduhan</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Pemrosesan Media</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime untuk integrasi yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Pustaka & Framework</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>Framework GUI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Pemrosesan Gambar</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>Permintaan HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Manajemen Versi & Pemaketan</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Rendering Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logging</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Aset & Kontributor</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 oleh Universfield</a></td>
|
||||
<td>Suara Notifikasi</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Kontributor Kode</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Penafian
|
||||
|
||||
Alat ini hanya untuk penggunaan pribadi. Harap hormati Ketentuan Layanan YouTube dan hak-hak produser konten.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Dibuat dengan ❤️ oleh [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Un downloader YouTube moderno con un'interfaccia PySide6 pulita.**
|
||||
Scarica video in qualsiasi qualità, estrai audio, ottieni sottotitoli e molto altro.
|
||||
|
||||
### 🌍 Lingue del README
|
||||
|
||||
Inglese: [EN](../README.md)
|
||||
| Arabo: [AR](README.ar.md)
|
||||
| Tedesco: [DE](README.de.md)
|
||||
| Spagnolo: [ES](README.es.md)
|
||||
| Francese: [FR](README.fr.md)
|
||||
| Hindi: [HI](README.hi.md)
|
||||
| Indonesiano: [ID](README.id.md)
|
||||
| Italiano: [IT](README.it.md)
|
||||
| Giapponese: [JA](README.ja.md)
|
||||
| Polacco: [PL](README.pl.md)
|
||||
| Portoghese: [PT](README.pt.md)
|
||||
| Russo: [RU](README.ru.md)
|
||||
| Turco: [TR](README.tr.md)
|
||||
| Cinese: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installazione">Installazione</a> •
|
||||
<a href="#caratteristiche">Caratteristiche</a> •
|
||||
<a href="#utilizzo">Utilizzo</a> •
|
||||
<a href="#screenshot">Screenshot</a> •
|
||||
<a href="#risoluzione-dei-problemi">Risoluzione dei problemi</a> •
|
||||
<a href="#sponsor">Sponsor</a> •
|
||||
<a href="#contribuire">Contribuire</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="perché-ytsage"></a>
|
||||
## ❓ Perché YTSage?
|
||||
|
||||
YTSage è progettato per gli utenti che desiderano un **downloader YouTube semplice ma potente**. A differenza di altri strumenti, offre:
|
||||
|
||||
- Un'interfaccia PySide6 moderna e pulita
|
||||
- Download di video, audio e sottotitoli con un solo clic
|
||||
- Funzioni avanzate come SponsorBlock, fusione dei sottotitoli e selezione della playlist
|
||||
- Modalità Generica opzionale per siti diversi da YouTube supportati da yt-dlp
|
||||
- Supporto multipiattaforma e installazione semplice
|
||||
|
||||
<a id="caratteristiche"></a>
|
||||
## ✨ Caratteristiche
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Funzioni Base | Funzioni Avanzate | Funzioni Extra |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Tabella Formati | 🚫 Integrazione SponsorBlock | 🎞️ Visualizzazione FPS/HDR |
|
||||
| 🎵 Estrazione Audio | 📝 Selezione e Fusione Sottotitoli | 🔄 Aggiornamento automatico yt-dlp |
|
||||
| ✨ Interfaccia Utente Semplice | 💾 Salva Descrizione e Anteprima | 🛠️ Rilevamento FFmpeg/yt-dlp/Deno |
|
||||
| 📋 Supporto e Selettore Playlist | 🚀 Limitatore di Velocità | ⚙️ Comandi Personalizzati |
|
||||
| 📑 Integrazione Capitoli | ✂️ Ritaglio Sezioni Video | 🍪 Login tramite Cookie |
|
||||
| 📜 Cronologia Download | 🔄 Scelta del Canale di Rilascio | 🌐 Supporto Proxy |
|
||||
| 🎚️ Conversione Formato Audio | 🎬 Impostazioni Formato Video | 🆙 Tab Aggiornamento Integrato |
|
||||
| 🌍 Modalità Generica | 🔊 Normalizzazione Audio (EBU R128) | 🌍 Localizzazione in 14 lingue |
|
||||
| 💾 Esportazione Playlist | ⚙️ Qualità e Sottotitoli Predefiniti | |
|
||||
</div>
|
||||
|
||||
<a id="installazione"></a>
|
||||
## 🚀 Installazione
|
||||
|
||||
### ⚡ Installazione Rapida (Consigliata)
|
||||
|
||||
Installa YTSage tramite PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Aggiornare un'installazione esistente</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Quindi esegui l'applicazione:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Eseguibili Pre-compilati (Executable)
|
||||
|
||||
> [👉 Scarica l'ultima versione](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Formato | Descrizione |
|
||||
|--------|-------------|
|
||||
|  | Installer Standard |
|
||||
|  | Include FFmpeg |
|
||||
|  | Versione Portable, nessuna installazione richiesta |
|
||||
|  | Portable con FFmpeg, compresso (ZIP) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Passaggi per l'installazione</summary>
|
||||
|
||||
1. **Installer EXE (`.exe`)**: Fare doppio clic sul file e seguire la procedura guidata.
|
||||
2. **Versione Portable (`.zip`)**: Estrarre l'archivio nella posizione desiderata ed eseguire `ytsage.exe`.
|
||||
3. **FFmpeg integrato**: Se non hai FFmpeg sul tuo sistema, scegli le versioni con FFmpeg integrato.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Formato | Descrizione |
|
||||
|--------|-------------|
|
||||
|  | Pacchetto Debian |
|
||||
|  | AppImage, Portable |
|
||||
|  | Pacchetto RPM |
|
||||
|  | Bundle Flatpak |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Passaggi per l'installazione</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Se necessario per correggere le dipendenze mancanti
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Seguire le istruzioni su Flathub o eseguire:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Formato | Descrizione |
|
||||
|--------|-------------|
|
||||
|  | App ZIP per Apple Silicon |
|
||||
|  | Installer Disk Image per Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Passaggi per l'installazione</summary>
|
||||
|
||||
- **Installer DMG (`.dmg`)**: Fare doppio clic per montare, quindi trascinare `YTSage.app` nella cartella Applicazioni.
|
||||
- **Archivio App (`.zip`)**: Estrarre lo ZIP e spostare `YTSage.app` nella cartella Applicazioni.
|
||||
|
||||
*Nota: Se ricevi l'errore "L'app è danneggiata", consulta la sezione Risoluzione dei problemi macOS di seguito.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Installazione manuale dai sorgenti</summary>
|
||||
|
||||
### 1. Clonare il repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Installare le dipendenze
|
||||
|
||||
#### ⚡ Con uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 O con pip standard
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Eseguire l'applicazione
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshot"></a>
|
||||
## 📸 Screenshot
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Impostazioni Download" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Download Playlist" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Impostazioni Download</em></td>
|
||||
<td align="center"><em>Download Playlist</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Selezione Formato Audio" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Opzioni Personalizzate" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Formato Audio</em></td>
|
||||
<td align="center"><em>Opzioni Personalizzate</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="utilizzo"></a>
|
||||
## 📖 Utilizzo
|
||||
|
||||
<details>
|
||||
<summary>🎯 Utilizzo Base</summary>
|
||||
|
||||
1. **Avvia YTSage**
|
||||
2. **Incolla un URL di YouTube** (o usa il pulsante "Incolla URL")
|
||||
3. **Clicca su "Analizza"**
|
||||
4. **Scegli il formato:**
|
||||
- `Video` per il download del video
|
||||
- `Solo Audio` per l'estrazione dell'audio
|
||||
5. **Seleziona le opzioni:**
|
||||
- Abilita i sottotitoli e scegli la lingua
|
||||
- Abilita la fusione dei sottotitoli
|
||||
- Salva l'anteprima (thumbnail)
|
||||
- Rimuovi sezioni sponsor
|
||||
- Salva la descrizione
|
||||
- Incorpora i capitoli
|
||||
6. **Scegli la directory di destinazione**
|
||||
7. **Clicca su "Download"**
|
||||
|
||||
> 💡 La directory di download predefinita è la cartella "Download" dell'utente.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Download Playlist</summary>
|
||||
|
||||
1. **Incolla l'URL della playlist**
|
||||
2. **Clicca su "Analizza"**
|
||||
3. **Seleziona i video dal selettore (opzionale, tutti per impostazione predefinita)**
|
||||
4. **Scegli il formato/qualità desiderati**
|
||||
5. **Clicca su "Download"**
|
||||
|
||||
> 💡 L'applicazione gestisce automaticamente la coda di download e puoi esportare le voci della playlist come file `.txt`, `.csv`, `.m3u` o `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Modalità Generica per siti non YouTube</summary>
|
||||
|
||||
Usa la Modalità Generica quando vuoi che YTSage accetti URL da siti supportati da yt-dlp come Dailymotion, CBC Gem, TikTok e altri.
|
||||
|
||||
Come usarla:
|
||||
|
||||
1. Apri `Impostazioni Download`.
|
||||
2. Abilita `Modalità Generica`.
|
||||
3. Incolla un URL di un video o di una playlist supportata che non sia YouTube.
|
||||
4. Clicca su `Analizza`.
|
||||
5. Scegli un formato e scarica normalmente.
|
||||
|
||||
Note:
|
||||
|
||||
- La Modalità Generica cambia solo la validazione dell'URL all'interno di YTSage. Il sito di destinazione deve essere comunque supportato dalla versione di yt-dlp installata.
|
||||
- Alcuni siti richiedono cookie, login, proxy o argomenti yt-dlp aggiuntivi a seconda dell'estrattore.
|
||||
- Se un sito fallisce, aggiorna yt-dlp dal tab di aggiornamento integrato prima di segnalare il problema.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Opzioni Media e Download</summary>
|
||||
|
||||
- **Opzioni Sottotitoli:** Filtra le lingue e incorpora i sottotitoli nel file video.
|
||||
- **Fusione Sottotitoli:** Fonde i sottotitoli nel file video per sottotitoli permanenti (hardcoded).
|
||||
- **Salva Descrizione:** Salva la descrizione del video come file di testo.
|
||||
- **Salva Anteprima:** Salva l'anteprima (thumbnail) del video come file immagine.
|
||||
- **Incorpora Capitoli:** Include i segnaposti dei capitoli come metadati per i lettori video compatibili.
|
||||
- **Rimuovi Sezioni Sponsor:** Usa SponsorBlock per rimuovere i segmenti sponsorizzati dal video.
|
||||
- **Ritaglia Video:** Scarica solo parti specifiche del video specificando un intervallo di tempo nel formato `HH:MM:SS`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Impostazioni Output e File</summary>
|
||||
|
||||
- **Limitatore di Velocità:** Limita la velocità di download, ad esempio `500K` per 500 KB/s.
|
||||
- **Salva Percorso Download:** Salva il percorso di download predefinito per i download futuri. Disponibile in **Impostazioni Download → Percorso Download**.
|
||||
- **Risoluzione Video Predefinita:** Imposta la risoluzione video preferita per la selezione automatica (es. 1080p, 720p). Disponibile in **Impostazioni Download → Risoluzione Video Predefinita**.
|
||||
- **Lingue Sottotitoli Predefinite:** Imposta le lingue dei sottotitoli predefinite per la selezione automatica (separate da virgola, es. `it,en`). Disponibile in **Impostazioni Download → Lingue Sottotitoli Predefinite**.
|
||||
- **Formato Nome File:** Personalizza il formato del nome del file di output utilizzando variabili come `%(title)s`, `%(uploader)s`, `%(playlist_index)s` e `%(resolution)s`. Disponibile in **Impostazioni Download → Formato Nome File**.
|
||||
- **Forza Formato Output:** Forza il download del video in un formato contenitore specifico come `mp4`, `webm` o `mkv`. Disponibile in **Impostazioni Download → Impostazioni Formato Output**.
|
||||
- **Conversione Formato Audio:** Converte i download di solo audio nel formato preferito come `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, o `Best`. Disponibile in **Impostazioni Download → Impostazioni Formato Audio**.
|
||||
- **Normalizzazione Audio:** Standardizza il volume per i download di solo audio utilizzando EBU R128.
|
||||
- **Connessioni Simultanee:** Aumenta significativamente la velocità di download scaricando i file in più parti contemporaneamente. Disponibile in **Impostazioni Download → Generali → Connessioni Simultanee** (predefinito 1, massimo 8-10 consigliato per evitare blocchi IP).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Accesso e Rete</summary>
|
||||
|
||||
- **Login tramite Cookie:** Accedi a YouTube utilizzando i cookie per accedere ai contenuti privati.
|
||||
Utilizzo:
|
||||
1. **Consigliato:** Usa l'opzione integrata nell'app `Estrai cookie dal browser`, quindi scegli il browser e opzionalmente il profilo.
|
||||
2. In alternativa, estrai i cookie manualmente:
|
||||
a. Esporta i cookie dal tuo browser utilizzando un'estensione come [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Copia i cookie nel formato Netscape
|
||||
c. Crea un file chiamato `cookies.txt` e incolla i cookie
|
||||
d. Seleziona il file `cookies.txt` nell'app
|
||||
- **Supporto Proxy:** Usa un server proxy per i download, ad esempio `http://<server-proxy>:<porta>`
|
||||
- **Modalità Generica:** Consente a YTSage di analizzare e scaricare da siti non YouTube supportati da yt-dlp. Abilitalo da **Impostazioni Download → Modalità Generica**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Strumenti e Manutenzione</summary>
|
||||
|
||||
- **Comandi Personalizzati:** Accedi alle funzioni avanzate di yt-dlp tramite argomenti della riga di comando.
|
||||
- **Tab Aggiornamento:** Gestisci gli strumenti di aggiornamento integrati da un unico posto nelle Opzioni Personalizzate:
|
||||
- **Aggiornamento yt-dlp:** Controlla gli aggiornamenti e passa tra i canali di rilascio Stabile e Nightly.
|
||||
- **Controllo Versione FFmpeg:** Verifica la tua versione di FFmpeg e apri le guide all'installazione.
|
||||
- **Aggiornamento Deno:** Controlla e aggiorna il runtime Deno.
|
||||
- **Rilevamento FFmpeg/yt-dlp/Deno:** Rileva automaticamente i percorsi e le versioni di FFmpeg, yt-dlp e Deno dal dialogo Informazioni.
|
||||
- **Cronologia Download:** Visualizza i download passati con anteprime e stati dal pulsante **Cronologia**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Localizzazione</summary>
|
||||
|
||||
YTSage supporta **14 lingue** per una portata globale. Scegli la tua lingua preferita in **Opzioni Personalizzate → Lingua**.
|
||||
|
||||
### Lingue Supportate
|
||||
|
||||
| Lingua | Codice | Lingua | Codice |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inglese | `en` | 🇪🇸 Spagnolo | `es` |
|
||||
| 🇸🇦 Arabo | `ar` | 🇫🇷 Francese | `fr` |
|
||||
| 🇩🇪 Tedesco | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonesiano | `id` | 🇮🇹 Italiano | `it` |
|
||||
| 🇯🇵 Giapponese | `ja` | 🇵🇱 Polacco | `pl` |
|
||||
| 🇧🇷 Portoghese | `pt` | 🇷🇺 Russo | `ru` |
|
||||
| 🇹🇷 Turco | `tr` | 🇨🇳 Cinese | `zh` |
|
||||
|
||||
### Traduzioni del README
|
||||
|
||||
| Lingua | File | Lingua | File |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inglese | [README.md](../README.md) | 🇪🇸 Spagnolo | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Arabo | [README.ar.md](README.ar.md) | 🇫🇷 Francese | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Tedesco | [README.de.md](README.de.md) | 🇮🇳 Hindi | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Indonesiano | [README.id.md](README.id.md) | 🇮🇹 Italiano | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Giapponese | [README.ja.md](README.ja.md) | 🇵🇱 Polacco | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Portoghese | [README.pt.md](README.pt.md) | 🇷🇺 Russo | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Turco | [README.tr.md](README.tr.md) | Cinese | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Vuoi aiutare con la traduzione?** Consulta la sezione [Contribuire](#contribuire) per aiutarci ad aggiungere altre lingue!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="risoluzione-dei-problemi"></a>
|
||||
## 🛠️ Risoluzione dei problemi
|
||||
|
||||
<details>
|
||||
<summary>Clicca per visualizzare i problemi comuni e le soluzioni</summary>
|
||||
|
||||
- **La tabella dei formati non appare:** Aggiorna yt-dlp all'ultima versione e prova a passare a yt-dlp Nightly.
|
||||
- **Download fallito:** Controlla la tua connessione internet e assicurati che il video sia disponibile.
|
||||
- **Errori di download specifici:**
|
||||
- **Video Privati:** Usa l'autenticazione tramite cookie per accedere ai contenuti privati.
|
||||
- **Contenuti con limiti di età:** Accedi al tuo account YouTube per visualizzare i video con limiti di età.
|
||||
- **Video bloccati geograficamente:** Considera l'uso di una VPN per aggirare le restrizioni regionali.
|
||||
- **Video rimosso:** Il video non è più disponibile su YouTube.
|
||||
- **Live Stream:** I live stream non possono essere scaricati durante la trasmissione; attendi la fine dello stream.
|
||||
- **Errori di rete:** Controlla la tua connessione internet e riprova.
|
||||
- **URL non valido:** Assicurati che l'URL sia corretto e provenga da una piattaforma supportata.
|
||||
- **Contenuti Premium:** Richiede un abbonamento YouTube Premium.
|
||||
- **Blocco Copyright:** Il contenuto è bloccato a causa di restrizioni sul copyright.
|
||||
- **I file video e audio sono separati dopo il download:** Questo accade quando FFmpeg manca o non viene rilevato. YTSage richiede FFmpeg per unire i flussi video e audio di alta qualità.
|
||||
- **Soluzione:** Assicurati che FFmpeg sia installato e accessibile nel PATH del tuo sistema. Per gli utenti Windows, l'opzione più semplice è scaricare il file `YTSage-v<versione>-ffmpeg.exe`, che include FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Avviso Windows Defender / Antivirus
|
||||
|
||||
Alcuni software antivirus potrebbero contrassegnare i file `.exe` come falsi positivi. Questa è una **limitazione nota** delle applicazioni pacchettizzate.
|
||||
|
||||
**Perché succede:**
|
||||
- Le euristiche dell'antivirus potrebbero identificare erroneamente gli eseguibili pacchettizzati come sospetti.
|
||||
|
||||
**Opzioni sicure:**
|
||||
- ✅ **Usa l'installazione tramite pip:** `pip install ytsage` (consigliato)
|
||||
- ✅ **Compila dai sorgenti**: Seguendo questa [guida](../.github/CI_CD_README.md)
|
||||
- ✅ **Aggiungi l'applicazione alla whitelist** nel tuo software antivirus.
|
||||
|
||||
#### 🍎 macOS: "L’app è danneggiata e non può essere aperta"
|
||||
Se visualizzi questo errore su macOS Sonoma o versioni successive, devi rimuovere l'attributo di quarantena.
|
||||
|
||||
1. **Apri il Terminale** (puoi trovarlo usando Spotlight).
|
||||
2. **Digita il seguente comando** ma **NON** premere ancora Invio. Assicurati di includere lo spazio alla fine:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Trascina il file `YTSage.app` dalla finestra del Finder** e rilascialo direttamente nella finestra del Terminale. Questo incollerà automaticamente il percorso corretto del file.
|
||||
4. **Premi Invio** per eseguire il comando.
|
||||
5. **Prova a riaprire YTSage.app.** Ora dovrebbe avviarsi correttamente.
|
||||
|
||||
---
|
||||
|
||||
#### **Percorsi di configurazione (Avanzato)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Sponsor
|
||||
|
||||
Se YTSage ti fa risparmiare tempo, considera di sponsorizzare il progetto. Le sponsorizzazioni aiutano a coprire il tempo di sviluppo, i test su tutte le piattaforme e i miglioramenti futuri.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Il link per la sponsorizzazione è disponibile direttamente tramite il dialogo Informazioni all'interno dell'app.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contribuire"></a>
|
||||
## 👥 Contribuire
|
||||
|
||||
I contributi sono benvenuti! Ecco come puoi aiutare:
|
||||
|
||||
1. 🍴 Fork del repository
|
||||
2. 🌿 Crea il tuo ramo per la funzione:
|
||||
```bash
|
||||
git checkout -b feature/FunzioneIncredibile
|
||||
```
|
||||
3. 💾 Fai il commit delle tue modifiche:
|
||||
```bash
|
||||
git commit -m 'Aggiungi FunzioneIncredibile'
|
||||
```
|
||||
4. 📤 Fai il push sul ramo:
|
||||
```bash
|
||||
git push origin feature/FunzioneIncredibile
|
||||
```
|
||||
5. 🔄 Apri una Pull Request
|
||||
|
||||
### 🌍 Contribuire alle traduzioni
|
||||
|
||||
- Aggiorna il file README localizzato pertinente (es. `readme-translations/README.it.md`)
|
||||
- Mantieni sincronizzate le stringhe dell'app modificando `ytsage/languages/<code>.json`
|
||||
- Se la tua lingua manca, parti da `README.md` e crea `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Struttura del Progetto</summary>
|
||||
|
||||
## YTSage - Struttura del Progetto
|
||||
|
||||
Questo documento descrive la struttura organizzata delle cartelle di YTSage.
|
||||
|
||||
### 📁 Struttura del Progetto
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # Configurazioni GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Modelli per segnalazioni
|
||||
│ │ └── 🐛-bug-report.md # Modello per segnalazione bug
|
||||
│ ├─── 📁 workflows/ # Workflow GitHub Actions
|
||||
│ │ ├── build-linux.yml # Workflow build Linux
|
||||
│ │ ├── build-macos.yml # Workflow build macOS
|
||||
│ │ │── build-windows.yml # Workflow build Windows
|
||||
| | └── release-all.yml # Workflow release master
|
||||
│ └── 📄 CI_CD_README.md # Documentazione CI/CD
|
||||
├── 📁 branding/ # Asset di branding (screenshot, SVG)
|
||||
│ ├── 📁 icons/ # Icone dell'app
|
||||
│ ├── 📁 screenshots/ # Screenshot per la documentazione
|
||||
│ └── 📁 svg/ # Asset SVG
|
||||
├── 📄 LICENSE # File della licenza
|
||||
├── 📄 pyproject.toml # Metadati del progetto e dipendenze
|
||||
├── 📄 README.md # Documentazione del progetto
|
||||
├── 📄 requirements.txt # Dipendenze Python (dev)
|
||||
└── 📁 ytsage/ # Pacchetto del codice sorgente
|
||||
├── 📁 assets/ # Asset runtime
|
||||
│ ├── 📁 Icon/ # Icone dell'app
|
||||
│ └── 📁 sound/ # File audio
|
||||
├── 📁 languages/ # File di localizzazione
|
||||
│ ├── 📄 ar.json # Traduzione araba
|
||||
│ ├── 📄 de.json # Traduzione tedesca
|
||||
│ ├── 📄 en.json # Traduzione inglese
|
||||
│ └── ... # Altre lingue
|
||||
├── 📁 core/ # Logica di business principale
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Integrazione Deno
|
||||
│ ├── 📄 ytsage_downloader.py # Funzionalità di download
|
||||
│ ├── 📄 ytsage_ffmpeg.py # Integrazione FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # Funzioni di utilità
|
||||
│ └── 📄 ytsage_yt_dlp.py # Integrazione yt-dlp
|
||||
├── 📁 gui/ # Componenti dell'interfaccia utente
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # Finestra principale dell'app
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Classi per i dialoghi
|
||||
├── 📁 utils/ # Moduli di utilità
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # Gestione configurazione
|
||||
│ └── 📄 ytsage_logger.py # Strumenti di logging
|
||||
├── 📄 __init__.py # Punto di ingresso del pacchetto
|
||||
└── 📄 main.py # Script di esecuzione principale
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Cronologia Stelle
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Licenza
|
||||
|
||||
Questo progetto è rilasciato sotto Licenza MIT - vedi il file [LICENSE](../LICENSE) per i dettagli.
|
||||
|
||||
## 🙏 Ringraziamenti
|
||||
|
||||
<details>
|
||||
<summary>Mostra Ringraziamenti</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Un grande ringraziamento a tutti coloro che hanno contribuito a questo progetto aprendo segnalazioni per suggerire miglioramenti o segnalare bug.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Componenti Principali</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Motore di Download</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Elaborazione Media</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime per l'integrazione di yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Librerie e Framework</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>Framework GUI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Elaborazione Immagini</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>Richieste HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Gestione Versioni e Packaging</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Rendering Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logging</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Asset e Collaboratori</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 di Universfield</a></td>
|
||||
<td>Suono di Notifica</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Collaboratore al Codice</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
Questo strumento è solo per uso personale. Rispettate i Termini di Servizio di YouTube e i diritti dei produttori di contenuti.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Creato con ❤️ da [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**クリーンなPySide6インターフェースを備えたモダンなYouTubeダウンローダー。**
|
||||
あらゆる品質でのビデオダウンロード、オーディオ抽出、字幕取得などが可能です。
|
||||
|
||||
### 🌍 README 言語
|
||||
|
||||
英語: [EN](../README.md)
|
||||
| アラビア語: [AR](README.ar.md)
|
||||
| ドイツ語: [DE](README.de.md)
|
||||
| スペイン語: [ES](README.es.md)
|
||||
| フランス語: [FR](README.fr.md)
|
||||
| ヒンディー語: [HI](README.hi.md)
|
||||
| インドネシア語: [ID](README.id.md)
|
||||
| イタリア語: [IT](README.it.md)
|
||||
| 日本語: [JA](README.ja.md)
|
||||
| ポーランド語: [PL](README.pl.md)
|
||||
| ポルトガル語: [PT](README.pt.md)
|
||||
| ロシア語: [RU](README.ru.md)
|
||||
| トルコ語: [TR](README.tr.md)
|
||||
| 中国語: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#インストール">インストール</a> •
|
||||
<a href="#機能">機能</a> •
|
||||
<a href="#使い方">使い方</a> •
|
||||
<a href="#スクリーンショット">スクリーンショット</a> •
|
||||
<a href="#トラブルシューティング">トラブルシューティング</a> •
|
||||
<a href="#スポンサー">スポンサー</a> •
|
||||
<a href="#貢献">貢献</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="なぜytsageなのか"></a>
|
||||
## ❓ なぜ YTSage なのか?
|
||||
|
||||
YTSageは、**シンプルでありながら強力なYouTubeダウンローダー**を求めるユーザーのために設計されています。他のツールとは異なり、以下の機能を提供します:
|
||||
|
||||
- モダンでクリーンなPySide6インターフェース
|
||||
- ビデオ、オーディオ、字幕のワンクリックダウンロード
|
||||
- SponsorBlock、字幕結合、プレイリスト選択などの高度な機能
|
||||
- yt-dlpがサポートするYouTube以外のサイト向けのオプションのジェネリックモード
|
||||
- クロスプラットフォーム対応と簡単なインストール
|
||||
|
||||
<a id="機能"></a>
|
||||
## ✨ 機能
|
||||
|
||||
<div align="center">
|
||||
|
||||
| 基本機能 | 高度な機能 | 追加機能 |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 フォーマットテーブル | 🚫 SponsorBlock統合 | 🎞️ FPS/HDR表示 |
|
||||
| 🎵 オーディオ抽出 | 📝 字幕選択と結合 | 🔄 yt-dlp自動更新 |
|
||||
| ✨ シンプルなユーザーインターフェース | 💾 説明とサムネイルの保存 | 🛠️ FFmpeg/yt-dlp/Deno検出 |
|
||||
| 📋 プレイリストのサポートと選択 | 🚀 速度制限 | ⚙️ カスタムコマンド |
|
||||
| 📑 チャプター統合 | ✂️ ビデオセクションのトリミング | 🍪 クッキーログイン |
|
||||
| 📜 ダウンロード履歴 | 🔄 リリースチャネルの選択 | 🌐 プロキシサポート |
|
||||
| 🎚️ オーディオフォーマット変換 | 🎬 ビデオフォーマット設定 | 🆙 統合アップデートタブ |
|
||||
| 🌍 ジェネリックモード | 🔊 オーディオノーマライズ (EBU R128) | 🌍 14言語へのローカライズ |
|
||||
| 💾 プレイリストのエクスポート | ⚙️ デフォルトの品質と字幕 | |
|
||||
</div>
|
||||
|
||||
<a id="インストール"></a>
|
||||
## 🚀 インストール
|
||||
|
||||
### ⚡ クイックインストール (推奨)
|
||||
|
||||
PyPI経由でYTSageをインストールします:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 既存のインストールを更新する</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
その後、アプリケーションを実行します:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 ビルド済み実行ファイル (Executable)
|
||||
|
||||
> [👉 最新リリースをダウンロード](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| フォーマット | 説明 |
|
||||
|--------|-------------|
|
||||
|  | 標準インストーラー |
|
||||
|  | FFmpeg同梱 |
|
||||
|  | ポータブル版、インストール不要 |
|
||||
|  | FFmpeg同梱、ポータブル版 (ZIP縮小) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ インストール手順</summary>
|
||||
|
||||
1. **EXE インストーラー (`.exe`)**: ファイルをダブルクリックし、セットアップウィザードに従います。
|
||||
2. **ポータブル版 (`.zip`)**: アーカイブを任意の場所に展開し、`ytsage.exe` を実行します。
|
||||
3. **内蔵 FFmpeg**: システムに FFmpeg がインストールされていない場合は、FFmpeg 同梱版を選択してください。
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| フォーマット | 説明 |
|
||||
|--------|-------------|
|
||||
|  | Debian パッケージ |
|
||||
|  | AppImage、ポータブル |
|
||||
|  | RPM パッケージ |
|
||||
|  | Flatpak バンドル |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ インストール手順</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # 必要に応じて不足している依存関係を修正
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Flathub の指示に従うか、以下を実行します:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| フォーマット | 説明 |
|
||||
|--------|-------------|
|
||||
|  | Apple Silicon 用 ZIP アプリ |
|
||||
|  | Apple Silicon 用ディスクイメージインストーラー |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ インストール手順</summary>
|
||||
|
||||
- **DMG インストーラー (`.dmg`)**: ダブルクリックしてマウントし、`YTSage.app` をアプリケーションフォルダにドラッグします。
|
||||
- **App アーカイブ (`.zip`)**: ZIP を展開し、`YTSage.app` をアプリケーションフォルダに移動します。
|
||||
|
||||
*注意: 「アプリが破損しています」というエラーが表示される場合は、以下の macOS トラブルシューティング セクションを参照してください。*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 ソースからの手動インストール</summary>
|
||||
|
||||
### 1. リポジトリをクローンする
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. 依存関係をインストールする
|
||||
|
||||
#### ⚡ uv を使用する場合
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 または標準の pip を使用する場合
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. アプリケーションを実行する
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="スクリーンショット"></a>
|
||||
## 📸 スクリーンショット
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="ダウンロード設定" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="プレイリストダウンロード" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>ダウンロード設定</em></td>
|
||||
<td align="center"><em>プレイリストダウンロード</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="オーディオフォーマット選択" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="カスタムオプション" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>オーディオフォーマット</em></td>
|
||||
<td align="center"><em>カスタムオプション</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="使い方"></a>
|
||||
## 📖 使い方
|
||||
|
||||
<details>
|
||||
<summary>🎯 基本的な使い方</summary>
|
||||
|
||||
1. **YTSage を起動する**
|
||||
2. **YouTube の URL を貼り付ける** (または「URL を貼り付け」ボタンを使用)
|
||||
3. **「分析」をクリックする**
|
||||
4. **フォーマットを選択する:**
|
||||
- ビデオダウンロードの場合は `Video`
|
||||
- オーディオ抽出の場合は `Audio Only`
|
||||
5. **オプションを選択する:**
|
||||
- 字幕を有効にして言語を選択
|
||||
- 字幕結合を有効化
|
||||
- サムネイルを保存
|
||||
- スポンサーセクションを削除
|
||||
- 説明を保存
|
||||
- チャプターを埋め込む
|
||||
6. **出力ディレクトリを選択する**
|
||||
7. **「ダウンロード」をクリックする**
|
||||
|
||||
> 💡 デフォルトのダウンロードディレクトリは、ユーザーの「ダウンロード」フォルダです。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 プレイリストのダウンロード</summary>
|
||||
|
||||
1. **プレイリストの URL を貼り付ける**
|
||||
2. **「分析」をクリックする**
|
||||
3. **プレイリストセレクターからビデオを選択する (任意、デフォルトはすべて)**
|
||||
4. **希望のフォーマット/品質を選択する**
|
||||
5. **「ダウンロード」をクリックする**
|
||||
|
||||
> 💡 アプリケーションはダウンロードキューを自動的に管理し、プレイリストのエントリを `.txt`, `.csv`, `.m3u`, または `.json` ファイルとしてエクスポートできます。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 YouTube 以外のサイト向けのジェネリックモード</summary>
|
||||
|
||||
Dailymotion、CBC Gem、TikTok など、yt-dlp がサポートするサイトからの URL を YTSage に受け入れさせたい場合は、ジェネリックモードを使用します。
|
||||
|
||||
使用方法:
|
||||
|
||||
1. `Download Settings` を開きます。
|
||||
2. `Generic Mode` を有効にします。
|
||||
3. YouTube 以外のサポートされているビデオまたはプレイリストの URL を貼り付けます。
|
||||
4. `Analyze` をクリックします。
|
||||
5. フォーマットを選択し、通常通りダウンロードします。
|
||||
|
||||
注意:
|
||||
|
||||
- ジェネリックモードは、YTSage 内部の URL バリデーションのみを変更します。対象サイトは、インストールされている yt-dlp バージョンでサポートされている必要があります。
|
||||
- サイトによっては、エクストラクターに応じてクッキー、ログイン、プロキシ、または追加の yt-dlp 引数が必要になる場合があります。
|
||||
- サイトが失敗する場合は、問題を報告する前に統合アップデートタブから yt-dlp を更新してください。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 メディアとダウンロードのオプション</summary>
|
||||
|
||||
- **字幕オプション:** 言語をフィルタリングし、字幕をビデオファイルに埋め込みます。
|
||||
- **字幕結合:** 字幕をビデオファイルにマージして、焼き付け字幕(ハードコード)にします。
|
||||
- **説明の保存:** ビデオの説明をテキストファイルとして保存します。
|
||||
- **サムネイルの保存:** ビデオのサムネイルを画像ファイルとして保存します。
|
||||
- **チャプターの埋め込み:** 対応しているビデオプレーヤー用に、チャプターマーカーをメタデータとして含めます。
|
||||
- **スポンサーセクションを削除:** SponsorBlock を使用して、ビデオからスポンサーセグメントを削除します。
|
||||
- **ビデオのトリミング:** `HH:MM:SS` 形式で時間範囲を指定して、ビデオの特定のセクションのみをダウンロードします。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ 出力とファイルの設定</summary>
|
||||
|
||||
- **速度制限:** ダウンロード速度を制限します(例:500 KB/s の場合は `500K`)。
|
||||
- **ダウンロードパスの保存:** 将来のダウンロードのためにデフォルトのダウンロードパスを保存します。**Download Settings → Download Path** で設定可能です。
|
||||
- **デフォルトのビデオ解像度:** 自動選択のために好みのビデオ解像度を設定します(例:1080p, 720p)。**Download Settings → Default Video Resolution** で設定可能です。
|
||||
- **デフォルトの字幕言語:** 自動選択のためにデフォルトの字幕言語を設定します(カンマ区切り、例:`ja,en`)。**Download Settings → Default Subtitle Languages** で設定可能です。
|
||||
- **ファイル名形式:** `%(title)s`, `%(uploader)s`, `%(playlist_index)s`, `%(resolution)s` などの変数を使用して出力ファイル名の形式をカスタマイズします。**Download Settings → Filename Format** で設定可能です。
|
||||
- **出力形式の強制:** ビデオダウンロードを `mp4`, `webm`, `mkv` などの特定のコンテナ形式に強制します。**Download Settings → Output Format Settings** で設定可能です。
|
||||
- **オーディオ形式の変換:** オーディオのみのダウンロードを `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, または `Best` などの好みの形式に変換します。**Download Settings → Audio Format Settings** で設定可能です。
|
||||
- **オーディオノーマライズ:** EBU R128 を使用して、オーディオのみのダウンロードの音量を標準化します。
|
||||
- **同時接続数:** ファイルを複数のパーツで同時にダウンロードすることで、ダウンロード速度を大幅に向上させます。**Download Settings → General → Concurrent Connections** で設定可能です(デフォルトは 1。IP ブロックを避けるため最大 8-10 を推奨)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 アクセスとネットワーク</summary>
|
||||
|
||||
- **クッキーログイン:** クッキーを使用して YouTube にログインし、非公開コンテンツにアクセスします。
|
||||
使用方法:
|
||||
1. **推奨:** アプリ内蔵の `Extract cookies from browser` オプションを使用し、ブラウザと(必要に応じて)プロフィールを選択します。
|
||||
2. あるいは、クッキーを手動で抽出します:
|
||||
a. [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file) などの拡張機能を使用して、ブラウザからクッキーをエクスポートします。
|
||||
b. Netscape 形式でクッキーをコピーします。
|
||||
c. `cookies.txt` という名前のファイルを作成し、クッキーを貼り付けます。
|
||||
d. アプリで `cookies.txt` ファイルを選択します。
|
||||
- **プロキシサポート:** ダウンロードにプロキシサーバーを使用します(例:`http://<proxy-server>:<port>`)。
|
||||
- **ジェネリックモード:** YTSage が yt-dlp でサポートされている YouTube 以外のサイトから分析およびダウンロードできるようにします。**Download Settings → Generic Mode** から有効にします。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ ツールとメンテナンス</summary>
|
||||
|
||||
- **カスタムコマンド:** コマンドライン引数を介して高度な yt-dlp 機能にアクセスします。
|
||||
- **アップデートタブ:** カスタムオプション内の一箇所で内蔵のアップデートツールを管理します:
|
||||
- **yt-dlp アップデート:** アップデートを確認し、Stable と Nightly リリースチャネルを切り替えます。
|
||||
- **FFmpeg バージョンチェッカー:** FFmpeg のバージョンを確認し、インストールガイドを開きます。
|
||||
- **Deno アップデート:** Deno ランタイムを確認し、アップデートします。
|
||||
- **FFmpeg/yt-dlp/Deno 検出:** About ダイアログから FFmpeg、yt-dlp、Deno のパスとバージョンを自動的に検出します。
|
||||
- **ダウンロード履歴:** **History** ボタンから、サムネイルとステータス付きで過去のダウンロードを表示します。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 ローカライズ</summary>
|
||||
|
||||
YTSage はグローバルに対応するため、**14 言語**をサポートしています。**Custom Options → Language** で好みの言語を選択してください。
|
||||
|
||||
### サポートされている言語
|
||||
|
||||
| 言語 | コード | 言語 | コード |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 英語 | `en` | 🇪🇸 スペイン語 | `es` |
|
||||
| 🇸🇦 アラビア語 | `ar` | 🇫🇷 フランス語 | `fr` |
|
||||
| 🇩🇪 ドイツ語 | `de` | 🇮🇳 ヒンディー語 | `hi` |
|
||||
| 🇮🇩 インドネシア語 | `id` | 🇮🇹 イタリア語 | `it` |
|
||||
| 🇯🇵 日本語 | `ja` | 🇵🇱 ポーランド語 | `pl` |
|
||||
| 🇧🇷 ポルトガル語 | `pt` | 🇷🇺 ロシア語 | `ru` |
|
||||
| 🇹🇷 トルコ語 | `tr` | 🇨🇳 中国語 | `zh` |
|
||||
|
||||
### README 翻訳
|
||||
|
||||
| 言語 | ファイル | 言語 | ファイル |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 英語 | [README.md](../README.md) | 🇪🇸 スペイン語 | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 アラビア語 | [README.ar.md](README.ar.md) | 🇫🇷 フランス語 | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 ドイツ語 | [README.de.md](README.de.md) | 🇮🇳 ヒンディー語 | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 インドネシア語 | [README.id.md](README.id.md) | 🇮🇹 イタリア語 | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 日本語 | [README.ja.md](README.ja.md) | 🇵🇱 ポーランド語 | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 ポルトガル語 | [README.pt.md](README.pt.md) | 🇷🇺 ロシア語 | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 トルコ語 | [README.tr.md](README.tr.md) | 🇨🇳 中国語 | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **翻訳を手伝いたいですか?** [貢献](#貢献) セクションを参照して、さらに多くの言語を追加するのを手伝ってください!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="トラブルシューティング"></a>
|
||||
## 🛠️ トラブルシューティング
|
||||
|
||||
<details>
|
||||
<summary>クリックして一般的な問題と解決策を表示</summary>
|
||||
|
||||
- **フォーマットテーブルが表示されない:** yt-dlp を最新バージョンに更新し、yt-dlp Nightly への切り替えを試してください。
|
||||
- **ダウンロードが失敗する:** インターネット接続を確認し、ビデオが利用可能であることを確認してください。
|
||||
- **特定のダウンロードエラー:**
|
||||
- **非公開ビデオ:** 非公開コンテンツにアクセスするには、クッキー認証を使用してください。
|
||||
- **年齢制限のあるコンテンツ:** YouTube アカウントにログインして、年齢制限のあるビデオを表示してください。
|
||||
- **ジオブロックされたビデオ:** 地域制限を回避するために VPN の使用を検討してください。
|
||||
- **削除されたビデオ:** ビデオは YouTube で利用できなくなっています。
|
||||
- **ライブストリーム:** ライブストリームは放送中にダウンロードできません。ストリームが終了するまで待ってください。
|
||||
- **ネットワークエラー:** インターネット接続を確認して再試行してください。
|
||||
- **無効な URL:** URL が正しく、サポートされているプラットフォームのものであることを確認してください。
|
||||
- **プレミアムコンテンツ:** YouTube Premium のサブスクリプションが必要です。
|
||||
- **著作権ブロック:** 著作権制限のため、コンテンツがブロックされています。
|
||||
- **ダウンロード後にビデオとオーディオファイルが分かれている:** これは FFmpeg が不足しているか、検出されない場合に発生します。YTSage は、高品質のビデオとオーディオストリームを結合するために FFmpeg を必要とします。
|
||||
- **解決策:** FFmpeg がインストールされており、システムの PATH でアクセス可能であることを確認してください。Windows ユーザーにとって最も簡単なオプションは、FFmpeg が同梱されている `YTSage-v<version>-ffmpeg.exe` ファイルをダウンロードすることです。
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Windows Defender / アンチウイルス警告
|
||||
|
||||
一部のアンチウイルスソフトウェアは、`.exe` ファイルを誤検知(False Positive)としてフラグを立てる場合があります。これは、パッケージ化されたアプリケーションの**既知の制限**です。
|
||||
|
||||
**なぜ発生するのか:**
|
||||
- アンチウイルスのヒューリスティック機能が、パッケージ化された実行ファイルを疑わしいものとして誤認することがあります。
|
||||
|
||||
**安全な選択肢:**
|
||||
- ✅ **pip インストールを使用する:** `pip install ytsage` (推奨)
|
||||
- ✅ **ソースからビルドする**: この[ガイド](../.github/CI_CD_README.md)に従ってください。
|
||||
- ✅ **アプリケーションをホワイトリストに追加する** (アンチウイルスソフトウェアの設定)。
|
||||
|
||||
#### 🍎 macOS: 「アプリが破損しているため開けません」
|
||||
macOS Sonoma 以降でこのエラーが表示される場合は、 quarantine(隔離)属性を削除する必要があります。
|
||||
|
||||
1. **ターミナルを開きます** (Spotlight で検索できます)。
|
||||
2. **次のコマンドを入力します** が、まだ Enter は押さないでください。最後にスペースを含めるようにしてください:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Finder ウィンドウから `YTSage.app` ファイルをドラッグし**、ターミナルウィンドウに直接ドロップします。これにより、正しいファイルパスが自動的に貼り付けられます。
|
||||
4. **Enter を押して** コマンドを実行します。
|
||||
5. **YTSage.app を再度開いてみてください。** 正しく起動するはずです。
|
||||
|
||||
---
|
||||
|
||||
#### **設定ファイルの場所 (上級者向け)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="スポンサー"></a>
|
||||
## 💖 スポンサー
|
||||
|
||||
YTSage があなたの時間を節約できたなら、プロジェクトのスポンサーになることを検討してください。スポンサーシップは、開発時間、全プラットフォームでのテスト、および将来の改善に役立てられます。
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- スポンサーリンクは、アプリ内の About ダイアログから直接利用可能です。
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="貢献"></a>
|
||||
## 👥 貢献
|
||||
|
||||
貢献を歓迎します!以下のように手助けができます:
|
||||
|
||||
1. 🍴 リポジトリをフォークする
|
||||
2. 🌿 フィーチャーブランチを作成する:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 変更をコミットする:
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 ブランチにプッシュする:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 プルリクエストを作成する
|
||||
|
||||
### 🌍 翻訳に貢献する
|
||||
|
||||
- 関連するローカライズ版 README ファイルを更新する (例: `readme-translations/README.ja.md`)
|
||||
- `ytsage/languages/<code>.json` を編集して、アプリの文字列を同期させる
|
||||
- お使いの言語がない場合は、 `README.md` をベースに `README.<code>.md` を作成してください。
|
||||
|
||||
<details>
|
||||
<summary>📂 プロジェクト構造</summary>
|
||||
|
||||
## YTSage - プロジェクト構造
|
||||
|
||||
このドキュメントでは、YTSage の整理されたフォルダ構造について説明します。
|
||||
|
||||
### 📁 プロジェクト構造
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # GitHub 設定
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # イシューテンプレート
|
||||
│ │ └── 🐛-bug-report.md # バグレポートテンプレート
|
||||
│ ├─── 📁 workflows/ # GitHub Actions ワークフロー
|
||||
│ │ ├── build-linux.yml # Linux ビルドワークフロー
|
||||
│ │ ├── build-macos.yml # macOS ビルドワークフロー
|
||||
│ │ │── build-windows.yml # Windows ビルドワークフロー
|
||||
| | └── release-all.yml # マスターリリースワークフロー
|
||||
│ └── 📄 CI_CD_README.md # CI/CD ドキュメント
|
||||
├── 📁 branding/ # ブランディングアセット (スクリーンショット, SVG)
|
||||
│ ├── 📁 icons/ # アプリアイコン
|
||||
│ ├── 📁 screenshots/ # ドキュメント用スクリーンショット
|
||||
│ └── 📁 svg/ # SVG アセット
|
||||
├── 📄 LICENSE # ライセンスファイル
|
||||
├── 📄 pyproject.toml # プロジェクトメタデータと依存関係
|
||||
├── 📄 README.md # プロジェクトドキュメント
|
||||
├── 📄 requirements.txt # Python 依存関係 (dev)
|
||||
└── 📁 ytsage/ # ソースコードパッケージ
|
||||
├── 📁 assets/ # ランタイムアセット
|
||||
│ ├── 📁 Icon/ # アプリアイコン
|
||||
│ └── 📁 sound/ # オーディオファイル
|
||||
├── 📁 languages/ # ローカライズファイル
|
||||
│ ├── 📄 ar.json # アラビア語翻訳
|
||||
│ ├── 📄 de.json # ドイツ語翻訳
|
||||
│ ├── 📄 en.json # 英語翻訳
|
||||
│ └── ... # その他の言語
|
||||
├── 📁 core/ # コアビジネスロジック
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Deno 統合
|
||||
│ ├── 📄 ytsage_downloader.py # ダウンロード機能
|
||||
│ ├── 📄 ytsage_ffmpeg.py # FFmpeg 統合
|
||||
│ ├── 📄 ytsage_utils.py # ユーティリティ関数
|
||||
│ └── 📄 ytsage_yt_dlp.py # yt-dlp 統合
|
||||
├── 📁 gui/ # ユーザーインターフェースコンポーネント
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # アプリメインウィンドウ
|
||||
│ └── 📁 ytsage_gui_dialogs/ # ダイアログクラス
|
||||
├── 📁 utils/ # ユーティリティモジュール
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # 設定管理
|
||||
│ └── 📄 ytsage_logger.py # ロギングツール
|
||||
├── 📄 __init__.py # パッケージエントリポイント
|
||||
└── 📄 main.py # メイン実行スクリプト
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ スター履歴
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 ライセンス
|
||||
|
||||
このプロジェクトは MIT ライセンスの下でライセンスされています。詳細は [LICENSE](../LICENSE) ファイルを参照してください。
|
||||
|
||||
## 🙏 謝辞
|
||||
|
||||
<details>
|
||||
<summary>謝辞を表示</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>改善の提案やバグの報告のためにイシューを開いてこのプロジェクトに貢献してくださったすべての方々に感謝いたします。</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">主要コンポーネント</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>ダウンロードエンジン</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>メディア処理</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>yt-dlp 統合用ランタイム</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">ライブラリとフレームワーク</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI フレームワーク</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>画像処理</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>HTTP リクエスト</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>バージョン管理とパッケージ化</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Markdown レンダリング</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>ロギング</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">アセットと貢献者</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>通知音</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>コード貢献者</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ 免責事項
|
||||
|
||||
このツールは個人利用のみを目的としています。YouTube の利用規約およびコンテンツ制作者の権利を尊重してください。
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Created with ❤️ by [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Nowoczesny downloader YouTube z czystym interfejsem PySide6.**
|
||||
Pobieraj wideo w dowolnej jakości, wyodrębniaj audio, pobieraj napisy i wiele więcej.
|
||||
|
||||
### 🌍 Języki README
|
||||
|
||||
Angielski: [EN](../README.md)
|
||||
| Arabski: [AR](README.ar.md)
|
||||
| Niemiecki: [DE](README.de.md)
|
||||
| Hiszpański: [ES](README.es.md)
|
||||
| Francuski: [FR](README.fr.md)
|
||||
| Hindi: [HI](README.hi.md)
|
||||
| Indonezyjski: [ID](README.id.md)
|
||||
| Włoski: [IT](README.it.md)
|
||||
| Japoński: [JA](README.ja.md)
|
||||
| Polski: [PL](README.pl.md)
|
||||
| Portugalski: [PT](README.pt.md)
|
||||
| Rosyjski: [RU](README.ru.md)
|
||||
| Turecki: [TR](README.tr.md)
|
||||
| Chiński: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#instalacja">Instalacja</a> •
|
||||
<a href="#funkcje">Funkcje</a> •
|
||||
<a href="#użycie">Użycie</a> •
|
||||
<a href="#zrzuty-ekranu">Zrzuty ekranu</a> •
|
||||
<a href="#rozwiązywanie-problemów">Rozwiązywanie problemów</a> •
|
||||
<a href="#sponsor">Sponsor</a> •
|
||||
<a href="#współpraca">Współpraca</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="dlaczego-ytsage"></a>
|
||||
## ❓ Dlaczego YTSage?
|
||||
|
||||
YTSage został zaprojektowany dla użytkowników, którzy chcą **prostego, ale potężnego downloadera YouTube**. W przeciwieństwie do innych narzędzi oferuje:
|
||||
|
||||
- Nowoczesny i czysty interfejs PySide6
|
||||
- Pobieranie wideo, audio i napisów jednym kliknięciem
|
||||
- Zaawansowane funkcje, takie jak SponsorBlock, scalanie napisów i wybór playlisty
|
||||
- Opcjonalny tryb ogólny (Generic Mode) dla stron spoza YouTube obsługiwanych przez yt-dlp
|
||||
- Obsługa wielu platform i łatwa instalacja
|
||||
|
||||
<a id="funkcje"></a>
|
||||
## ✨ Funkcje
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Funkcje podstawowe | Funkcje zaawansowane | Funkcje dodatkowe |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Tabela formatów | 🚫 Integracja SponsorBlock | 🎞️ Wyświetlanie FPS/HDR |
|
||||
| 🎵 Wyodrębnianie audio | 📝 Wybór i scalanie napisów | 🔄 Auto-aktualizacja yt-dlp |
|
||||
| ✨ Prosty interfejs użytkownika | 💾 Zapis opisu i miniatur | 🛠️ Wykrywanie FFmpeg/yt-dlp/Deno |
|
||||
| 📋 Obsługa i wybór playlist | 🚀 Ogranicznik prędkości | ⚙️ Własne komendy |
|
||||
| 📑 Integracja rozdziałów | ✂️ Przycinanie sekcji wideo | 🍪 Logowanie przez ciasteczka |
|
||||
| 📜 Historia pobierania | 🔄 Wybór kanału wydań | 🌐 Obsługa proxy |
|
||||
| 🎚️ Konwersja formatów audio | 🎬 Ustawienia formatu wideo | 🆙 Zintegrowana karta aktualizacji |
|
||||
| 🌍 Tryb ogólny | 🔊 Normalizacja audio (EBU R128) | 🌍 Lokalizacja w 14 językach |
|
||||
| 💾 Eksport playlisty | ⚙️ Domyślna jakość i napisy | |
|
||||
</div>
|
||||
|
||||
<a id="instalacja"></a>
|
||||
## 🚀 Instalacja
|
||||
|
||||
### ⚡ Szybka instalacja (zalecana)
|
||||
|
||||
Zainstaluj YTSage przez PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Aktualizacja istniejącej instalacji</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Następnie uruchom aplikację:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Gotowe pliki wykonywalne (Executable)
|
||||
|
||||
> [👉 Pobierz najnowsze wydanie](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Format | Opis |
|
||||
|--------|-------------|
|
||||
|  | Standardowy instalator |
|
||||
|  | Z dołączonym FFmpeg |
|
||||
|  | Wersja przenośna, nie wymaga instalacji |
|
||||
|  | Przenośna z FFmpeg, skompresowana (ZIP) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Kroki instalacji</summary>
|
||||
|
||||
1. **Instalator EXE (`.exe`)**: Kliknij dwukrotnie plik i postępuj zgodnie z instrukcjami kreatora.
|
||||
2. **Wersja przenośna (`.zip`)**: Rozpakuj archiwum w wybranym miejscu i uruchom `ytsage.exe`.
|
||||
3. **Wbudowany FFmpeg**: Jeśli nie masz zainstalowanego FFmpeg w systemie, wybierz wersję z dołączonym FFmpeg.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Format | Opis |
|
||||
|--------|-------------|
|
||||
|  | Pakiet Debian |
|
||||
|  | AppImage, przenośny |
|
||||
|  | Pakiet RPM |
|
||||
|  | Pakiet Flatpak |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Kroki instalacji</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Jeśli trzeba naprawić brakujące zależności
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Postępuj zgodnie z instrukcjami na Flathub lub uruchom:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Format | Opis |
|
||||
|--------|-------------|
|
||||
|  | Aplikacja ZIP dla Apple Silicon |
|
||||
|  | Instalator Disk Image dla Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Kroki instalacji</summary>
|
||||
|
||||
- **Instalator DMG (`.dmg`)**: Kliknij dwukrotnie, aby zamontować, a następnie przeciągnij `YTSage.app` do folderu Aplikacje.
|
||||
- **Archiwum aplikacji (`.zip`)**: Rozpakuj ZIP i przenieś `YTSage.app` do folderu Aplikacje.
|
||||
|
||||
*Uwaga: Jeśli otrzymasz błąd "Aplikacja jest uszkodzona", zapoznaj się z sekcją rozwiązywania problemów macOS poniżej.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Ręczna instalacja ze źródeł</summary>
|
||||
|
||||
### 1. Sklonuj repozytorium
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Zainstaluj zależności
|
||||
|
||||
#### ⚡ Z użyciem uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Lub ze standardowym pip
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Uruchom aplikację
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="zrzuty-ekranu"></a>
|
||||
## 📸 Zrzuty ekranu
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Ustawienia pobierania" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Pobieranie playlisty" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Ustawienia pobierania</em></td>
|
||||
<td align="center"><em>Pobieranie playlisty</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Wybór formatu audio" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Opcje niestandardowe" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Format audio</em></td>
|
||||
<td align="center"><em>Opcje niestandardowe</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="użycie"></a>
|
||||
## 📖 Użycie
|
||||
|
||||
<details>
|
||||
<summary>🎯 Podstawowe użycie</summary>
|
||||
|
||||
1. **Uruchom YTSage**
|
||||
2. **Wklej URL z YouTube** (lub użyj przycisku "Wklej URL")
|
||||
3. **Kliknij "Analizuj"**
|
||||
4. **Wybierz format:**
|
||||
- `Wideo` dla pobierania wideo
|
||||
- `Tylko audio` dla wyodrębniania dźwięku
|
||||
5. **Wybierz opcje:**
|
||||
- Włącz napisy i wybierz język
|
||||
- Włącz scalanie napisów
|
||||
- Zapisz miniaturę
|
||||
- Usuń sekcje sponsorowane
|
||||
- Zapisz opis
|
||||
- Osadź rozdziały
|
||||
6. **Wybierz folder wyjściowy**
|
||||
7. **Kliknij "Pobierz"**
|
||||
|
||||
> 💡 Domyślny folder pobierania to folder "Pobrane" użytkownika.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Pobieranie playlisty</summary>
|
||||
|
||||
1. **Wklej URL playlisty**
|
||||
2. **Kliknij "Analizuj"**
|
||||
3. **Wybierz filmy z selektora (opcjonalnie, domyślnie wszystkie)**
|
||||
4. **Wybierz żądany format/jakość**
|
||||
5. **Kliknij "Pobierz"**
|
||||
|
||||
> 💡 Aplikacja automatycznie zarządza kolejką pobierania, a wpisy playlisty możesz eksportować do plików `.txt`, `.csv`, `.m3u` lub `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Tryb ogólny dla stron innych niż YouTube</summary>
|
||||
|
||||
Użyj trybu ogólnego (Generic Mode), gdy chcesz, aby YTSage akceptował adresy URL ze stron obsługiwanych przez yt-dlp, takich jak Dailymotion, CBC Gem, TikTok i inne.
|
||||
|
||||
Jak go użyć:
|
||||
|
||||
1. Otwórz `Download Settings`.
|
||||
2. Włącz `Generic Mode`.
|
||||
3. Wklej obsługiwany URL wideo lub playlisty spoza YouTube.
|
||||
4. Kliknij `Analyze`.
|
||||
5. Wybierz format i pobierz jak zwykle.
|
||||
|
||||
Uwagi:
|
||||
|
||||
- Tryb ogólny zmienia tylko walidację adresu URL wewnątrz YTSage. Strona docelowa musi być nadal obsługiwana przez zainstalowaną wersję yt-dlp.
|
||||
- Niektóre strony wymagają ciasteczek, logowania, proxy lub dodatkowych argumentów yt-dlp w zależności od ekstraktora.
|
||||
- Jeśli strona nie działa, zaktualizuj yt-dlp ze zintegrowanej karty aktualizacji przed zgłoszeniem problemu.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Opcje mediów i pobierania</summary>
|
||||
|
||||
- **Opcje napisów:** Filtruj języki i osadzaj napisy w pliku wideo.
|
||||
- **Scalanie napisów:** Scala napisy z plikiem wideo (hardcoded).
|
||||
- **Zapis opisu:** Zapisuje opis wideo jako plik tekstowy.
|
||||
- **Zapis miniatury:** Zapisuje miniaturę wideo jako plik graficzny.
|
||||
- **Osadź rozdziały:** Dołącza znaczniki rozdziałów jako metadane dla kompatybilnych odtwarzaczy.
|
||||
- **Usuń sekcje sponsorowane:** Używa SponsorBlock do usuwania segmentów sponsorowanych z filmu.
|
||||
- **Przycinanie wideo:** Pobieraj tylko określone części filmu, określając zakres czasu w formacie `GG:MM:SS`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Ustawienia wyjściowe i plików</summary>
|
||||
|
||||
- **Ogranicznik prędkości:** Ogranicz prędkość pobierania, np. `500K` dla 500 KB/s.
|
||||
- **Zapisz ścieżkę pobierania:** Zapisuje domyślną ścieżkę dla przyszłych pobrań. Dostępne w **Download Settings → Download Path**.
|
||||
- **Domyślna rozdzielczość wideo:** Ustaw preferowaną rozdzielczość dla automatycznego wyboru (np. 1080p, 720p). Dostępne w **Download Settings → Default Video Resolution**.
|
||||
- **Domyślne języki napisów:** Ustaw domyślne języki dla automatycznego wyboru (rozdzielone przecinkami, np. `pl,en`). Dostępne w **Download Settings → Default Subtitle Languages**.
|
||||
- **Format nazwy pliku:** Dostosuj format nazwy pliku wyjściowego, używając zmiennych takich jak `%(title)s`, `%(uploader)s`, `%(playlist_index)s` i `%(resolution)s`. Dostępne w **Download Settings → Filename Format**.
|
||||
- **Wymuś format wyjściowy:** Wymuś pobieranie wideo do konkretnego formatu kontenera, jak `mp4`, `webm` lub `mkv`. Dostępne w **Download Settings → Output Format Settings**.
|
||||
- **Konwersja formatów audio:** Konwertuj pobierany dźwięk do preferowanego formatu, takiego jak `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis` lub `Best`. Dostępne w **Download Settings → Audio Format Settings**.
|
||||
- **Normalizacja audio:** Ujednolica głośność pobieranego dźwięku przy użyciu EBU R128.
|
||||
- **Jednoczesne połączenia:** Znacznie zwiększa prędkość pobierania, pobierając pliki w kilku częściach naraz. Dostępne w **Download Settings → General → Concurrent Connections** (domyślnie 1, zalecane maks. 8-10, aby uniknąć blokad IP).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Dostęp i sieć</summary>
|
||||
|
||||
- **Logowanie przez ciasteczka:** Zaloguj się do YouTube, używając ciasteczek, aby uzyskać dostęp do prywatnych treści.
|
||||
Użycie:
|
||||
1. **Zalecane:** Użyj wbudowanej opcji `Eksportuj ciasteczka z przeglądarki`, a następnie wybierz przeglądarkę i opcjonalnie profil.
|
||||
2. Alternatywnie, wyeksportuj ciasteczka ręcznie:
|
||||
a. Eksportuj ciasteczka z przeglądarki, używając rozszerzenia typu [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Skopiuj ciasteczka w formacie Netscape
|
||||
c. Utwórz plik o nazwie `cookies.txt` i wklej ciasteczka
|
||||
d. Wybierz plik `cookies.txt` w aplikacji
|
||||
- **Obsługa proxy:** Użyj serwera proxy do pobierania, np. `http://<serwer-proxy>:<port>`
|
||||
- **Tryb ogólny:** Pozwala YTSage na analizę i pobieranie z witryn innych niż YouTube obsługiwanych przez yt-dlp. Włącz w **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Narzędzia i konserwacja</summary>
|
||||
|
||||
- **Własne komendy:** Dostęp do zaawansowanych funkcji yt-dlp poprzez argumenty wiersza poleceń.
|
||||
- **Karta aktualizacji:** Zarządzaj wbudowanymi narzędziami aktualizacji z jednego miejsca w opcjach niestandardowych:
|
||||
- **Aktualizacja yt-dlp:** Sprawdzaj aktualizacje i przełączaj się między kanałami Stable i Nightly.
|
||||
- **Sprawdzanie wersji FFmpeg:** Weryfikuj wersję FFmpeg i otwieraj przewodniki instalacji.
|
||||
- **Aktualizacja Deno:** Sprawdzaj i aktualizuj środowisko uruchomieniowe Deno.
|
||||
- **Wykrywanie FFmpeg/yt-dlp/Deno:** Automatycznie wykrywa ścieżki i wersje FFmpeg, yt-dlp i Deno w oknie "O programie".
|
||||
- **Historia pobierania:** Przeglądaj poprzednie pobrania z miniaturami i statusami za pomocą przycisku **History**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Lokalizacja</summary>
|
||||
|
||||
YTSage obsługuje **14 języków**. Wybierz preferowany język w **Custom Options → Language**.
|
||||
|
||||
### Obsługiwane języki
|
||||
|
||||
| Język | Kod | Język | Kod |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Angielski | `en` | 🇪🇸 Hiszpański | `es` |
|
||||
| 🇸🇦 Arabski | `ar` | 🇫🇷 Francuski | `fr` |
|
||||
| 🇩🇪 Niemiecki | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonezyjski | `id` | 🇮🇹 Włoski | `it` |
|
||||
| 🇯🇵 Japoński | `ja` | 🇵🇱 Polski | `pl` |
|
||||
| 🇧🇷 Portugalski | `pt` | 🇷🇺 Rosyjski | `ru` |
|
||||
| 🇹🇷 Turecki | `tr` | 🇨🇳 Chiński | `zh` |
|
||||
|
||||
### Tłumaczenia README
|
||||
|
||||
| Język | Plik | Język | Plik |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Angielski | [README.md](../README.md) | 🇪🇸 Hiszpański | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Arabski | [README.ar.md](README.ar.md) | 🇫🇷 Francuski | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Niemiecki | [README.de.md](README.de.md) | 🇮🇳 Hindi | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Indonezyjski | [README.id.md](README.id.md) | 🇮🇹 Włoski | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Japoński | [README.ja.md](README.ja.md) | 🇵🇱 Polski | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Portugalski | [README.pt.md](README.pt.md) | 🇷🇺 Rosyjski | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Turecki | [README.tr.md](README.tr.md) | 🇨🇳 Chiński | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Chcesz pomóc w tłumaczeniu?** Sprawdź sekcję [Współpraca](#współpraca), aby pomóc nam dodać więcej języków!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="rozwiązywanie-problemów"></a>
|
||||
## 🛠️ Rozwiązywanie problemów
|
||||
|
||||
<details>
|
||||
<summary>Kliknij, aby zobaczyć typowe problemy i rozwiązania</summary>
|
||||
|
||||
- **Tabela formatów nie pojawia się:** Zaktualizuj yt-dlp do najnowszej wersji i spróbuj przełączyć się na kanał yt-dlp Nightly.
|
||||
- **Pobieranie nie powiodło się:** Sprawdź połączenie z internetem i upewnij się, że wideo jest dostępne.
|
||||
- **Konkretne błędy pobierania:**
|
||||
- **Prywatne wideo:** Użyj uwierzytelniania przez ciasteczka, aby uzyskać dostęp do prywatnych treści.
|
||||
- **Treści z ograniczeniem wiekowym:** Zaloguj się na swoje konto YouTube, aby wyświetlić filmy z ograniczeniem wiekowym.
|
||||
- **Wideo z blokadą regionalną:** Rozważ użycie VPN, aby obejść ograniczenia regionalne.
|
||||
- **Usunięte wideo:** Film nie jest już dostępny na YouTube.
|
||||
- **Transmisje na żywo:** Transmisji na żywo nie można pobierać w trakcie ich trwania; poczekaj, aż transmisja się zakończy.
|
||||
- **Błędy sieciowe:** Sprawdź połączenie z internetem i spróbuj ponownie.
|
||||
- **Nieprawidłowy URL:** Upewnij się, że adres URL jest poprawny i pochodzi z obsługiwanej platformy.
|
||||
- **Treść Premium:** Wymaga subskrypcji YouTube Premium.
|
||||
- **Blokada praw autorskich:** Treść jest zablokowana z powodu ograniczeń praw autorskich.
|
||||
- **Pliki wideo i audio są oddzielne po pobraniu:** Dzieje się tak, gdy brakuje FFmpeg lub nie został on wykryty. YTSage wymaga FFmpeg do scalania strumieni wideo i audio wysokiej jakości.
|
||||
- **Rozwiązanie:** Upewnij się, że FFmpeg jest zainstalowany i dostępny w zmiennej PATH systemu. Dla użytkowników Windows najprostszą opcją jest pobranie pliku `YTSage-v<wersja>-ffmpeg.exe`, który zawiera FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Ostrzeżenie Windows Defender / Antywirus
|
||||
|
||||
Niektóre programy antywirusowe mogą oznaczać pliki `.exe` jako fałszywe alarmy (false positive). Jest to **znane ograniczenie** pakowanych aplikacji.
|
||||
|
||||
**Dlaczego tak się dzieje:**
|
||||
- Heurystyka antywirusa może błędnie zidentyfikować spakowane pliki wykonywalne jako podejrzane.
|
||||
|
||||
**Bezpieczne alternatywy:**
|
||||
- ✅ **Użyj instalacji przez pip:** `pip install ytsage` (zalecane)
|
||||
- ✅ **Zbuduj ze źródeł**: Postępując zgodnie z tym [przewodnikiem](../.github/CI_CD_README.md)
|
||||
- ✅ **Dodaj aplikację do wyjątków** w swoim programie antywirusowym.
|
||||
|
||||
#### 🍎 macOS: "Aplikacja jest uszkodzona i nie można jej otworzyć"
|
||||
Jeśli widzisz ten błąd w systemie macOS Sonoma lub nowszym, musisz usunąć atrybut kwarantanny.
|
||||
|
||||
1. **Otwórz Terminal** (możesz go znaleźć przez Spotlight).
|
||||
2. **Wpisz następującą komendę**, ale jeszcze **NIE** naciskaj Enter. Pamiętaj o spacjach na końcu:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Przeciągnij plik `YTSage.app` z okna Findera** i upuść go bezpośrednio w oknie Terminala. To automatycznie wklei poprawną ścieżkę do pliku.
|
||||
4. **Naciśnij Enter**, aby uruchomić komendę.
|
||||
5. **Spróbuj ponownie otworzyć YTSage.app.** Powinien uruchomić się poprawnie.
|
||||
|
||||
---
|
||||
|
||||
#### **Lokalizacja konfiguracji (zaawansowane)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Sponsor
|
||||
|
||||
Jeśli YTSage oszczędza Twój czas, rozważ wsparcie projektu. Sponsoring pomaga pokryć czas programistyczny, testy na wszystkich platformach i przyszłe ulepszenia.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Link do wsparcia jest dostępny bezpośrednio przez okno "O programie" w aplikacji.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="współpraca"></a>
|
||||
## 👥 Współpraca
|
||||
|
||||
Zapraszamy do współpracy! Oto jak możesz pomóc:
|
||||
|
||||
1. 🍴 Skonfiguruj fork repozytorium
|
||||
2. 🌿 Utwórz gałąź (branch) dla swojej funkcji:
|
||||
```bash
|
||||
git checkout -b feature/NiesamowitaFunkcja
|
||||
```
|
||||
3. 💾 Zatwierdź zmiany (commit):
|
||||
```bash
|
||||
git commit -m 'Dodaj NiesamowitaFunkcja'
|
||||
```
|
||||
4. 📤 Wyślij zmiany do gałęzi (push):
|
||||
```bash
|
||||
git push origin feature/NiesamowitaFunkcja
|
||||
```
|
||||
5. 🔄 Otwórz Pull Request
|
||||
|
||||
### 🌍 Pomóż w tłumaczeniach
|
||||
|
||||
- Zaktualizuj odpowiedni lokalny plik README (np. `readme-translations/README.pl.md`)
|
||||
- Dbaj o synchronizację komunikatów aplikacji, edytując `ytsage/languages/<code>.json`
|
||||
- Jeśli brakuje Twojego języka, zacznij od `README.md` i stwórz `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Struktura projektu</summary>
|
||||
|
||||
## YTSage - Struktura projektu
|
||||
|
||||
Ten dokument opisuje zorganizowaną strukturę folderów projektu YTSage.
|
||||
|
||||
### 📁 Struktura projektu
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # Konfiguracja GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Szablony zgłoszeń
|
||||
│ │ └── 🐛-bug-report.md # Szablon zgłoszenia błędu
|
||||
│ ├─── 📁 workflows/ # Procesy GitHub Actions
|
||||
│ │ ├── build-linux.yml # Proces budowania dla Linux
|
||||
│ │ ├── build-macos.yml # Proces budowania dla macOS
|
||||
│ │ │── build-windows.yml # Proces budowania dla Windows
|
||||
| | └── release-all.yml # Proces głównego wydania
|
||||
│ └── 📄 CI_CD_README.md # Dokumentacja CI/CD
|
||||
├── 📁 branding/ # Materiały graficzne (zrzuty ekranu, SVG)
|
||||
│ ├── 📁 icons/ # Ikony aplikacji
|
||||
│ ├── 📁 screenshots/ # Zrzuty ekranu do dokumentacji
|
||||
│ └── 📁 svg/ # Zasoby SVG
|
||||
├── 📄 LICENSE # Plik licencji
|
||||
├── 📄 pyproject.toml # Metadane projektu i zależności
|
||||
├── 📄 README.md # Dokumentacja projektu
|
||||
├── 📄 requirements.txt # Zależności Python (dev)
|
||||
└── 📁 ytsage/ # Pakiet kodu źródłowego
|
||||
├── 📁 assets/ # Zasoby czasu wykonywania
|
||||
│ ├── 📁 Icon/ # Ikony aplikacji
|
||||
│ └── 📁 sound/ # Pliki dźwiękowe
|
||||
├── 📁 languages/ # Pliki lokalizacji
|
||||
│ ├── 📄 ar.json # Tłumaczenie arabskie
|
||||
│ ├── 📄 de.json # Tłumaczenie niemieckie
|
||||
│ ├── 📄 en.json # Tłumaczenie angielskie
|
||||
│ └── ... # Inne języki
|
||||
├── 📁 core/ # Główna logika biznesowa
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Integracja Deno
|
||||
│ ├── 📄 ytsage_downloader.py # Funkcjonalność pobierania
|
||||
│ ├── 📄 ytsage_ffmpeg.py # Integracja FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # Funkcje pomocnicze
|
||||
│ └── 📄 ytsage_yt_dlp.py # Integrasi yt-dlp
|
||||
├── 📁 gui/ # Komponenty interfejsu użytkownika
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # Główne okno aplikacji
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Klasy okien dialogowych
|
||||
├── 📁 utils/ # Moduły pomocnicze
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # Zarządzanie konfiguracją
|
||||
│ └── 📄 ytsage_logger.py # Narzędzia logowania
|
||||
├── 📄 __init__.py # Punkt wejścia pakietu
|
||||
└── 📄 main.py # Główny skrypt wykonawczy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Historia gwiazdek
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Licencja
|
||||
|
||||
Ten projekt jest udostępniany na licencji MIT - zobacz plik [LICENSE](../LICENSE), aby poznać szczegóły.
|
||||
|
||||
## 🙏 Podziękowania
|
||||
|
||||
<details>
|
||||
<summary>Pokaż podziękowania</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Wielkie podziękowania dla wszystkich, którzy przyczynili się do rozwoju projektu poprzez zgłaszanie sugestii i błędów.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Główne komponenty</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Silnik pobierania</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Przetwarzanie mediów</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Środowisko dla integracji yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Biblioteki i frameworki</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>Framework GUI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Przetwarzanie obrazów</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>Żądania HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Zarządzanie wersjami i pakowanie</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Renderowanie Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logowanie</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Zasoby i współpracownicy</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>Dźwięk powiadomienia</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Współpracownik kodu</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Zastrzeżenie
|
||||
|
||||
To narzędzie służy wyłącznie do użytku osobistego. Prosimy o przestrzeganie Warunków świadczenia usług YouTube i praw twórców treści.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Stworzone z ❤️ przez [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Um downloader de YouTube moderno com uma interface PySide6 limpa.**
|
||||
Baixe vídeos em qualquer qualidade, extraia áudio, obtenha legendas e muito mais.
|
||||
|
||||
### 🌍 Idiomas do README
|
||||
|
||||
Inglês: [EN](../README.md)
|
||||
| Árabe: [AR](README.ar.md)
|
||||
| Alemão: [DE](README.de.md)
|
||||
| Espanhol: [ES](README.es.md)
|
||||
| Francês: [FR](README.fr.md)
|
||||
| Hindi: [HI](README.hi.md)
|
||||
| Indonésio: [ID](README.id.md)
|
||||
| Italiano: [IT](README.it.md)
|
||||
| Japonês: [JA](README.ja.md)
|
||||
| Polonês: [PL](README.pl.md)
|
||||
| Português: [PT](README.pt.md)
|
||||
| Russo: [RU](README.ru.md)
|
||||
| Turco: [TR](README.tr.md)
|
||||
| Chinês: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#instalação">Instalação</a> •
|
||||
<a href="#funcionalidades">Funcionalidades</a> •
|
||||
<a href="#uso">Uso</a> •
|
||||
<a href="#capturas-de-tela">Capturas de Tela</a> •
|
||||
<a href="#solução-de-problemas">Solução de Problemas</a> •
|
||||
<a href="#patrocinar">Patrocinar</a> •
|
||||
<a href="#contribuindo">Contribuindo</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="por-que-ytsage"></a>
|
||||
## ❓ Por que YTSage?
|
||||
|
||||
O YTSage foi projetado para usuários que desejam um **downloader de YouTube simples, mas poderoso**. Ao contrário de outras ferramentas, ele oferece:
|
||||
|
||||
- Uma interface PySide6 moderna e limpa
|
||||
- Download de vídeo, áudio e legendas com um clique
|
||||
- Recursos avançados como SponsorBlock, mesclagem de legendas e seleção de playlist
|
||||
- Modo Genérico opcional para sites além do YouTube suportados pelo yt-dlp
|
||||
- Suporte multiplataforma e instalação fácil
|
||||
|
||||
<a id="funcionalidades"></a>
|
||||
## ✨ Funcionalidades
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Recursos Básicos | Recursos Avançados | Recursos Extras |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Tabela de Formatos | 🚫 Integração SponsorBlock | 🎞️ Exibição de FPS/HDR |
|
||||
| 🎵 Extração de Áudio | 📝 Seleção e Mesclagem de Legendas | 🔄 Atualização Automática do yt-dlp |
|
||||
| ✨ Interface de Usuário Simples | 💾 Salvar Descrição e Miniatura | 🛠️ Detecção de FFmpeg/yt-dlp/Deno |
|
||||
| 📋 Suporte e Seletor de Playlist | 🚀 Limitador de Velocidade | ⚙️ Comandos Personalizados |
|
||||
| 📑 Integração de Capítulos | ✂️ Cortar Seções de Vídeo | 🍪 Login por Cookies |
|
||||
| 📜 Histórico de Downloads | 🔄 Escolha do Canal de Lançamento | 🌐 Suporte a Proxy |
|
||||
| 🎚️ Conversão de Formato de Áudio | 🎬 Configurações de Formato de Vídeo | 🆙 Aba de Atualização Integrada |
|
||||
| 🌍 Modo Genérico | 🔊 Normalização de Áudio (EBU R128) | 🌍 Localização em 14 idiomas |
|
||||
| 💾 Exportação de Playlist | ⚙️ Qualidade e Legendas Padrão | |
|
||||
</div>
|
||||
|
||||
<a id="instalação"></a>
|
||||
## 🚀 Instalação
|
||||
|
||||
### ⚡ Instalação Rápida (Recomendada)
|
||||
|
||||
Instale o YTSage via PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Atualizar Instalação Existente</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Em seguida, execute o aplicativo:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Executáveis Pré-compilados (Executable)
|
||||
|
||||
> [👉 Baixar Lançamento Mais Recente](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Formato | Descrição |
|
||||
|--------|-------------|
|
||||
|  | Instalador Padrão |
|
||||
|  | Com FFmpeg incluído |
|
||||
|  | Versão Portátil, sem necessidade de instalação |
|
||||
|  | Portátil com FFmpeg, compactado (ZIP) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Passos para Instalação</summary>
|
||||
|
||||
1. **Instalador EXE (`.exe`)**: Clique duas vezes no arquivo e siga o assistente de configuração.
|
||||
2. **Versão Portátil (`.zip`)**: Extraia o arquivo para o local desejado e execute `ytsage.exe`.
|
||||
3. **FFmpeg Integrado**: Se você não possui o FFmpeg instalado no sistema, escolha as versões com FFmpeg integrado.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Formato | Descrição |
|
||||
|--------|-------------|
|
||||
|  | Pacote Debian |
|
||||
|  | AppImage, Portátil |
|
||||
|  | Pacote RPM |
|
||||
|  | Pacote Flatpak |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Passos para Instalação</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Se necessário para corrigir dependências ausentes
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Siga as instruções no Flathub ou execute:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Formato | Descrição |
|
||||
|--------|-------------|
|
||||
|  | Aplicativo ZIP para Apple Silicon |
|
||||
|  | Instalador Disk Image para Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Passos para Instalação</summary>
|
||||
|
||||
- **Instalador DMG (`.dmg`)**: Clique duas vezes para montar e arraste `YTSage.app` para a sua pasta Aplicativos.
|
||||
- **Arquivo do Aplicativo (`.zip`)**: Extraia o ZIP e mova `YTSage.app` para a sua pasta Aplicativos.
|
||||
|
||||
*Nota: Se você receber o erro "App está danificado", veja a seção de Problemos no macOS abaixo.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Instalação Manual a partir do Código-Fonte</summary>
|
||||
|
||||
### 1. Clonar o Repositório
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Instalar Dependências
|
||||
|
||||
#### ⚡ Com uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Ou com pip padrão
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Executar o Aplicativo
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="capturas-de-tela"></a>
|
||||
## 📸 Capturas de Tela
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Configurações de Download" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Download de Playlist" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Configurações de Download</em></td>
|
||||
<td align="center"><em>Download de Playlist</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Seleção de Formato de Áudio" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Opções Personalizadas" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Formato de Áudio</em></td>
|
||||
<td align="center"><em>Opções Personalizadas</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="uso"></a>
|
||||
## 📖 Uso
|
||||
|
||||
<details>
|
||||
<summary>🎯 Uso Básico</summary>
|
||||
|
||||
1. **Inicie o YTSage**
|
||||
2. **Cole uma URL do YouTube** (ou use o botão "Paste URL")
|
||||
3. **Clique em "Analyze"**
|
||||
4. **Escolha o Formato:**
|
||||
- `Video` para download de vídeo
|
||||
- `Audio Only` para extração de áudio
|
||||
5. **Selecione Opções:**
|
||||
- Habilite legendas e escolha o idioma
|
||||
- Habilite a mesclagem de legendas
|
||||
- Salvar miniatura
|
||||
- Remover seções de patrocinadores
|
||||
- Salvar descrição
|
||||
- Incorporar capítulos
|
||||
6. **Escolha o Diretório de Saída**
|
||||
7. **Clique em "Download"**
|
||||
|
||||
> 💡 O diretório de download padrão é a pasta "Downloads" do usuário.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Download de Playlist</summary>
|
||||
|
||||
1. **Cole a URL da Playlist**
|
||||
2. **Clique em "Analyze"**
|
||||
3. **Selecione vídeos do seletor (opcional, padrão todos)**
|
||||
4. **Escolha o formato/qualidade desejado**
|
||||
5. **Clique em "Download"**
|
||||
|
||||
> 💡 O aplicativo gerencia automaticamente a fila de download, e você pode exportar as entradas da playlist como arquivos `.txt`, `.csv`, `.m3u` ou `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Modo Genérico para Sites além do YouTube</summary>
|
||||
|
||||
Use o Modo Genérico quando quiser que o YTSage aceite URLs de sites suportados pelo yt-dlp, como Dailymotion, CBC Gem, TikTok e outros.
|
||||
|
||||
Como usar:
|
||||
|
||||
1. Abra `Download Settings`.
|
||||
2. Habilite `Generic Mode`.
|
||||
3. Cole uma URL de vídeo ou playlist suportada que não seja do YouTube.
|
||||
4. Clique em `Analyze`.
|
||||
5. Escolha um formato e baixe normalmente.
|
||||
|
||||
Notas:
|
||||
|
||||
- O Modo Genérico apenas altera a validação da URL dentro do YTSage. O site de destino ainda deve ser suportado pela sua versão instalada do yt-dlp.
|
||||
- Alguns sites exigem cookies, login, proxy ou argumentos adicionais do yt-dlp, dependendo do extrator.
|
||||
- Se um site falhar, atualize o yt-dlp na aba de atualização integrada antes de relatar o problema.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Opções de Mídia e Download</summary>
|
||||
|
||||
- **Opções de Legendas:** Filtre idiomas e incorpore legendas no arquivo de vídeo.
|
||||
- **Mesclagem de Legendas:** Mescla legendas no arquivo de vídeo para legendas fixas (hardcoded).
|
||||
- **Salvar Descrição:** Salva a descrição do vídeo como um arquivo de texto.
|
||||
- **Salvar Miniatura:** Salva a miniatura do vídeo como um arquivo de imagem.
|
||||
- **Incorporar Capítulos:** Inclui marcadores de capítulo como metadados para players de vídeo compatíveis.
|
||||
- **Remover Seções de Patrocinadores:** Usa o SponsorBlock para remover segmentos patrocinados do vídeo.
|
||||
- **Cortar Vídeo:** Baixe apenas partes específicas do vídeo, especificando o intervalo de tempo no formato `HH:MM:SS`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Configurações de Saída e Arquivos</summary>
|
||||
|
||||
- **Limitador de Velocidade:** Limite a velocidade de download, por exemplo, `500K` para 500 KB/s.
|
||||
- **Salvar Caminho de Download:** Salva o caminho de download padrão para downloads futuros. Disponível em **Download Settings → Download Path**.
|
||||
- **Resolução de Vídeo Padrão:** Defina sua resolução de vídeo preferida para seleção automática (ex: 1080p, 720p). Disponível em **Download Settings → Default Video Resolution**.
|
||||
- **Idiomas de Legendas Padrão:** Defina idiomas de legendas padrão para seleção automática (separados por vírgula, ex: `pt,en`). Disponível em **Download Settings → Default Subtitle Languages**.
|
||||
- **Formato de Nome de Arquivo:** Personalize o formato do nome do arquivo de saída usando variáveis como `%(title)s`, `%(uploader)s`, `%(playlist_index)s` e `%(resolution)s`. Disponível em **Download Settings → Filename Format**.
|
||||
- **Forçar Formato de Saída:** Força o download do vídeo em um formato de contêiner específico, como `mp4`, `webm` ou `mkv`. Disponível em **Download Settings → Output Format Settings**.
|
||||
- **Conversão de Formato de Áudio:** Converta downloads de apenas áudio para formatos preferidos como `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, ou `Best`. Disponível em **Download Settings → Audio Format Settings**.
|
||||
- **Normalização de Áudio:** Padroniza o volume para downloads de apenas áudio usando EBU R128.
|
||||
- **Conexões Simultâneas:** Aumente significativamente a velocidade de download baixando arquivos em várias partes ao mesmo tempo. Disponível em **Download Settings → General → Concurrent Connections** (padrão 1, máximo 8-10 recomendado para evitar bloqueios de IP).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Acesso e Rede</summary>
|
||||
|
||||
- **Login por Cookies:** Faça login no YouTube usando cookies para acessar conteúdo privado.
|
||||
Uso:
|
||||
1. **Recomendado:** Use a opção integrada `Extract cookies from browser` no aplicativo, selecione o navegador e, opcionalmente, o perfil.
|
||||
2. Opcionalmente, extraia cookies manualmente:
|
||||
a. Exporte cookies do seu navegador usando uma extensão como [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Copie os cookies no formato Netscape
|
||||
c. Crie um arquivo chamado `cookies.txt` e cole os cookies
|
||||
d. Selecione o arquivo `cookies.txt` no aplicativo
|
||||
- **Suporte a Proxy:** Use um servidor proxy para downloads, ex: `http://<proxy-server>:<port>`
|
||||
- **Modo Genérico:** Permite que o YTSage analise e baixe de sites além do YouTube suportados pelo yt-dlp. Habilite em **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Ferramentas e Manutenção</summary>
|
||||
|
||||
- **Comandos Personalizados:** Acesse recursos avançados do yt-dlp via argumentos de linha de comando.
|
||||
- **Aba de Atualização:** Gerencie as ferramentas de atualização integradas em um só lugar nas Opções Personalizadas:
|
||||
- **Atualização do yt-dlp:** Verifique atualizações e alterne entre os canais de lançamento Stable e Nightly.
|
||||
- **Verificador de Versão do FFmpeg:** Verifique sua versão do FFmpeg e abra guias de instalação.
|
||||
- **Atualização do Deno:** Verifique e atualize o runtime do Deno.
|
||||
- **Detecção de FFmpeg/yt-dlp/Deno:** Detecta automaticamente caminhos e versões de FFmpeg, yt-dlp e Deno no diálogo Sobre.
|
||||
- **Histórico de Downloads:** Veja downloads anteriores com miniaturas e status no botão **History**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Localização</summary>
|
||||
|
||||
O YTSage suporta **14 idiomas** para alcance global. Escolha o seu idioma preferido em **Custom Options → Language**.
|
||||
|
||||
### Idiomas Suportados
|
||||
|
||||
| Idioma | Código | Idioma | Código |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inglês | `en` | 🇪🇸 Espanhol | `es` |
|
||||
| 🇸🇦 Árabe | `ar` | 🇫🇷 Francês | `fr` |
|
||||
| 🇩🇪 Alemão | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonésio | `id` | 🇮🇹 Italiano | `it` |
|
||||
| 🇯🇵 Japonês | `ja` | 🇵🇱 Polonês | `pl` |
|
||||
| 🇧🇷 Português | `pt` | 🇷🇺 Russo | `ru` |
|
||||
| 🇹🇷 Turco | `tr` | 🇨🇳 Chinês | `zh` |
|
||||
|
||||
### Traduções do README
|
||||
|
||||
| Idioma | Arquivo | Idioma | Arquivo |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Inglês | [README.md](../README.md) | 🇪🇸 Espanhol | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Árabe | [README.ar.md](README.ar.md) | 🇫🇷 Francês | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Alemão | [README.de.md](README.de.md) | 🇮🇳 Hindi | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Indonésio | [README.id.md](README.id.md) | 🇮🇹 Italiano | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Japonês | [README.ja.md](README.ja.md) | 🇵🇱 Polonês | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Português | [README.pt.md](README.pt.md) | 🇷🇺 Russo | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Turco | [README.tr.md](README.tr.md) | 🇨🇳 Chinês | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Quer ajudar na tradução?** Veja a seção [Contribuindo](#contribuindo) para nos ajudar a adicionar mais idiomas!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="solução-de-problemas"></a>
|
||||
## 🛠️ Solução de Problemas
|
||||
|
||||
<details>
|
||||
<summary>Clique para ver problemas comuns e soluções</summary>
|
||||
|
||||
- **Tabela de formatos não aparece:** Atualize o yt-dlp para a versão mais recente e tente alternar para o yt-dlp Nightly.
|
||||
- **Download falhou:** Verifique sua conexão com a internet e certifique-se de que o vídeo está disponível.
|
||||
- **Erros de Download Específicos:**
|
||||
- **Vídeos Privados:** Use a autenticação por cookies para acessar conteúdo privado.
|
||||
- **Conteúdo com Restrição de Idade:** Faça login na sua conta do YouTube para visualizar vídeos com restrição de idade.
|
||||
- **Vídeos com Bloqueio Geográfico:** Considere usar uma VPN para contornar restrições regionais.
|
||||
- **Vídeo Removido:** O vídeo não está mais disponível no YouTube.
|
||||
- **Live Streams:** Transmissões ao vivo não podem ser baixadas enquanto estão sendo transmitidas; aguarde até que a transmissão termine.
|
||||
- **Erros de Rede:** Verifique sua conexão com a internet e tente novamente.
|
||||
- **URL Inválida:** Certifique-se de que a URL está correta e pertence a uma plataforma suportada.
|
||||
- **Conteúdo Premium:** Requer uma assinatura do YouTube Premium.
|
||||
- **Bloqueio por Direitos Autorais:** O conteúdo está bloqueado devido a restrições de direitos autorais.
|
||||
- **Arquivos de vídeo e áudio estão separados após o download:** Isso acontece quando o FFmpeg está ausente ou não foi detectado. O YTSage requer o FFmpeg para mesclar streams de vídeo e áudio de alta qualidade.
|
||||
- **Solução:** Certifique-se de que o FFmpeg está instalado e acessível no PATH do seu sistema. Para usuários do Windows, a opção mais fácil é baixar o arquivo `YTSage-v<versão>-ffmpeg.exe`, que já vem com o FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Aviso do Windows Defender / Antivírus
|
||||
|
||||
Alguns softwares antivírus podem sinalizar arquivos `.exe` como falsos positivos. Esta é uma **limitação conhecida** de aplicativos empacotados.
|
||||
|
||||
**Por que isso acontece:**
|
||||
- As heurísticas do antivírus podem identificar incorretamente executáveis empacotados como suspeitos.
|
||||
|
||||
**Opções Seguras:**
|
||||
- ✅ **Use a instalação via pip:** `pip install ytsage` (recomendado)
|
||||
- ✅ **Compile a partir do código-fonte**: Seguindo este [guia](../.github/CI_CD_README.md)
|
||||
- ✅ **Adicione o aplicativo à lista de permissões** do seu software antivírus.
|
||||
|
||||
#### 🍎 macOS: "App está danificado e não pode ser aberto"
|
||||
Se você vir este erro no macOS Sonoma ou mais recente, você precisa remover o atributo de quarentena.
|
||||
|
||||
1. **Abra o Terminal** (você pode encontrá-lo usando o Spotlight).
|
||||
2. **Digite o seguinte comando**, mas ainda **NÃO** pressione Enter. Certifique-se de incluir o espaço no final:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Arraste o arquivo `YTSage.app` da janela do Finder** e solte-o diretamente na janela do Terminal. Isso colará automaticamente o caminho correto do arquivo.
|
||||
4. **Pressione Enter** para executar o comando.
|
||||
5. **Tente abrir o YTSage.app novamente.** Ele agora deve iniciar corretamente.
|
||||
|
||||
---
|
||||
|
||||
#### **Localização da Configuração (Avançado)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="patrocinar"></a>
|
||||
## 💖 Patrocinar
|
||||
|
||||
Se o YTSage economiza seu tempo, considere patrocinar o projeto. Os patrocínios ajudam a cobrir o tempo de desenvolvimento, testes em todas as plataformas e melhorias futuras.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- O link de patrocínio está disponível diretamente através do diálogo Sobre no aplicativo.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contribuindo"></a>
|
||||
## 👥 Contribuindo
|
||||
|
||||
Contribuições são bem-vindas! Veja como você pode ajudar:
|
||||
|
||||
1. 🍴 Faça um Fork do repositório
|
||||
2. 🌿 Crie sua branch de recurso:
|
||||
```bash
|
||||
git checkout -b feature/RecursoIncrivel
|
||||
```
|
||||
3. 💾 Faça o commit de suas alterações:
|
||||
```bash
|
||||
git commit -m 'Adicionar RecursoIncrivel'
|
||||
```
|
||||
4. 📤 Faça o push para a branch:
|
||||
```bash
|
||||
git push origin feature/RecursoIncrivel
|
||||
```
|
||||
5. 🔄 Abra um Pull Request
|
||||
|
||||
### 🌍 Contribua com Traduções
|
||||
|
||||
- Atualize o arquivo README localizado relevante (ex: `readme-translations/README.pt.md`)
|
||||
- Mantenha as strings do aplicativo em sincronia editando `ytsage/languages/<code>.json`
|
||||
- Se o seu idioma estiver ausente, comece pelo `README.md` e crie `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Estrutura do Projeto</summary>
|
||||
|
||||
## YTSage - Estrutura do Projeto
|
||||
|
||||
Este documento descreve a estrutura de pastas organizada do YTSage.
|
||||
|
||||
### 📁 Estrutura do Projeto
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # Configurações do GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Modelos de problemas
|
||||
│ │ └── 🐛-bug-report.md # Modelo de relatório de erro
|
||||
│ ├─── 📁 workflows/ # Fluxos de trabalho do GitHub Actions
|
||||
│ │ ├── build-linux.yml # Fluxo de build para Linux
|
||||
│ │ ├── build-macos.yml # Fluxo de build para macOS
|
||||
│ │ │── build-windows.yml # Fluxo de build para Windows
|
||||
| | └── release-all.yml # Fluxo de lançamento principal
|
||||
│ └── 📄 CI_CD_README.md # Documentação CI/CD
|
||||
├── 📁 branding/ # Ativos de marca (capturas de tela, SVGs)
|
||||
│ ├── 📁 icons/ # Ícones do aplicativo
|
||||
│ ├── 📁 screenshots/ # Capturas de tela para documentação
|
||||
│ └── 📁 svg/ # Ativos SVG
|
||||
├── 📄 LICENSE # Arquivo de licença
|
||||
├── 📄 pyproject.toml # Metadados do projeto e dependências
|
||||
├── 📄 README.md # Documentação do projeto
|
||||
├── 📄 requirements.txt # Dependências Python (dev)
|
||||
└── 📁 ytsage/ # Pacote de código-fonte
|
||||
├── 📁 assets/ # Ativos de tempo de execução
|
||||
│ ├── 📁 Icon/ # Ícones do aplicativo
|
||||
│ └── 📁 sound/ # Arquivos de áudio
|
||||
├── 📁 languages/ # Arquivos de localização
|
||||
│ ├── 📄 ar.json # Tradução em árabe
|
||||
│ ├── 📄 de.json # Tradução em alemão
|
||||
│ ├── 📄 en.json # Tradução em inglês
|
||||
│ └── ... # Outros idiomas
|
||||
├── 📁 core/ # Lógica de negócios principal
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Integração Deno
|
||||
│ ├── 📄 ytsage_downloader.py # Funcionalidade de download
|
||||
│ ├── 📄 ytsage_ffmpeg.py # Integração FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # Funções utilitárias
|
||||
│ └── 📄 ytsage_yt_dlp.py # Integração yt-dlp
|
||||
├── 📁 gui/ # Componentes de interface do usuário
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # Janela principal do aplicativo
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Classes de diálogo
|
||||
├── 📁 utils/ # Módulos utilitários
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # Gerenciamento de configuração
|
||||
│ └── 📄 ytsage_logger.py # Ferramentas de registro
|
||||
├── 📄 __init__.py # Ponto de entrada do pacote
|
||||
└── 📄 main.py # Script de execução principal
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Histórico de Estrelas
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Licença
|
||||
|
||||
Este projeto é licenciado sob a Licença MIT - consulte o arquivo [LICENSE](../LICENSE) para mais detalhes.
|
||||
|
||||
## 🙏 Agradecimentos
|
||||
|
||||
<details>
|
||||
<summary>Mostrar Agradecimentos</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Muito obrigado a todos que contribuíram para este projeto abrindo problemas para sugerir melhorias ou relatar erros.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Componentes Principais</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Mecanismo de Download</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Processamento de Mídia</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime para integração do yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Bibliotecas e Frameworks</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>Framework GUI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Processamento de Imagens</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>Requisições HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Gerenciamento de Versão e Empacotamento</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Renderização de Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Registro de Logs</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Ativos e Contribuidores</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 de Universfield</a></td>
|
||||
<td>Som de Notificação</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Contribuidor de Código</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Isenção de Responsabilidade
|
||||
|
||||
Esta ferramenta é apenas para uso pessoal. Respeite os Termos de Serviço do YouTube e os direitos dos produtores de conteúdo.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Criado com ❤️ por [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,613 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="Интерфейс YTSage"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Современный загрузчик с YouTube с чистым интерфейсом на PySide6.**
|
||||
Загружайте видео в любом качестве, извлекайте аудио, получайте субтитры и многое другое.
|
||||
|
||||
### 🌍 Языки README
|
||||
|
||||
Английский: [EN](../README.md)
|
||||
| Арабский: [AR](README.ar.md)
|
||||
| Немецкий: [DE](README.de.md)
|
||||
| Испанский: [ES](README.es.md)
|
||||
| Французский: [FR](README.fr.md)
|
||||
| Хинди: [HI](README.hi.md)
|
||||
| Индонезийский: [ID](README.id.md)
|
||||
| Итальянский: [IT](README.it.md)
|
||||
| Японский: [JA](README.ja.md)
|
||||
| Польский: [PL](README.pl.md)
|
||||
| Португальский: [PT](README.pt.md)
|
||||
| Русский: [RU](README.ru.md)
|
||||
| Турецкий: [TR](README.tr.md)
|
||||
| Китайский: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#установка">Установка</a> •
|
||||
<a href="#функции">Функции</a> •
|
||||
<a href="#использование">Использование</a> •
|
||||
<a href="#скриншоты">Скриншоты</a> •
|
||||
<a href="#решение-проблем">Решение проблем</a> •
|
||||
<a href="#спонсорство">Спонсорство</a> •
|
||||
<a href="#участие-в-проекте">Участие в проекте</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="почему-ytsage"></a>
|
||||
## ❓ Почему YTSage?
|
||||
|
||||
YTSage создан для пользователей, которым нужен **простой, но мощный загрузчик с YouTube**. В отличие от других инструментов, он предлагает:
|
||||
|
||||
- Современный и чистый интерфейс PySide6
|
||||
- Загрузку видео, аудио и субтитров одним щелчком мыши
|
||||
- Дополнительные функции, такие как SponsorBlock, объединение субтитров и выбор плейлиста
|
||||
- Опциональный "Общий режим" (Generic Mode) для сайтов помимо YouTube, поддерживаемых yt-dlp
|
||||
- Кроссплатформенную поддержку и простую установку
|
||||
|
||||
<a id="функции"></a>
|
||||
## ✨ Функции
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Основные функции | Продвинутые функции | Дополнительные возможности |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Таблица форматов | 🚫 Интеграция SponsorBlock | 🎞️ Отображение FPS/HDR |
|
||||
| 🎵 Извлечение аудио | 📝 Выбор и объединение субтитров | 🔄 Автообновление yt-dlp |
|
||||
| ✨ Простой пользовательский интерфейс | 💾 Сохранение описания и обложки | 🛠️ Обнаружение FFmpeg/yt-dlp/Deno |
|
||||
| 📋 Поддержка и выбор плейлиста | 🚀 Ограничение скорости | ⚙️ Пользовательские команды |
|
||||
| 📑 Интеграция разделов | ✂️ Обрезка видео | 🍪 Вход через Cookies |
|
||||
| 📜 История загрузок | 🔄 Выбор канала выпуска | 🌐 Поддержка прокси |
|
||||
| 🎚️ Конвертация аудиоформатов | 🎬 Настройки видеоформата | 🆙 Встроенная вкладка обновления |
|
||||
| 🌍 Общий режим | 🔊 Нормализация звука (EBU R128) | 🌍 Локализация на 14 языков |
|
||||
| 💾 Экспорт плейлиста | ⚙️ Качество и субтитры по умолчанию | |
|
||||
</div>
|
||||
|
||||
<a id="установка"></a>
|
||||
## 🚀 Установка
|
||||
|
||||
### ⚡ Быстрая установка (Рекомендуется)
|
||||
|
||||
Установите YTSage через PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Обновить существующую установку</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Затем запустите приложение:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Готовые исполняемые файлы (Executable)
|
||||
|
||||
> [👉 Скачать последний релиз](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Формат | Описание |
|
||||
|--------|-------------|
|
||||
|  | Стандартный установщик |
|
||||
|  | С включенным FFmpeg |
|
||||
|  | Портативная версия (не требует установки) |
|
||||
|  | Портативная с FFmpeg, сжатая (ZIP) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Шаги по установке</summary>
|
||||
|
||||
1. **EXE установщик (`.exe`)**: Дважды щелкните файл и следуйте инструкциям мастера установки.
|
||||
2. **Портативная версия (`.zip`)**: Распакуйте файл в нужное место и запустите `ytsage.exe`.
|
||||
3. **Встроенный FFmpeg**: Если в вашей системе не установлен FFmpeg, выбирайте версии со встроенным FFmpeg.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Формат | Описание |
|
||||
|--------|-------------|
|
||||
|  | Пакет Debian |
|
||||
|  | AppImage, Портативный |
|
||||
|  | Пакет RPM |
|
||||
|  | Пакет Flatpak |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Шаги по установке</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Если нужно исправить зависимости
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Следуйте инструкциям на Flathub или выполните:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Формат | Описание |
|
||||
|--------|-------------|
|
||||
|  | Приложение ZIP для Apple Silicon |
|
||||
|  | Установщик DMG для Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Шаги по установке</summary>
|
||||
|
||||
- **DMG установщик (`.dmg`)**: Дважды щелкните, чтобы смонтировать, и перетащите `YTSage.app` в папку Applications (Программы).
|
||||
- **Приложение в архиве (`.zip`)**: Распакуйте ZIP и переместите `YTSage.app` в папку Applications.
|
||||
|
||||
*Примечание: Если вы получили ошибку "App is damaged", см. раздел по macOS ниже.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Ручная установка из исходного кода</summary>
|
||||
|
||||
### 1. Клонировать репозиторий
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Установить зависимости
|
||||
|
||||
#### ⚡ С помощью uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Или через стандартный pip
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Запустить приложение
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="скриншоты"></a>
|
||||
## 📸 Скриншоты
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="Настройки загрузки" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Загрузка плейлиста" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Настройки загрузки</em></td>
|
||||
<td align="center"><em>Загрузка плейлиста</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Выбор аудиоформата" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Пользовательские опции" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Аудиоформат</em></td>
|
||||
<td align="center"><em>Пользовательские опции</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="использование"></a>
|
||||
## 📖 Использование
|
||||
|
||||
<details>
|
||||
<summary>🎯 Базовое использование</summary>
|
||||
|
||||
1. **Запустите YTSage**
|
||||
2. **Вставьте ссылку на YouTube** (или используйте кнопку "Paste URL")
|
||||
3. **Нажмите "Analyze"**
|
||||
4. **Выберите формат:**
|
||||
- `Video` для загрузки видео
|
||||
- `Audio Only` для извлечения аудио
|
||||
5. **Выберите опции:**
|
||||
- Включите субтитры и выберите язык
|
||||
- Включите объединение субтитров (Merge)
|
||||
- Сохранить обложку (Save Thumbnail)
|
||||
- Удалить спонсорские вставки (SponsorBlock)
|
||||
- Сохранить описание (Save Description)
|
||||
- Внедрить главы (Embed Chapters)
|
||||
6. **Выберите папку для сохранения**
|
||||
7. **Нажмите "Download"**
|
||||
|
||||
> 💡 Папка загрузок по умолчанию — папка "Загрузки" текущего пользователя.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Загрузка плейлиста</summary>
|
||||
|
||||
1. **Вставьте ссылку на плейлист**
|
||||
2. **Нажмите "Analyze"**
|
||||
3. **Выберите видео в селекторе (по умолчанию выбраны все)**
|
||||
4. **Выберите нужный формат/качество**
|
||||
5. **Нажмите "Download"**
|
||||
|
||||
> 💡 Приложение автоматически управляет очередью загрузки, и вы можете экспортировать записи плейлиста в файлы `.txt`, `.csv`, `.m3u` или `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Общий режим для сайтов помимо YouTube</summary>
|
||||
|
||||
Используйте Общий режим (Generic Mode), когда хотите, чтобы YTSage принимал ссылки с других сайтов, поддерживаемых yt-dlp, таких как Dailymotion, TikTok и другие.
|
||||
|
||||
Как использовать:
|
||||
|
||||
1. Откройте `Download Settings`.
|
||||
2. Включите `Generic Mode`.
|
||||
3. Вставьте ссылку на видео или плейлист с поддерживаемого сайта (не YouTube).
|
||||
4. Нажмите `Analyze`.
|
||||
5. Выберите формат и скачайте как обычно.
|
||||
|
||||
Примечания:
|
||||
|
||||
- Общий режим только отключает строгую проверку URL внутри YTSage. Сайт все равно должен поддерживаться установленной версией yt-dlp.
|
||||
- Некоторым сайтам требуются cookies, вход, прокси или дополнительные аргументы yt-dlp.
|
||||
- Если сайт перестал работать, обновите yt-dlp во встроенной вкладке обновления, прежде чем сообщать о проблеме.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Опции медиа и загрузки</summary>
|
||||
|
||||
- **Опции субтитров:** Фильтруйте языки и внедряйте субтитры в видеофайл.
|
||||
- **Объединение субтитров:** "Вшивает" субтитры в видеофайл (hardcode).
|
||||
- **Сохранить описание:** Сохраняет описание видео в текстовый файл.
|
||||
- **Сохранить обложку:** Сохраняет превью видео как изображение.
|
||||
- **Внедрить главы:** Добавляет маркеры глав в метаданные для совместимых медиаплееров.
|
||||
- **Удалить спонсорские вставки:** Использует SponsorBlock для удаления рекламных сегментов из видео.
|
||||
- **Обрезать видео:** Загружайте только части видео, указав временной интервал в формате `HH:MM:SS`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Настройки вывода и файлов</summary>
|
||||
|
||||
- **Ограничение скорости:** Ограничьте скорость загрузки, например, `500K` для 500 КБ/с.
|
||||
- **Путь загрузки:** Сохраните путь по умолчанию для будущих загрузок. Доступно в **Download Settings → Download Path**.
|
||||
- **Разрешение по умолчанию:** Установите предпочитаемое разрешение для автовыбора (например, 1080p, 720p). Доступно в **Download Settings → Default Video Resolution**.
|
||||
- **Языки субтитров по умолчанию:** Установите языки по умолчанию для автовыбора (через запятую, например, `ru,en`). Доступно в **Download Settings → Default Subtitle Languages**.
|
||||
- **Формат имени файла:** Настройте формат имени выходного файла, используя переменные типа `%(title)s`, `%(uploader)s` и др. Доступно в **Download Settings → Filename Format**.
|
||||
- **Принудительный формат вывода:** Принудительно скачивайте видео в определенном контейнере, например `mp4`, `webm` или `mkv`. Доступно в **Download Settings → Output Format Settings**.
|
||||
- **Конвертация аудио:** Конвертируйте аудио в форматы `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, или `Best`. Доступно в **Download Settings → Audio Format Settings**.
|
||||
- **Нормализация звука:** Выравнивает громкость аудио загрузок согласно EBU R128.
|
||||
- **Одновременные соединения:** Значительно увеличивает скорость за счет загрузки в несколько потоков. Доступно в **Download Settings → General → Concurrent Connections** (рекомендуется 8-10).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Доступ и сеть</summary>
|
||||
|
||||
- **Вход через Cookies:** Войдите в YouTube через куки для доступа к приватному контенту.
|
||||
Как использовать:
|
||||
1. **Рекомендуется:** Используйте встроенную опцию `Extract cookies from browser`, выберите браузер и профиль.
|
||||
2. Или вручную:
|
||||
a. Экспортируйте куки через расширение [cookie-editor](https://github.com/moustachauve/cookie-editor).
|
||||
b. Скопируйте куки в формате Netscape.
|
||||
c. Создайте файл `cookies.txt` и вставьте их туда.
|
||||
d. Выберите этот файл в приложении.
|
||||
- **Поддержка прокси:** Используйте прокси-сервер для загрузок, например: `http://<proxy-server>:<port>`
|
||||
- **Общий режим:** Позволяет скачивать с сайтов помимо YouTube. Включите в **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Инструменты и обслуживание</summary>
|
||||
|
||||
- **Пользовательские команды:** Доступ к продвинутым функциям yt-dlp через аргументы командной строки.
|
||||
- **Вкладка обновления:** Управляйте инструментами в одном месте (Custom Options):
|
||||
- **Обновление yt-dlp:** Проверка обновлений, переключение между Stable и Nightly ветками.
|
||||
- **Версия FFmpeg:** Проверка версии и инструкции по установке.
|
||||
- **Обновление Deno:** Проверка и обновление Deno.
|
||||
- **Обнаружение FFmpeg/yt-dlp/Deno:** Автоматически находит пути и версии в окне "About".
|
||||
- **История загрузок:** Просматривайте историю с обложками и статусом через кнопку **History**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Локализация</summary>
|
||||
|
||||
YTSage поддерживает **14 языков**. Выберите нужный в **Custom Options → Language**.
|
||||
|
||||
### Поддерживаемые языки
|
||||
|
||||
| Язык | Код | Язык | Код |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Английский | `en` | 🇪🇸 Испанский | `es` |
|
||||
| 🇸🇦 Арабский | `ar` | 🇫🇷 Французский | `fr` |
|
||||
| 🇩🇪 Немецкий | `de` | 🇮🇳 Хинди | `hi` |
|
||||
| 🇮🇩 Индонезийский | `id` | 🇮🇹 Итальянский | `it` |
|
||||
| 🇯🇵 Японский | `ja` | 🇵🇱 Польский | `pl` |
|
||||
| 🇧🇷 Португальский | `pt` | 🇷🇺 Русский | `ru` |
|
||||
| 🇹🇷 Турецкий | `tr` | 🇨🇳 Китайский | `zh` |
|
||||
|
||||
### Переводы README
|
||||
|
||||
| Язык | Файл | Язык | Файл |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 Английский | [README.md](../README.md) | 🇪🇸 Испанский | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Арабский | [README.ar.md](README.ar.md) | 🇫🇷 Французский | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Немецкий | [README.de.md](README.de.md) | 🇮🇳 Хинди | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Индонезийский | [README.id.md](README.id.md) | 🇮🇹 Итальянский | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Японский | [README.ja.md](README.ja.md) | 🇵🇱 Польский | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Португальский | [README.pt.md](README.pt.md) | 🇷🇺 Русский | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Турецкий | [README.tr.md](README.tr.md) | 🇨🇳 Китайский | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Хотите помочь с переводом?** Ознакомьтесь с разделом [Участие в проекте](#участие-в-проекте), чтобы помочь нам добавить новые языки!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="решение-проблем"></a>
|
||||
## 🛠️ Решение проблем
|
||||
|
||||
<details>
|
||||
<summary>Нажмите, чтобы увидеть частые вопросы</summary>
|
||||
|
||||
- **Не появляется таблица форматов:** Обновите yt-dlp до последней версии и попробуйте переключиться на канал Nightly.
|
||||
- **Загрузка не удалась:** Проверьте интернет-соединение и доступность видео.
|
||||
- **Специфические ошибки:**
|
||||
- **Приватные видео:** Используйте вход через Cookies.
|
||||
- **Ограничение по возрасту:** Войдите в аккаунт YouTube.
|
||||
- **Геоблокировка:** Используйте VPN.
|
||||
- **Видео удалено:** Видео больше недоступно на YouTube.
|
||||
- **Прямые трансляции:** Стримы нельзя скачать, пока они идут; дождитесь завершения.
|
||||
- **Сетевые ошибки:** Проверьте подключение к интернету и повторите попытку.
|
||||
- **Неверный URL:** Убедитесь, что URL указан правильно и принадлежит поддерживаемой платформе.
|
||||
- **Премиум-контент:** Требуется подписка YouTube Premium.
|
||||
- **Блокировка авторских прав:** Контент заблокирован из-за ограничений авторского права.
|
||||
- **Видео и аудио разделены после загрузки:** Это происходит, если FFmpeg отсутствует или не обнаружен. YTSage нужен FFmpeg для объединения потоков.
|
||||
- **Решение:** Убедитесь, что FFmpeg установлен и добавлен в PATH. Пользователям Windows проще всего скачать версию с `-ffmpeg.exe`.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Предупреждение Windows Defender / Антивируса
|
||||
|
||||
Некоторые антивирусы могут помечать `.exe` файлы как ложные срабатывания. Это **известная особенность** упакованных приложений.
|
||||
|
||||
**Причины:**
|
||||
- Эвристические алгоритмы антивирусов могут ошибочно идентифицировать упакованные исполняемые файлы как подозрительные.
|
||||
|
||||
**Безопасные варианты:**
|
||||
- ✅ **Установка через pip:** `pip install ytsage` (рекомендуется)
|
||||
- ✅ **Сборка из исходников**: Инструкция [здесь](../.github/CI_CD_README.md)
|
||||
- ✅ **Добавить в исключения** антивируса.
|
||||
|
||||
#### 🍎 macOS: "App is damaged and cannot be opened"
|
||||
Если вы видите эту ошибку на macOS Sonoma или новее:
|
||||
|
||||
1. **Откройте Терминал**.
|
||||
2. **Введите команду** (с пробелом в конце):
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Перетащите `YTSage.app`** из Finder в окно Терминала.
|
||||
4. **Нажмите Enter**.
|
||||
5. Попробуйте открыть программу снова.
|
||||
|
||||
---
|
||||
|
||||
#### **Пути конфигурации (Продвинутым)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="спонсорство"></a>
|
||||
## 💖 Спонсорство
|
||||
|
||||
Если YTSage экономит ваше время, поддержите разработку проекта. Спонсорство помогает поддерживать тестирование на всех платформах и внедрять новые функции.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Ссылка на спонсорство также есть в окне "About" в приложении.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="участие-в-проекте"></a>
|
||||
## 👥 Участие в проекте
|
||||
|
||||
Мы рады любой помощи!
|
||||
|
||||
1. 🍴 Сделайте Fork репозитория
|
||||
2. 🌿 Создайте ветку: `git checkout -b feature/NewFeature`
|
||||
3. 💾 Сделайте коммит: `git commit -m 'Add NewFeature'`
|
||||
4. 📤 Отправите изменения: `git push origin feature/NewFeature`
|
||||
5. 🔄 Создайте Pull Request
|
||||
|
||||
### 🌍 Помощь с переводами
|
||||
|
||||
- Обновите файл README (например, `readme-translations/README.ru.md`)
|
||||
- Обновите строки интерфейса в `ytsage/languages/<code>.json`
|
||||
- Если ваш язык отсутствует, начните с `README.md` и создайте `README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Структура проекта</summary>
|
||||
|
||||
## YTSage - Структура проекта
|
||||
|
||||
Этот документ описывает организованную структуру папок YTSage.
|
||||
|
||||
### 📁 Схема проекта
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # Настройки GitHub
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Шаблоны проблем
|
||||
│ │ └── 🐛-bug-report.md # Шаблон отчета об ошибке
|
||||
│ ├─── 📁 workflows/ # Рабочие процессы GitHub Actions
|
||||
│ │ ├── build-linux.yml # Сборка для Linux
|
||||
│ │ ├── build-macos.yml # Сборка для macOS
|
||||
│ │ │── build-windows.yml # Сборка для Windows
|
||||
| | └── release-all.yml # Основной процесс выпуска
|
||||
│ └── 📄 CI_CD_README.md # Документация CI/CD
|
||||
├── 📁 branding/ # Брендинг (скриншоты, SVG)
|
||||
│ ├── 📁 icons/ # Иконки приложения
|
||||
│ ├── 📁 screenshots/ # Скриншоты для документации
|
||||
│ └── 📁 svg/ # SVG ассеты
|
||||
├── 📄 LICENSE # Файл лицензии
|
||||
├── 📄 pyproject.toml # Метаданные проекта и зависимости
|
||||
├── 📄 README.md # Документация проекта
|
||||
├── 📄 requirements.txt # Зависимости Python (разработка)
|
||||
└── 📁 ytsage/ # Исходный код
|
||||
├── 📁 assets/ # Ресурсы времени выполнения
|
||||
│ ├── 📁 Icon/ # Иконки приложения
|
||||
│ └── 📁 sound/ # Звуковые файлы
|
||||
├── 📁 languages/ # Файлы локализации
|
||||
│ ├── 📄 ar.json # Перевод на арабский
|
||||
│ ├── 📄 de.json # Перевод на немецкий
|
||||
│ ├── 📄 en.json # Перевод на английский
|
||||
│ └── ... # Другие языки
|
||||
├── 📁 core/ # Основная бизнес-логика
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Интеграция Deno
|
||||
│ ├── 📄 ytsage_downloader.py # Функционал загрузки
|
||||
│ ├── 📄 ytsage_ffmpeg.py # Интеграция FFmpeg
|
||||
│ ├── 📄 ytsage_utils.py # Вспомогательные функции
|
||||
│ └── 📄 ytsage_yt_dlp.py # Интеграция yt-dlp
|
||||
├── 📁 gui/ # Компоненты интерфейса
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # Главное окно приложения
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Классы диалогов
|
||||
├── 📁 utils/ # Утилиты
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # Управление конфигурацией
|
||||
│ └── 📄 ytsage_logger.py # Логирование
|
||||
├── 📄 __init__.py # Точка входа в пакет
|
||||
└── 📄 main.py # Основной скрипт запуска
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ История звезд
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Лицензия
|
||||
|
||||
Проект распространяется под лицензией MIT — подробности в файле [LICENSE](../LICENSE).
|
||||
|
||||
## 🙏 Благодарности
|
||||
|
||||
<details>
|
||||
<summary>Показать благодарности</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>Большое спасибо всем, кто внес свой вклад в этот проект, открывая проблемы с предложениями по улучшению или отчетами об ошибках.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Основные компоненты</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Движок загрузки</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Обработка медиа</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Среда для интеграции yt-dlp</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Библиотеки и фреймворки</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI фреймворк</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Обработка изображений</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>HTTP запросы</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Управление версиями</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Рендеринг Markdown</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Логирование</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Контент и участники</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 от Universfield</a></td>
|
||||
<td>Звук уведомления</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Участник разработки</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Отказ от ответственности
|
||||
|
||||
Этот инструмент предназначен только для личного использования. Пожалуйста, соблюдайте Условия использования YouTube и права создателей контента.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
Сделано с ❤️ от [oop7](https://github.com/oop7)
|
||||
</div>
|
||||
@@ -0,0 +1,624 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage Arayüzü"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Temiz bir PySide6 arayüzüne sahip modern bir YouTube indiricisi.**
|
||||
Videoları herhangi bir kalitede indirin, sesleri çıkarın, altyazıları alın ve daha fazlasını yapın.
|
||||
|
||||
### 🌍 README Dilleri
|
||||
|
||||
İngilizce: [EN](../README.md)
|
||||
| Arapça: [AR](README.ar.md)
|
||||
| Almanca: [DE](README.de.md)
|
||||
| İspanyolca: [ES](README.es.md)
|
||||
| Fransızca: [FR](README.fr.md)
|
||||
| Hintçe: [HI](README.hi.md)
|
||||
| Endonezce: [ID](README.id.md)
|
||||
| İtalyanca: [IT](README.it.md)
|
||||
| Japonca: [JA](README.ja.md)
|
||||
| Lehçe: [PL](README.pl.md)
|
||||
| Portekizce: [PT](README.pt.md)
|
||||
| Rusça: [RU](README.ru.md)
|
||||
| Türkçe: [TR](README.tr.md)
|
||||
| Çince: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#kurulum">Kurulum</a> •
|
||||
<a href="#özellikler">Özellikler</a> •
|
||||
<a href="#kullanım">Kullanım</a> •
|
||||
<a href="#ekran-görüntüleri">Ekran Görüntüleri</a> •
|
||||
<a href="#sorun-giderme">Sorun Giderme</a> •
|
||||
<a href="#sponsor-olun">Sponsor Olun</a> •
|
||||
<a href="#katkıda-bulunma">Katkıda Bulunma</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="neden-ytsage"></a>
|
||||
## ❓ Neden YTSage?
|
||||
|
||||
YTSage, **basit ama güçlü bir YouTube indiricisi** isteyen kullanıcılar için tasarlanmıştır. Diğer araçların aksine şunları sunar:
|
||||
|
||||
- Modern ve temiz bir PySide6 arayüzü
|
||||
- Tek tıkla video, ses ve altyazı indirme
|
||||
- SponsorBlock, altyazı birleştirme ve oynatma listesi seçimi gibi gelişmiş özellikler
|
||||
- yt-dlp tarafından desteklenen YouTube dışındaki siteler için isteğe bağlı "Genel Mod"
|
||||
- Çoklu platform desteği ve kolay kurulum
|
||||
|
||||
<a id="özellikler"></a>
|
||||
## ✨ Özellikler
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Temel Özellikler | Gelişmiş Özellikler | Ekstra Özellikler |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Format Tablosu | 🚫 SponsorBlock Entegrasyonu | 🎞️ FPS/HDR Gösterimi |
|
||||
| 🎵 Ses Çıkarma | 📝 Altyazı Seçimi ve Birleştirme | 🔄 Otomatik yt-dlp Güncellemesi |
|
||||
| ✨ Basit Kullanıcı Arayüzü | 💾 Açıklama ve Küçük Resim Kaydetme | 🛠️ FFmpeg/yt-dlp/Deno Algılama |
|
||||
| 📋 Oynatma Listesi Desteği | 🚀 Hız Sınırlayıcı | ⚙️ Özel Komutlar |
|
||||
| 📑 Bölüm Entegrasyonu | ✂️ Video Kırpma | 🍪 Çerez ile Giriş |
|
||||
| 📜 İndirme Geçmişi | 🔄 Yayın Kanalı Seçimi | 🌐 Proxy Desteği |
|
||||
| 🎚️ Ses Formatı Dönüştürme | 🎬 Video Format Ayarları | 🆙 Entegre Güncelleme Sekmesi |
|
||||
| 🌍 Genel Mod | 🔊 Ses Normalizasyonu (EBU R128) | 🌍 14 Dilde Yerelleştirme |
|
||||
| 💾 Oynatma Listesi Dışa Aktarma | ⚙️ Varsayılan Kalite ve Altyazı | |
|
||||
</div>
|
||||
|
||||
<a id="kurulum"></a>
|
||||
## 🚀 Kurulum
|
||||
|
||||
### ⚡ Hızlı Kurulum (Önerilen)
|
||||
|
||||
YTSage'i PyPI üzerinden yükleyin:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Mevcut Kurulumu Güncelle</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Ardından uygulamayı çalıştırın:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Hazır Çalıştırılabilir Dosyalar (Executable)
|
||||
|
||||
> [👉 En Son Sürümü İndir](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Format | Açıklama |
|
||||
|--------|-------------|
|
||||
|  | Standart Kurulum Dosyası |
|
||||
|  | FFmpeg Dahil |
|
||||
|  | Taşınabilir Sürüm, kuruluma gerek yok |
|
||||
|  | FFmpeg Dahil Taşınabilir, sıkıştırılmış (ZIP) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Kurulum Adımları</summary>
|
||||
|
||||
1. **EXE Yükleyici (`.exe`)**: Dosyaya çift tıklayın ve kurulum sihirbazını takip edin.
|
||||
2. **Taşınabilir Sürüm (`.zip`)**: Dosyayı istediğiniz yere çıkarın ve `ytsage.exe` dosyasını çalıştırın.
|
||||
3. **Dahili FFmpeg**: Sisteminizde FFmpeg kurulu değilse, FFmpeg dahil olan sürümleri seçin.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Format | Açıklama |
|
||||
|--------|-------------|
|
||||
|  | Debian Paketi |
|
||||
|  | AppImage, Taşınabilir |
|
||||
|  | RPM Paketi |
|
||||
|  | Flatpak Paketi |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Kurulum Adımları</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Gerekirse eksik bağımlılıkları gidermek için
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Flathub üzerindeki talimatları izleyin veya şunu çalıştırın:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Format | Açıklama |
|
||||
|--------|-------------|
|
||||
|  | Apple Silicon için ZIP Uygulaması |
|
||||
|  | Apple Silicon için Disk Image Kurulum Dosyası |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Kurulum Adımları</summary>
|
||||
|
||||
- **DMG Yükleyici (`.dmg`)**: Bağlamak için çift tıklayın ve `YTSage.app` dosyasını Uygulamalar klasörünüze sürükleyin.
|
||||
- **Uygulama Arşivi (`.zip`)**: ZIP dosyasını çıkarın ve `YTSage.app` dosyasını Uygulamalar klasörünüze taşıyın.
|
||||
|
||||
*Not: "Uygulama hasarlı" hatası alırsanız, aşağıdaki macOS Sorun Giderme bölümüne bakın.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Kaynak Kodundan Manuel Kurulum</summary>
|
||||
|
||||
### 1. Depoyu Klonlayın
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Bağımlılıkları Yükleyin
|
||||
|
||||
#### ⚡ uv ile
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Veya standart pip ile
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Uygulamayı Çalıştırın
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="ekran-görüntüleri"></a>
|
||||
## 📸 Ekran Görüntüleri
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="İndirme Ayarları" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="Oynatma Listesi İndirme" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>İndirme Ayarları</em></td>
|
||||
<td align="center"><em>Oynatma Listesi İndirme</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="Ses Formatı Seçimi" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="Özel Seçenekler" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Ses Formatı</em></td>
|
||||
<td align="center"><em>Özel Seçenekler</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="kullanım"></a>
|
||||
## 📖 Kullanım
|
||||
|
||||
<details>
|
||||
<summary>🎯 Temel Kullanım</summary>
|
||||
|
||||
1. **YTSage'i başlatın**
|
||||
2. **Bir YouTube URL'si yapıştırın** (veya "Paste URL" düğmesini kullanın)
|
||||
3. **"Analyze" düğmesine tıklayın**
|
||||
4. **Formatı Seçin:**
|
||||
- Video indirmek için `Video`
|
||||
- Sadece ses çıkarmak için `Audio Only`
|
||||
5. **Seçenekleri Belirleyin:**
|
||||
- Altyazıları etkinleştirin ve dili seçin
|
||||
- Altyazı birleştirmeyi (Merge subs) etkinleştirin
|
||||
- Küçük resmi kaydet (Save thumbnail)
|
||||
- Sponsor bölümlerini kaldır (SponsorBlock)
|
||||
- Açıklamayı kaydet (Save description)
|
||||
- Bölümleri göm (Embed chapters)
|
||||
6. **Çıkış Dizinini Seçin**
|
||||
7. **"Download" düğmesine tıklayın**
|
||||
|
||||
> 💡 Varsayılan indirme dizini kullanıcının "İndirmeler" klasörüdür.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Oynatma Listesi İndirme</summary>
|
||||
|
||||
1. **Oynatma Listesi URL'sini yapıştırın**
|
||||
2. **"Analyze" düğmesine tıklayın**
|
||||
3. **Seçiciden videoları seçin (varsayılan olarak tümü seçilidir)**
|
||||
4. **İstediğiniz formatı/kaliteyi seçin**
|
||||
5. **"Download" düğmesine tıklayın**
|
||||
|
||||
> 💡 Uygulama indirme kuyruğunu otomatik olarak yönetir ve oynatma listesi girişlerini `.txt`, `.csv`, `.m3u` veya `.json` dosyaları olarak dışa aktarabilirsiniz.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 YouTube Dışındaki Siteler İçin Genel Mod</summary>
|
||||
|
||||
YTSage'in Dailymotion, TikTok ve diğerleri gibi yt-dlp tarafından desteklenen sitelerden gelen URL'leri kabul etmesini istediğinizde Genel Modu kullanın.
|
||||
|
||||
Nasıl kullanılır:
|
||||
|
||||
1. `Download Settings` bölümünü açın.
|
||||
2. `Generic Mode` seçeneğini etkinleştirin.
|
||||
3. YouTube dışındaki desteklenen bir video veya oynatma listesi URL'sini yapıştırın.
|
||||
4. `Analyze` düğmesine tıklayın.
|
||||
5. Bir format seçin ve normal şekilde indirin.
|
||||
|
||||
Notlar:
|
||||
|
||||
- Genel Mod sadece YTSage içindeki URL doğrulamasını değiştirir. Hedef site hala yüklü yt-dlp sürümünüz tarafından desteklenmelidir.
|
||||
- Bazı siteler, çıkarıcıya bağlı olarak çerezler, giriş, proxy veya ek yt-dlp argümanları gerektirebilir.
|
||||
- Bir site hata verirse, sorun bildirmeden önce entegre güncelleme sekmesinden yt-dlp'yi güncelleyin.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Medya ve İndirme Seçenekleri</summary>
|
||||
|
||||
- **Altyazı Seçenekleri:** Dilleri filtreleyin ve altyazıları video dosyasına gömün.
|
||||
- **Altyazı Birleştirme:** Altyazıları video dosyasına kalıcı olarak (hardcode) birleştirir.
|
||||
- **Açıklamayı Kaydet:** Video açıklamasını bir metin dosyası olarak kaydeder.
|
||||
- **Küçük Resmi Kaydet:** Video küçük resmini bir resim dosyası olarak kaydeder.
|
||||
- **Bölümleri Göm:** Uyumlu video oynatıcılar için meta veri olarak bölüm işaretlerini ekler.
|
||||
- **Sponsor Bölümlerini Kaldır:** Videodaki sponsorlu bölümleri kaldırmak için SponsorBlock kullanır.
|
||||
- **Videoyu Kırp:** Zaman aralığını `SA:DA:SA` formatında belirterek videonun sadece belirli bölümlerini indirin.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Çıkış ve Dosya Ayarları</summary>
|
||||
|
||||
- **Hız Sınırlayıcı:** İndirme hızını sınırlandırın, örneğin 500 KB/s için `500K`.
|
||||
- **İndirme Yolunu Kaydet:** Gelecekteki indirmeler için varsayılan indirme yolunu kaydeder. **Download Settings → Download Path** bölümünde mevcuttur.
|
||||
- **Varsayılan Video Çözünürlüğü:** Otomatik seçim için tercih ettiğiniz video çözünürlüğünü ayarlayın (örn: 1080p, 720p). **Download Settings → Default Video Resolution** bölümünde mevcuttur.
|
||||
- **Varsayılan Altyazı Dilleri:** Otomatik seçim için varsayılan altyazı dillerini ayarlayın (virgülle ayrılmış, örn: `tr,en`). **Download Settings → Default Subtitle Languages** bölümünde mevcuttur.
|
||||
- **Dosya Adı Formatı:** Çıkış dosyası adı formatını `%(title)s`, `%(uploader)s` gibi değişkenler kullanarak özelleştirin. **Download Settings → Filename Format** bölümünde mevcuttur.
|
||||
- **Çıkış Formatını Zorla:** Videoyu `mp4`, `webm` veya `mkv` gibi belirli bir konteyner formatında indirmeye zorlar. **Download Settings → Output Format Settings** bölümünde mevcuttur.
|
||||
- **Ses Formatı Dönüştürme:** Sadece ses indirmelerini `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis` veya `Best` gibi tercih edilen formatlara dönüştürün. **Download Settings → Audio Format Settings** bölümünde mevcuttur.
|
||||
- **Ses Normalizasyonu:** EBU R128 kullanarak sadece ses indirmeleri için ses seviyesini standartlaştırır.
|
||||
- **Eşzamanlı Bağlantılar:** Dosyaları aynı anda birden fazla parça halinde indirerek indirme hızını önemli ölçüde artırın. **Download Settings → General → Concurrent Connections** bölümünde mevcuttur (Varsayılan 1, IP engellemelerini önlemek için maksimum 8-10 önerilir).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Erişim ve Ağ</summary>
|
||||
|
||||
- **Çerez ile Giriş:** Özel içeriğe erişmek için çerezleri kullanarak YouTube'da oturum açın.
|
||||
Kullanım:
|
||||
1. **Önerilen:** Uygulamadaki entegre `Extract cookies from browser` seçeneğini kullanın, tarayıcıyı ve isteğe bağlı olarak profili seçin.
|
||||
2. İsteğe bağlı olarak çerezleri manuel olarak çıkarın:
|
||||
a. [cookie-editor](https://github.com/moustachauve/cookie-editor) gibi bir uzantı kullanarak tarayıcınızdan çerezleri dışa aktarın.
|
||||
b. Çerezleri Netscape formatında kopyalayın.
|
||||
c. `cookies.txt` adlı bir dosya oluşturun ve çerezleri yapıştırın.
|
||||
d. Uygulamada `cookies.txt` dosyasını seçin.
|
||||
- **Proxy Desteği:** İndirmeler için bir proxy sunucusu kullanın, örn: `http://<proxy-server>:<port>`
|
||||
- **Genel Mod:** YTSage'in yt-dlp tarafından desteklenen YouTube dışındaki siteleri analiz etmesine ve indirmesine olanak tanır. **Download Settings → Generic Mode** bölümünden etkinleştirin.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Araçlar ve Bakım</summary>
|
||||
|
||||
- **Özel Komutlar:** Komut satırı argümanları aracılığıyla gelişmiş yt-dlp özelliklerine erişin.
|
||||
- **Güncelleme Sekmesi:** Entegre güncelleme araçlarını Özel Seçenekler altındaki tek bir yerden yönetin:
|
||||
- **yt-dlp Güncelleme:** Güncellemeleri kontrol edin ve Stable ile Nightly yayın kanalları arasında geçiş yapın.
|
||||
- **FFmpeg Sürüm Kontrolü:** FFmpeg sürümünüzü kontrol edin ve kurulum kılavuzlarını açın.
|
||||
- **Deno Güncelleme:** Deno çalışma zamanını kontrol edin ve güncelleyin.
|
||||
- **FFmpeg/yt-dlp/Deno Algılama:** Hakkında diyaloğunda FFmpeg, yt-dlp ve Deno yollarını ve sürümlerini otomatik olarak algılar.
|
||||
- **İndirme Geçmişi:** **History** düğmesi aracılığıyla küçük resimler ve durumlarla birlikte geçmiş indirmeleri görün.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Yerelleştirme</summary>
|
||||
|
||||
YTSage, küresel erişim için **14 dili** destekler. Tercih ettiğiniz dili **Custom Options → Language** bölümünden seçin.
|
||||
|
||||
### Desteklenen Diller
|
||||
|
||||
| Dil | Kod | Dil | Kod |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 İngilizce | `en` | 🇪🇸 İspanyolca | `es` |
|
||||
| 🇸🇦 Arapça | `ar` | 🇫🇷 Fransızca | `fr` |
|
||||
| 🇩🇪 Almanca | `de` | 🇮🇳 Hintçe | `hi` |
|
||||
| 🇮🇩 Endonezce | `id` | 🇮🇹 İtalyanca | `it` |
|
||||
| 🇯🇵 Japonca | `ja` | 🇵🇱 Lehçe | `pl` |
|
||||
| 🇧🇷 Portekizce | `pt` | 🇷🇺 Rusça | `ru` |
|
||||
| 🇹🇷 Türkçe | `tr` | 🇨🇳 Çince | `zh` |
|
||||
|
||||
### README Çevirileri
|
||||
|
||||
| Dil | Dosya | Dil | Dosya |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 İngilizce | [README.md](../README.md) | 🇪🇸 İspanyolca | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 Arapça | [README.ar.md](README.ar.md) | 🇫🇷 Fransızca | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 Almanca | [README.de.md](README.de.md) | 🇮🇳 Hintçe | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 Endonezce | [README.id.md](README.id.md) | 🇮🇹 İtalyanca | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 Japonca | [README.ja.md](README.ja.md) | 🇵🇱 Lehçe | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 Portekizce | [README.pt.md](README.pt.md) | 🇷🇺 Rusça | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 Türkçe | [README.tr.md](README.tr.md) | 🇨🇳 Çince | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **Çeviriye yardımcı olmak ister misiniz?** Daha fazla dil eklememize yardımcı olmak için [Katkıda Bulunma](#katkıda-bulunma) bölümüne bakın!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sorun-giderme"></a>
|
||||
## 🛠️ Sorun Giderme
|
||||
|
||||
<details>
|
||||
<summary>Yaygın sorunlar ve çözümler için tıklayın</summary>
|
||||
|
||||
- **Format tablosu görünmüyor:** yt-dlp'yi en son sürüme güncelleyin ve yt-dlp Nightly kanalına geçmeyi deneyin.
|
||||
- **İndirme başarısız oldu:** İnternet bağlantınızı kontrol edin ve videonun erişilebilir olduğundan emin olun.
|
||||
- **Belirli İndirme Hataları:**
|
||||
- **Özel Videolar:** Özel içeriğe erişmek için çerez kimlik doğrulamasını kullanın.
|
||||
- **Yaş Sınırlı İçerik:** Yaş sınırlı videoları görüntülemek için YouTube hesabınızda oturum açın.
|
||||
- **Coğrafi Engelli Videolar:** Bölgesel kısıtlamaları aşmak için bir VPN kullanmayı düşünün.
|
||||
- **Video Kaldırıldı:** Video artık YouTube'da mevcut değildir.
|
||||
- **Canlı Yayınlar:** Canlı yayınlar yayınlanırken indirilemez; yayın bitene kadar bekleyin.
|
||||
- **Ağ Hataları:** İnternet bağlantınızı kontrol edin ve tekrar deneyin.
|
||||
- **Geçersiz URL:** URL'nin doğru olduğundan ve desteklenen bir platforma ait olduğundan emin olun.
|
||||
- **Premium İçerik:** YouTube Premium aboneliği gerektirir.
|
||||
- **Telif Hakkı Engeli:** İçerik telif hakkı kısıtlamaları nedeniyle engellenmiştir.
|
||||
- **İndirme sonrası video ve ses dosyaları ayrı:** Bu durum FFmpeg eksik olduğunda veya algılanmadığında olur. YTSage, yüksek kaliteli video ve ses akışlarını birleştirmek için FFmpeg gerektirir.
|
||||
- **Çözüm:** FFmpeg'in kurulu olduğundan ve sistem PATH'inizde erişilebilir olduğundan emin olun. Windows kullanıcıları için en kolay seçenek, FFmpeg ile birlikte gelen `YTSage-v<sürüm>-ffmpeg.exe` dosyasını indirmektir.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Windows Defender / Antivirüs Uyarısı
|
||||
|
||||
Bazı antivirüs yazılımları `.exe` dosyalarını yanlış pozitif olarak işaretleyebilir. Bu, paketlenmiş uygulamaların **bilinen bir sınırlamasıdır**.
|
||||
|
||||
**Neden olur:**
|
||||
- Antivirüs sezgiselleri paketlenmiş yürütülebilir dosyaları hatalı bir şekilde şüpheli olarak tanımlayabilir.
|
||||
|
||||
**Güvenli Seçenekler:**
|
||||
- ✅ **pip kurulumunu kullanın:** `pip install ytsage` (önerilir)
|
||||
- ✅ **Kaynaktan derleyin**: Bu [kılavuzu](../.github/CI_CD_README.md) takip ederek
|
||||
- ✅ **Uygulamayı antivirüs yazılımınızın beyaz listesine ekleyin**.
|
||||
|
||||
#### 🍎 macOS: "Uygulama hasarlı ve açılamıyor"
|
||||
macOS Sonoma veya daha yeni sürümlerde bu hatayı görüyorsanız, karantina özniteliğini kaldırmanız gerekir.
|
||||
|
||||
1. **Terminal'i açın** (Spotlight kullanarak bulabilirsiniz).
|
||||
2. **Aşağıdaki komutu yazın**, ancak henüz Enter tuşuna **BASMAYIN**. Sonundaki boşluğu eklediğinizden emin olun:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **`YTSage.app` dosyasını Finder penceresinden sürükleyin** ve doğrudan Terminal penceresine bırakın. Bu, doğru dosya yolunu otomatik olarak yapıştıracaktır.
|
||||
4. Komutu çalıştırmak için **Enter tuşuna basın**.
|
||||
5. **YTSage.app'i tekrar açmayı deneyin.** Artık düzgün bir şekilde çalışmalıdır.
|
||||
|
||||
---
|
||||
|
||||
#### **Yapılandırma Konumu (Gelişmiş)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor-olun"></a>
|
||||
## 💖 Sponsor Olun
|
||||
|
||||
YTSage size zaman kazandırıyorsa, projeye sponsor olmayı düşünün. Sponsorluklar geliştirme süresini, tüm platformlarda test yapmayı ve gelecekteki iyileştirmeleri karşılamaya yardımcı olur.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Sponsorluk bağlantısı uygulamadaki Hakkında diyaloğu üzerinden doğrudan mevcuttur.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="katkıda-bulunma"></a>
|
||||
## 👥 Katkıda Bulunma
|
||||
|
||||
Katkılarınız bekliyoruz! İşte nasıl yardımcı olabileceğiniz:
|
||||
|
||||
1. 🍴 Depoyu Fork'layın
|
||||
2. 🌿 Özellik dalınızı oluşturun:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 Değişikliklerinizi commit'leyin:
|
||||
```bash
|
||||
git commit -m ' AmazingFeature Ekle'
|
||||
```
|
||||
4. 📤 Dalı push'layın:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 Bir Pull Request açın
|
||||
|
||||
### 🌍 Çevirilerle Katkıda Bulunun
|
||||
|
||||
- İlgili yerelleştirilmiş README dosyasını güncelleyin (örn: `readme-translations/README.tr.md`)
|
||||
- Uygulama dizelerini `ytsage/languages/<code>.json` dosyasını düzenleyerek senkronize tutun
|
||||
- Diliniz eksikse, `README.md` dosyasından başlayın ve `README.<code>.md` dosyasını oluşturun
|
||||
|
||||
<details>
|
||||
<summary>📂 Proje Yapısı</summary>
|
||||
|
||||
## YTSage - Proje Yapısı
|
||||
|
||||
Bu belge YTSage'in düzenli klasör yapısını detaylandırır.
|
||||
|
||||
### 📁 Proje Düzeni
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # GitHub konfigürasyonları
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Sorun şablonları
|
||||
│ │ └── 🐛-bug-report.md # Hata raporu şablonu
|
||||
│ ├─── 📁 workflows/ # GitHub Actions iş akışları
|
||||
│ │ ├── build-linux.yml # Linux derleme akışı
|
||||
│ │ ├── build-macos.yml # macOS derleme akışı
|
||||
│ │ │── build-windows.yml # Windows derleme akışı
|
||||
| | └── release-all.yml # Ana yayın akışı
|
||||
│ └── 📄 CI_CD_README.md # CI/CD dökümantasyonu
|
||||
├── 📁 branding/ # Marka varlıkları (ekran görüntüleri, SVG'ler)
|
||||
│ ├── 📁 icons/ # Uygulama ikonları
|
||||
│ ├── 📁 screenshots/ # Dökümantasyon için ekran görüntüleri
|
||||
│ └── 📁 svg/ # SVG varlıkları
|
||||
├── 📄 LICENSE # Lisans dosyası
|
||||
├── 📄 pyproject.toml # Proje metadatası ve bağımlılıklar
|
||||
├── 📄 README.md # Proje dökümantasyonu
|
||||
├── 📄 requirements.txt # Python bağımlılıkları (dev)
|
||||
└── 📁 ytsage/ # Kaynak kod paketi
|
||||
├── 📁 assets/ # Çalışma zamanı varlıkları
|
||||
│ ├── 📁 Icon/ # Uygulama ikonları
|
||||
│ └── 📁 sound/ # Ses dosyaları
|
||||
├── 📁 languages/ # Yerelleştirme dosyaları
|
||||
│ ├── 📄 ar.json # Arapça çeviri
|
||||
│ ├── 📄 de.json # Almanca çeviri
|
||||
│ ├── 📄 en.json # İngilizce çeviri
|
||||
│ └── ... # Diğer diller
|
||||
├── 📁 core/ # Temel iş mantığı
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_deno.py # Deno entegrasyonu
|
||||
│ ├── 📄 ytsage_downloader.py # İndirme işlevselliği
|
||||
│ ├── 📄 ytsage_ffmpeg.py # FFmpeg entegrasyonu
|
||||
│ ├── 📄 ytsage_utils.py # Yardımcı fonksiyonlar
|
||||
│ └── 📄 ytsage_yt_dlp.py # yt-dlp entegrasyonu
|
||||
├── 📁 gui/ # Kullanıcı arayüzü bileşenleri
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_gui_main.py # Ana uygulama penceresi
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Diyalog sınıfları
|
||||
├── 📁 utils/ # Yardımcı modüller
|
||||
│ ├── 📄 __init__.py
|
||||
│ ├── 📄 ytsage_config_manager.py # Yapılandırma yönetimi
|
||||
│ └── 📄 ytsage_logger.py # Günlük tutma araçları
|
||||
├── 📄 __init__.py # Paket giriş noktası
|
||||
└── 📄 main.py # Ana yürütme scripti
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Yıldız Geçmişi
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Lisans
|
||||
|
||||
Bu proje MIT Lisansı altında lisanslanmıştır - detaylar için [LICENSE](../LICENSE) dosyasına bakın.
|
||||
|
||||
## 🙏 Teşekkürler
|
||||
|
||||
<details>
|
||||
<summary>Teşekkürleri Göster</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>İyileştirmeler önermek veya hataları bildirmek için sorunlar açarak bu projeye katkıda bulunan herkese çok teşekkürler.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Temel Bileşenler</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>İndirme Motoru</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Medya İşleme</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>yt-dlp entegrasyonu için runtime</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Kütüphaneler ve Frameworkler</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI Framework</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Resim İşleme</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>HTTP İstekleri</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Sürüm ve Paket Yönetimi</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Markdown İşleme</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Günlük Kaydı</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Varlıklar ve Katkıda Bulunanlar</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">Universfield'dan New Notification 09</a></td>
|
||||
<td>Bildirim Sesi</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Kod Katkıda Bulunan</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Feragatname
|
||||
|
||||
Bu araç sadece kişisel kullanım içindir. Lütfen YouTube'un Hizmet Şartlarına ve içerik oluşturucuların haklarına saygı gösterin.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
[oop7](https://github.com/oop7) tarafından ❤️ ile yapıldı
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,576 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="../branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="../branding/screenshots/main.png" width="800" alt="YTSage 界面"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**一款现代、整洁、基于 PySide6 的 YouTube 下载器。**
|
||||
支持下载任意质量的视频、提取音频、获取字幕等更多功能。
|
||||
|
||||
### 🌍 README 语言
|
||||
|
||||
英语: [EN](../README.md)
|
||||
| 阿拉伯语: [AR](README.ar.md)
|
||||
| 德语: [DE](README.de.md)
|
||||
| 西班牙语: [ES](README.es.md)
|
||||
| 法语: [FR](README.fr.md)
|
||||
| 印地语: [HI](README.hi.md)
|
||||
| 印尼语: [ID](README.id.md)
|
||||
| 意大利语: [IT](README.it.md)
|
||||
| 日语: [JA](README.ja.md)
|
||||
| 波兰语: [PL](README.pl.md)
|
||||
| 葡萄牙语: [PT](README.pt.md)
|
||||
| 俄语: [RU](README.ru.md)
|
||||
| 土耳其语: [TR](README.tr.md)
|
||||
| 中文: [ZH](README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#安装">安装</a> •
|
||||
<a href="#功能">功能</a> •
|
||||
<a href="#使用说明">使用说明</a> •
|
||||
<a href="#屏幕截图">屏幕截图</a> •
|
||||
<a href="#故障排除">故障排除</a> •
|
||||
<a href="#赞助支持">赞助支持</a> •
|
||||
<a href="#贡献">贡献</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="为什么选择-ytsage"></a>
|
||||
## ❓ 为什么选择 YTSage?
|
||||
|
||||
YTSage 专为寻找 **简单但强大** 的 YouTube 下载器的用户而设计。与其他工具不同,它提供:
|
||||
|
||||
- 现代、整洁的 PySide6 用户界面
|
||||
- 一键下载视频、音频和字幕
|
||||
- 支持 SponsorBlock、字幕合并和播放列表选择等高级功能
|
||||
- 可选的“通用模式”,支持 yt-dlp 兼容的其他非 YouTube 网站
|
||||
- 跨平台支持,安装简单
|
||||
|
||||
<a id="功能"></a>
|
||||
## ✨ 功能
|
||||
|
||||
<div align="center">
|
||||
|
||||
| 核心功能 | 高级功能 | 更多功能 |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 格式选择列表 | 🚫 集成 SponsorBlock | 🎞️ FPS / HDR 显示 |
|
||||
| 🎵 音频提取 | 📝 字幕选择与合并 | 🔄 yt-dlp 自动更新 |
|
||||
| ✨ 现代 UI 体验 | 💾 保存描述与缩略图 | 🛠️ FFmpeg/yt-dlp/Deno 检测 |
|
||||
| 📋 播放列表支持与选择 | 🚀 下载限速 | ⚙️ 自定义参数支持 |
|
||||
| 📑 视频列表集成 | ✂️ 视频剪辑 | 🍪 Cookie 登录集成 |
|
||||
| 📜 下载历史记录 | 🔄 更新版本分支选择 | 🌐 代理支持 |
|
||||
| 🎚️ 音频格式转换 | 🎬 视频格式设置 | 🆙 内置更新页签 |
|
||||
| 🌍 通用模式 | 🔊 音频归一化 (EBU R128) | 🌍 支持 14 种语言 |
|
||||
| 💾 播放列表导出 | ⚙️ 默认质量与字幕设置 | |
|
||||
</div>
|
||||
|
||||
<a id="安装"></a>
|
||||
## 🚀 安装
|
||||
|
||||
### ⚡ 快速安装 (推荐)
|
||||
|
||||
通过 PyPI 安装 YTSage:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 更新现有安装</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
然后通过以下命令运行:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 独立可执行文件
|
||||
|
||||
> [👉 下载最新版本](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| 格式 | 说明 |
|
||||
|--------|-------------|
|
||||
|  | 标准安装程序 |
|
||||
|  | 包含 FFmpeg 的安装程序 |
|
||||
|  | 便携版 (无需安装) |
|
||||
|  | 包含 FFmpeg 的便携版 (ZIP) |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ 安装步骤</summary>
|
||||
|
||||
1. **EXE 安装程序 (`.exe`)**: 双击并按照安装向导进行操作。
|
||||
2. **便携版 (`.zip`)**: 解压到所需位置并运行 `ytsage.exe`。
|
||||
3. **内置 FFmpeg**: 如果系统没有安装 FFmpeg,请选择带 `-ffmpeg` 的版本。
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| 格式 | 说明 |
|
||||
|--------|-------------|
|
||||
|  | Debian 软件包 |
|
||||
|  | 便携 AppImage |
|
||||
|  | RPM 软件包 |
|
||||
|  | Flatpak 软件包 |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ 安装步骤</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # 如有依赖问题请运行
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: 按照 Flathub 的说明或运行:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| 格式 | 说明 |
|
||||
|--------|-------------|
|
||||
|  | Apple Silicon 专用 ZIP 压缩包 |
|
||||
|  | Apple Silicon 专用 DMG 安装程序 |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ 安装步骤</summary>
|
||||
|
||||
- **DMG 安装程序 (`.dmg`)**: 双击挂载,然后将 `YTSage.app` 拖入 Applications 文件夹。
|
||||
- **ZIP 应用包 (`.zip`)**: 解压并移动 `YTSage.app` 到 Applications 文件夹。
|
||||
|
||||
*注意:如果遇到“应用已损坏”的提示,请参阅下方的 macOS 解决办法。*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 从源码手动安装</summary>
|
||||
|
||||
### 1. 克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
#### ⚡ 使用 uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 或使用标准 pip
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. 运行程序
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="屏幕截图"></a>
|
||||
## 📸 屏幕截图
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/Download-Settings.png" alt="下载设置" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/playlist.png" alt="播放列表下载" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>下载设置</em></td>
|
||||
<td align="center"><em>播放列表列表</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../branding/screenshots/audio_format.png" alt="音频格式选择" width="400"/></td>
|
||||
<td><img src="../branding/screenshots/Custom-Option.png" alt="自定义选项" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>音频格式设置</em></td>
|
||||
<td align="center"><em>自定义选项</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="使用说明"></a>
|
||||
## 📖 使用说明
|
||||
|
||||
<details>
|
||||
<summary>🎯 基本使用方法</summary>
|
||||
|
||||
1. **运行 YTSage**
|
||||
2. **粘贴 YouTube 地址** (或点击 "Paste URL")
|
||||
3. **点击 "Analyze" (分析)**
|
||||
4. **选择下载模式:**
|
||||
- `Video` 下载视频
|
||||
- `Audio Only` 仅提取音频
|
||||
5. **配置下载项:**
|
||||
- 启用字幕并选择语言
|
||||
- 启用字幕合并 (Merge)
|
||||
- 保存缩略图 (Save Thumbnail)
|
||||
- 启用赞助内容跳过 (SponsorBlock)
|
||||
- 下载描述文本 (Save Description)
|
||||
- 嵌入视频章节 (Embed Chapters)
|
||||
6. **选择路径**
|
||||
7. **点击 "Download" (下载)**
|
||||
|
||||
> 💡 默认下载到系统的“下载”文件夹。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 下载播放列表</summary>
|
||||
|
||||
1. **粘贴播放列表地址**
|
||||
2. **添加并分析**
|
||||
3. **在弹出窗口中选择视频 (默认全选)**
|
||||
4. **设置质量方案**
|
||||
5. **开始下载**
|
||||
|
||||
> 💡 程序会自动管理下载队列。您可以将列表导出为 `.txt`, `.csv`, `.m3u` 或 `.json` 格式。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 非 YouTube 网站 (通用模式)</summary>
|
||||
|
||||
当您想从被 yt-dlp 支持的其他网站(如 TikTok、Dailymotion 等)下载时,请开启通用模式。
|
||||
|
||||
如何使用:
|
||||
|
||||
1. 打开 `Download Settings` (下载设置)。
|
||||
2. 启用 `Generic Mode` (通用模式)。
|
||||
3. 输入非 YouTube 的支持网站链接。
|
||||
4. 点击 `Analyze` (分析)。
|
||||
5. 正常下载。
|
||||
|
||||
说明:
|
||||
|
||||
- 通用模式仅关闭了 YTSage 内部的 URL 类型检查。网站支持程度仍取决于您安装的 yt-dlp。
|
||||
- 有些站点可能需要特定的 Cookie 或代理配置。
|
||||
- 如果某个网站无法分析,请先在内置更新程序中更新 yt-dlp。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 媒体与下载选项</summary>
|
||||
|
||||
- **字幕选项:** 过滤特定语言并支持嵌入到视频中。
|
||||
- **字幕合并:** 将字幕“烧录”到视频中(硬字幕)。
|
||||
- **保存描述:** 将视频描述保存为独立的文本文档。
|
||||
- **保存缩略图:** 下载视频的高清封面。
|
||||
- **嵌入章节:** 允许将视频章节标记写入媒体元数据。
|
||||
- **赞助商跳过:** 配合 SponsorBlock 自动跳过或剪辑掉广告段落。
|
||||
- **视频剪裁:** 输入 `HH:MM:SS` 时间点来实现部分下载。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ 文件与输出设置</summary>
|
||||
|
||||
- **下载限速:** 输入如 `500K` 表示限制为 500 KB/s。
|
||||
- **保存路径:** 在 **Download Settings → Download Path** 中保存您的默认位置。
|
||||
- **默认分辨率:** 设置您首选的清晰度(如 1080p, 720p)。
|
||||
- **默认字幕语言:** 输入语言代码(如 `zh,en`)来自动选择默认字幕。
|
||||
- **文件命名模板:** 通过 `%(title)s` 等变量自定义文件名格式。
|
||||
- **强制输出格式:** 强制转换输出容器,如 `mp4`, `webm` 或 `mkv`。
|
||||
- **音频转换:** 将音频转换为 `AAC`, `MP3`, `FLAC` 等格式。
|
||||
- **音量归一化:** 使用 EBU R128 标准使下载的音量均衡。
|
||||
- **多线程连接:** 设置 **Concurrent Connections** 为 8-10 以最大化下载速度。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 进阶访问与网络</summary>
|
||||
|
||||
- **Cookie 登录:** 允许通过 Cookie 下载私人内容或绕过限制。
|
||||
推荐方法:
|
||||
1. 在设置中点击 `Extract cookies from browser`,选择您的浏览器。
|
||||
2. 或:导出 Netscape 格式的 `cookies.txt` 文件并手动载入。
|
||||
- **代理支持:** 支持设置 HTTP 代理,例如 `http://127.0.0.1:8080`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ 系统工具与维护</summary>
|
||||
|
||||
- **自定义参数:** 为 yt-dlp 传递特定的命令行参数。
|
||||
- **内置更新器:** (在 Custom Options 中)
|
||||
- **yt-dlp 更新:** 在稳定版和测试版之间切换。
|
||||
- **FFmpeg 检测:** 验证安装路劲。
|
||||
- **Deno 更新:** 维护相关的集成环境。
|
||||
- **下载历史:** 管理您的所有下载历史记录,包含缩略图和状态。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 语言支持</summary>
|
||||
|
||||
YTSage 支持 **14 种语言**。您可以在 **Custom Options → Language** 中更改。
|
||||
|
||||
### 支持的界面语言
|
||||
|
||||
| 语言 | 代码 | 语言 | 代码 |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 英语 | `en` | 🇪🇸 西班牙语 | `es` |
|
||||
| 🇸🇦 阿拉伯语 | `ar` | 🇫🇷 法语 | `fr` |
|
||||
| 🇩🇪 德语 | `de` | 🇮🇳 印地语 | `hi` |
|
||||
| 🇮🇩 印尼语 | `id` | 🇮🇹 意大利语 | `it` |
|
||||
| 🇯🇵 日语 | `ja` | 🇵🇱 波兰语 | `pl` |
|
||||
| 🇧🇷 葡萄牙语 | `pt` | 🇷🇺 俄语 | `ru` |
|
||||
| 🇹🇷 土耳其语 | `tr` | 🇨🇳 中文 | `zh` |
|
||||
|
||||
### README 翻译版
|
||||
|
||||
| 语言 | 文件 | 语言 | 文件 |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 英语 | [README.md](../README.md) | 🇪🇸 西班牙语 | [README.es.md](README.es.md) |
|
||||
| 🇸🇦 阿拉伯语 | [README.ar.md](README.ar.md) | 🇫🇷 法语 | [README.fr.md](README.fr.md) |
|
||||
| 🇩🇪 德语 | [README.de.md](README.de.md) | 🇮🇳 印地语 | [README.hi.md](README.hi.md) |
|
||||
| 🇮🇩 印尼语 | [README.id.md](README.id.md) | 🇮🇹 意大利语 | [README.it.md](README.it.md) |
|
||||
| 🇯🇵 日语 | [README.ja.md](README.ja.md) | 🇵🇱 波兰语 | [README.pl.md](README.pl.md) |
|
||||
| 🇧🇷 葡萄牙语 | [README.pt.md](README.pt.md) | 🇷🇺 俄语 | [README.ru.md](README.ru.md) |
|
||||
| 🇹🇷 土耳其语 | [README.tr.md](README.tr.md) | 🇨🇳 中文 | [README.zh.md](README.zh.md) |
|
||||
|
||||
> 💡 **想要贡献翻译?** 欢迎查看 [贡献指南](#贡献) 部分!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="故障排除"></a>
|
||||
## 🛠️ 故障排除
|
||||
|
||||
<details>
|
||||
<summary>常见问题解答</summary>
|
||||
|
||||
- **没有显示格式表格:** 请更新 yt-dlp。如果仍无效,请尝试切换到测试版 (Nightly) 分支。
|
||||
- **下载失败:** 请检查网络连接或该地区视频是否可用。
|
||||
- **常见特定的错误提示:**
|
||||
- **私有视频:** 需提供 Cookie 登录。
|
||||
- **内容受限:** 部分视频需登录账号。
|
||||
- **地理锁定:** 需使用代理或 VPN。
|
||||
- **视频已删除:** 视频已不存在。
|
||||
- **直播内容:** YTSage 目前不支持实时直播下载,请等直播结束后再试。
|
||||
- **下载后画面和声音分离:** 意味着系统中未安装或未检测到 FFmpeg。
|
||||
- **解决方法:** 确保 FFmpeg 已安装并加入 PATH,或下载带 `-ffmpeg` 版本的 Windows 程序。
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Windows Defender / 杀毒软件警告
|
||||
|
||||
某些杀毒软件可能会误报。这是打包程序的常见现象。
|
||||
|
||||
**原因:**
|
||||
- 启发式查杀可能会误认为打包的可执行文件是恶意软件。
|
||||
|
||||
**安全建议:**
|
||||
- ✅ **通过 pip 安装:** `pip install ytsage`(推荐)
|
||||
- ✅ **自行构建**: 参照 [CI_CD 指南](../.github/CI_CD_README.md)
|
||||
- ✅ **添加排除项**。
|
||||
|
||||
#### 🍎 macOS: "应用已损坏,无法打开"
|
||||
在 macOS Sonoma 及以后版本:
|
||||
|
||||
1. 打开 **终端 (Terminal)**。
|
||||
2. 输入命令 (结尾有一个空格):
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. 从 Finder 中将 **YTSage.app** 拖入终端窗口。
|
||||
4. 按回车。
|
||||
5. 再次打开应用。
|
||||
|
||||
---
|
||||
|
||||
#### **配置存放路径**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="赞助支持"></a>
|
||||
## 💖 赞助支持
|
||||
|
||||
如果 YTSage 节省了您的时间,请考虑资助本项目。赞助收入将用于多平台测试环境的维护和新功能开发。
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- 您也可以通过应用内的“About (关于)”窗口找到捐赠链接。
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="贡献"></a>
|
||||
## 👥 贡献
|
||||
|
||||
感谢所有帮助!
|
||||
|
||||
1. 🍴 Fork 仓库
|
||||
2. 🌿 创建特性分支: `git checkout -b feature/NewFeature`
|
||||
3. 💾 提交更改: `git commit -m 'Add NewFeature'`
|
||||
4. 📤 推送分支: `git push origin feature/NewFeature`
|
||||
5. 🔄 开启 Pull Request
|
||||
|
||||
### 🌍 翻译贡献
|
||||
|
||||
- 您可以更新各语种 README (例如 `readme-translations/README.zh.md`)。
|
||||
- 也可以翻译界面词条:`ytsage/languages/<语言代码>.json`。
|
||||
|
||||
<details>
|
||||
<summary>📂 项目结构</summary>
|
||||
|
||||
## YTSage - 项目结构
|
||||
|
||||
### 📁 目录概览
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # GitHub 设置
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # 问题模板
|
||||
│ ├─── 📁 workflows/ # GitHub Actions 流程
|
||||
├── 📁 branding/ # 品牌资源 (截图, SVG)
|
||||
│ ├── 📁 icons/ # 图标
|
||||
│ ├── 📁 screenshots/ # 截图说明
|
||||
│ └── 📁 svg/ # SVG 素材
|
||||
├── 📄 LICENSE # 许可证
|
||||
├── 📄 pyproject.toml # 项目元数据与依赖
|
||||
├── 📄 README.md # 英语 README
|
||||
├── 📄 requirements.txt # 开发依赖
|
||||
└── 📁 ytsage/ # 源代码
|
||||
├── 📁 assets/ # 运行时资源 (音频, 图标)
|
||||
├── 📁 languages/ # 多语言 JSON 文件
|
||||
├── 📁 core/ # 下载与集成核心逻辑
|
||||
├── 📁 gui/ # UI 组件
|
||||
├── 📁 utils/ # 工具类
|
||||
├── 📄 __init__.py
|
||||
└── 📄 main.py # 程序入口
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ 关注趋势
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
本项目基于 MIT 许可证分发 - 详情请参阅 [LICENSE](../LICENSE) 文件。
|
||||
|
||||
## 🙏 鸣谢
|
||||
|
||||
<details>
|
||||
<summary>查看致谢名单</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>特别鸣谢所有通过反馈、建议或代码合并来完善此工具的贡献者。</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">核心组件</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>核心下载引擎</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>媒体流处理</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>集成运行环境</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">库与框架</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI 框架</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>图片处理</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>网络请求</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>版本管理</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Markdown 渲染</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>日志记录</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">内容与贡献</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">Universfield 的通知音效</a></td>
|
||||
<td>通知声音</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>代码贡献</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本工具仅供个人学习与研究使用。请尊重 YouTube 服务条款及创作者版权。
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
由 [oop7](https://github.com/oop7) 倾力协作 ❤️
|
||||
</div>
|
||||
@@ -0,0 +1,418 @@
|
||||
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
|
||||
;
|
||||
; To download user-contributed translations of this file, go to:
|
||||
; https://jrsoftware.org/files/istrans/
|
||||
;
|
||||
; Note: When translating this text, do not add periods (.) to the end of
|
||||
; messages that didn't have them already, because on those messages Inno
|
||||
; Setup adds the periods automatically (appending a period would result in
|
||||
; two periods being displayed).
|
||||
;
|
||||
; Maintained by Zhenghan Yang
|
||||
; Email: 847320916@QQ.com
|
||||
; Translation based on network resource
|
||||
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
;
|
||||
|
||||
[LangOptions]
|
||||
; The following three entries are very important. Be sure to read and
|
||||
; understand the '[LangOptions] section' topic in the help file.
|
||||
LanguageName=简体中文
|
||||
; If Language Name display incorrect, uncomment next line
|
||||
; LanguageName=<7B80><4F53><4E2D><6587>
|
||||
; About LanguageID, to reference link:
|
||||
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
|
||||
LanguageID=$0804
|
||||
; About CodePage, to reference link:
|
||||
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
|
||||
LanguageCodePage=936
|
||||
; If the language you are translating to requires special font faces or
|
||||
; sizes, uncomment any of the following entries and change them accordingly.
|
||||
;DialogFontName=
|
||||
;DialogFontSize=9
|
||||
;DialogFontBaseScaleWidth=7
|
||||
;DialogFontBaseScaleHeight=15
|
||||
;WelcomeFontName=Segoe UI
|
||||
;WelcomeFontSize=14
|
||||
|
||||
[Messages]
|
||||
|
||||
; *** 应用程序标题
|
||||
SetupAppTitle=安装
|
||||
SetupWindowTitle=安装 - %1
|
||||
UninstallAppTitle=卸载
|
||||
UninstallAppFullTitle=%1 卸载
|
||||
|
||||
; *** Misc. common
|
||||
InformationTitle=信息
|
||||
ConfirmTitle=确认
|
||||
ErrorTitle=错误
|
||||
|
||||
; *** SetupLdr messages
|
||||
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
|
||||
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
|
||||
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
|
||||
HelpTextNote=
|
||||
|
||||
; *** 启动错误消息
|
||||
LastErrorMessage=%1。%n%n错误 %2: %3
|
||||
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
|
||||
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
|
||||
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
|
||||
InvalidParameter=无效的命令行参数:%n%n%1
|
||||
SetupAlreadyRunning=安装程序正在运行。
|
||||
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
|
||||
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
|
||||
NotOnThisPlatform=此程序不能在 %1 上运行。
|
||||
OnlyOnThisPlatform=此程序只能在 %1 上运行。
|
||||
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
|
||||
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
|
||||
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
|
||||
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
|
||||
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
|
||||
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
|
||||
; *** 启动问题
|
||||
PrivilegesRequiredOverrideTitle=选择安装程序模式
|
||||
PrivilegesRequiredOverrideInstruction=选择安装模式
|
||||
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
|
||||
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
|
||||
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
|
||||
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
|
||||
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
|
||||
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
|
||||
|
||||
; *** 其他错误
|
||||
ErrorCreatingDir=安装程序无法创建目录“%1”
|
||||
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
|
||||
|
||||
; *** 安装程序公共消息
|
||||
ExitSetupTitle=退出安装程序
|
||||
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
|
||||
AboutSetupMenuItem=关于安装程序(&A)...
|
||||
AboutSetupTitle=关于安装程序
|
||||
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
|
||||
AboutSetupNote=
|
||||
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
|
||||
; *** 按钮
|
||||
ButtonBack=< 上一步(&B)
|
||||
ButtonNext=下一步(&N) >
|
||||
ButtonInstall=安装(&I)
|
||||
ButtonOK=确定
|
||||
ButtonCancel=取消
|
||||
ButtonYes=是(&Y)
|
||||
ButtonYesToAll=全是(&A)
|
||||
ButtonNo=否(&N)
|
||||
ButtonNoToAll=全否(&O)
|
||||
ButtonFinish=完成(&F)
|
||||
ButtonBrowse=浏览(&B)...
|
||||
ButtonWizardBrowse=浏览(&R)...
|
||||
ButtonNewFolder=新建文件夹(&M)
|
||||
|
||||
; *** “选择语言”对话框消息
|
||||
SelectLanguageTitle=选择安装语言
|
||||
SelectLanguageLabel=选择安装时使用的语言。
|
||||
|
||||
; *** 公共向导文字
|
||||
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
|
||||
BeveledLabel=
|
||||
BrowseDialogTitle=浏览文件夹
|
||||
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
|
||||
NewFolderName=新建文件夹
|
||||
|
||||
; *** “欢迎”向导页
|
||||
WelcomeLabel1=欢迎使用 [name] 安装向导
|
||||
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
|
||||
|
||||
; *** “密码”向导页
|
||||
WizardPassword=密码
|
||||
PasswordLabel1=这个安装程序有密码保护。
|
||||
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
|
||||
PasswordEditLabel=密码(&P):
|
||||
IncorrectPassword=您输入的密码不正确,请重新输入。
|
||||
|
||||
; *** “许可协议”向导页
|
||||
WizardLicense=许可协议
|
||||
LicenseLabel=请在继续安装前阅读以下重要信息。
|
||||
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
|
||||
LicenseAccepted=我同意此协议(&A)
|
||||
LicenseNotAccepted=我不同意此协议(&D)
|
||||
|
||||
; *** “信息”向导页
|
||||
WizardInfoBefore=信息
|
||||
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
|
||||
WizardInfoAfter=信息
|
||||
InfoAfterLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
|
||||
|
||||
; *** “用户信息”向导页
|
||||
WizardUserInfo=用户信息
|
||||
UserInfoDesc=请输入您的信息。
|
||||
UserInfoName=用户名(&U):
|
||||
UserInfoOrg=组织(&O):
|
||||
UserInfoSerial=序列号(&S):
|
||||
UserInfoNameRequired=您必须输入用户名。
|
||||
|
||||
; *** “选择目标目录”向导页
|
||||
WizardSelectDir=选择目标位置
|
||||
SelectDirDesc=您想将 [name] 安装在哪里?
|
||||
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
|
||||
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
|
||||
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
|
||||
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
|
||||
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
|
||||
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share
|
||||
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
|
||||
DiskSpaceWarningTitle=磁盘空间不足
|
||||
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗?
|
||||
DirNameTooLong=文件夹名称或路径太长。
|
||||
InvalidDirName=文件夹名称无效。
|
||||
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
|
||||
DirExistsTitle=文件夹已存在
|
||||
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗?
|
||||
DirDoesntExistTitle=文件夹不存在
|
||||
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
|
||||
|
||||
; *** “选择组件”向导页
|
||||
WizardSelectComponents=选择组件
|
||||
SelectComponentsDesc=您想安装哪些程序组件?
|
||||
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
|
||||
FullInstallation=完全安装
|
||||
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
|
||||
CompactInstallation=简洁安装
|
||||
CustomInstallation=自定义安装
|
||||
NoUninstallWarningTitle=组件已存在
|
||||
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗?
|
||||
ComponentSize1=%1 KB
|
||||
ComponentSize2=%1 MB
|
||||
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
|
||||
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
|
||||
|
||||
; *** “选择附加任务”向导页
|
||||
WizardSelectTasks=选择附加任务
|
||||
SelectTasksDesc=您想要安装程序执行哪些附加任务?
|
||||
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
|
||||
|
||||
; *** “选择开始菜单文件夹”向导页
|
||||
WizardSelectProgramGroup=选择开始菜单文件夹
|
||||
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
|
||||
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
|
||||
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
MustEnterGroupName=您必须输入一个文件夹名。
|
||||
GroupNameTooLong=文件夹名或路径太长。
|
||||
InvalidGroupName=无效的文件夹名字。
|
||||
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
|
||||
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
|
||||
|
||||
; *** “准备安装”向导页
|
||||
WizardReady=准备安装
|
||||
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
|
||||
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
|
||||
ReadyLabel2b=点击“安装”继续此安装程序。
|
||||
ReadyMemoUserInfo=用户信息:
|
||||
ReadyMemoDir=目标位置:
|
||||
ReadyMemoType=安装类型:
|
||||
ReadyMemoComponents=已选择组件:
|
||||
ReadyMemoGroup=开始菜单文件夹:
|
||||
ReadyMemoTasks=附加任务:
|
||||
|
||||
; *** TExtractionWizardPage 向导页面与 ExtractArchive
|
||||
ExtractingLabel=正在解压文件...
|
||||
ButtonStopExtraction=停止解压(&S)
|
||||
StopExtraction=您确定要停止解压吗?
|
||||
ErrorExtractionAborted=解压已中止
|
||||
ErrorExtractionFailed=解压失败:%1
|
||||
|
||||
; *** 压缩文件解压失败详情
|
||||
ArchiveIncorrectPassword=压缩文件密码不正确
|
||||
ArchiveIsCorrupted=压缩文件已损坏
|
||||
ArchiveUnsupportedFormat=不支持的压缩文件格式
|
||||
|
||||
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
|
||||
DownloadingLabel2=正在下载文件...
|
||||
ButtonStopDownload=停止下载(&S)
|
||||
StopDownload=您确定要停止下载吗?
|
||||
ErrorDownloadAborted=下载已中止
|
||||
ErrorDownloadFailed=下载失败:%1 %2
|
||||
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
|
||||
ErrorProgress=无效的进度:%1 / %2
|
||||
ErrorFileSize=文件大小错误:预期 %1,实际 %2
|
||||
|
||||
; *** “正在准备安装”向导页
|
||||
WizardPreparing=正在准备安装
|
||||
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
|
||||
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。
|
||||
CannotContinue=安装程序不能继续。请点击“取消”退出。
|
||||
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
|
||||
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
|
||||
CloseApplications=自动关闭应用程序(&A)
|
||||
DontCloseApplications=不要关闭应用程序(&D)
|
||||
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
|
||||
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动?
|
||||
|
||||
; *** “正在安装”向导页
|
||||
WizardInstalling=正在安装
|
||||
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
|
||||
|
||||
; *** “安装完成”向导页
|
||||
FinishedHeadingLabel=[name] 安装完成
|
||||
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
|
||||
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
|
||||
ClickFinish=点击“完成”退出安装程序。
|
||||
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
|
||||
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗?
|
||||
ShowReadmeCheck=是,我想查阅自述文件
|
||||
YesRadio=是,立即重启电脑(&Y)
|
||||
NoRadio=否,稍后重启电脑(&N)
|
||||
; used for example as 'Run MyProg.exe'
|
||||
RunEntryExec=运行 %1
|
||||
; used for example as 'View Readme.txt'
|
||||
RunEntryShellExec=查阅 %1
|
||||
|
||||
; *** “安装程序需要下一张磁盘”提示
|
||||
ChangeDiskTitle=安装程序需要下一张磁盘
|
||||
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
|
||||
PathLabel=路径(&P):
|
||||
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
|
||||
SelectDirectoryLabel=请指定下一张磁盘的位置。
|
||||
|
||||
; *** 安装阶段消息
|
||||
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
|
||||
AbortRetryIgnoreSelectAction=选择操作
|
||||
AbortRetryIgnoreRetry=重试(&T)
|
||||
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
|
||||
AbortRetryIgnoreCancel=关闭安装程序
|
||||
RetryCancelSelectAction=选择操作
|
||||
RetryCancelRetry=重试(&T)
|
||||
RetryCancelCancel=取消(&C)
|
||||
|
||||
; *** 安装状态消息
|
||||
StatusClosingApplications=正在关闭应用程序...
|
||||
StatusCreateDirs=正在创建目录...
|
||||
StatusExtractFiles=正在提取文件...
|
||||
StatusDownloadFiles=正在下载文件...
|
||||
StatusCreateIcons=正在创建快捷方式...
|
||||
StatusCreateIniEntries=正在创建 INI 条目...
|
||||
StatusCreateRegistryEntries=正在创建注册表条目...
|
||||
StatusRegisterFiles=正在注册文件...
|
||||
StatusSavingUninstall=正在保存卸载信息...
|
||||
StatusRunProgram=正在完成安装...
|
||||
StatusRestartingApplications=正在重启应用程序...
|
||||
StatusRollback=正在撤销更改...
|
||||
|
||||
; *** 其他错误
|
||||
ErrorInternal2=内部错误:%1
|
||||
ErrorFunctionFailedNoCode=%1 失败
|
||||
ErrorFunctionFailed=%1 失败;错误代码 %2
|
||||
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
|
||||
ErrorExecutingProgram=无法执行文件:%n%1
|
||||
|
||||
; *** 注册表错误
|
||||
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
|
||||
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
|
||||
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
|
||||
|
||||
; *** INI 错误
|
||||
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
|
||||
|
||||
; *** 文件复制错误
|
||||
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
|
||||
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
|
||||
SourceIsCorrupted=源文件已损坏
|
||||
SourceDoesntExist=源文件“%1”不存在
|
||||
SourceVerificationFailed=源文件验证失败: %1
|
||||
VerificationSignatureDoesntExist=签名文件“%1”不存在
|
||||
VerificationSignatureInvalid=签名文件“%1”无效
|
||||
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
|
||||
VerificationFileNameIncorrect=文件名不正确
|
||||
VerificationFileTagIncorrect=文件标签不正确
|
||||
VerificationFileSizeIncorrect=文件大小不正确
|
||||
VerificationFileHashIncorrect=文件哈希值不正确
|
||||
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
|
||||
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
|
||||
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
|
||||
ErrorReadingExistingDest=尝试读取现有文件时出错:
|
||||
FileExistsSelectAction=选择操作
|
||||
FileExists2=文件已经存在。
|
||||
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
|
||||
FileExistsKeepExisting=保留现有的文件(&K)
|
||||
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||
ExistingFileNewerSelectAction=选择操作
|
||||
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
|
||||
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
|
||||
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
|
||||
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
|
||||
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
|
||||
ErrorReadingSource=尝试读取下列源文件时出错:
|
||||
ErrorCopying=尝试复制下列文件时出错:
|
||||
ErrorDownloading=下载文件时出错:
|
||||
ErrorExtracting=解压压缩文件时出错:
|
||||
ErrorReplacingExistingFile=尝试替换现有文件时出错:
|
||||
ErrorRestartReplace=重启并替换失败:
|
||||
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
|
||||
ErrorRegisterServer=无法注册 DLL/OCX:%1
|
||||
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
|
||||
ErrorRegisterTypeLib=无法注册类库:%1
|
||||
|
||||
; *** 卸载显示名字标记
|
||||
; used for example as 'My Program (32-bit)'
|
||||
UninstallDisplayNameMark=%1 (%2)
|
||||
; used for example as 'My Program (32-bit, All users)'
|
||||
UninstallDisplayNameMarks=%1 (%2, %3)
|
||||
UninstallDisplayNameMark32Bit=32 位
|
||||
UninstallDisplayNameMark64Bit=64 位
|
||||
UninstallDisplayNameMarkAllUsers=所有用户
|
||||
UninstallDisplayNameMarkCurrentUser=当前用户
|
||||
|
||||
; *** 安装后错误
|
||||
ErrorOpeningReadme=尝试打开自述文件时出错。
|
||||
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
|
||||
|
||||
; *** 卸载消息
|
||||
UninstallNotFound=文件“%1”不存在。无法卸载。
|
||||
UninstallOpenError=文件“%1”不能被打开。无法卸载。
|
||||
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
|
||||
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
|
||||
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
|
||||
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
|
||||
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
|
||||
UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。
|
||||
UninstalledAll=已顺利从您的电脑中移除 %1。
|
||||
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
|
||||
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗?
|
||||
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
|
||||
|
||||
; *** 卸载状态消息
|
||||
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
|
||||
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。
|
||||
SharedFileNameLabel=文件名:
|
||||
SharedFileLocationLabel=位置:
|
||||
WizardUninstalling=卸载状态
|
||||
StatusUninstalling=正在卸载 %1...
|
||||
|
||||
; *** Shutdown block reasons
|
||||
ShutdownBlockReasonInstallingApp=正在安装 %1。
|
||||
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
|
||||
|
||||
; The custom messages below aren't used by Setup itself, but if you make
|
||||
; use of them in your scripts, you'll want to translate them.
|
||||
|
||||
[CustomMessages]
|
||||
|
||||
NameAndVersion=%1 版本 %2
|
||||
AdditionalIcons=附加快捷方式:
|
||||
CreateDesktopIcon=创建桌面快捷方式(&D)
|
||||
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
|
||||
ProgramOnTheWeb=%1 网站
|
||||
UninstallProgram=卸载 %1
|
||||
LaunchProgram=运行 %1
|
||||
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
|
||||
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
|
||||
AutoStartProgramGroupDescription=启动:
|
||||
AutoStartProgram=自动启动 %1
|
||||
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗?
|
||||
@@ -0,0 +1,336 @@
|
||||
; *** Inno Setup version 5.5.3+ Hindi messages ***
|
||||
; Translated by Him Prasad Gautam [ drishtibachak at gmail.com ]
|
||||
; To download user-contributed translations of this file, go to:
|
||||
; http://www.jrsoftware.org/files/istrans/
|
||||
;
|
||||
; Note: When translating this text, do not add periods (.) to the end of
|
||||
; messages that didn't have them already, because on those messages Inno
|
||||
; Setup adds the periods automatically (appending a period would result in
|
||||
; two periods being displayed).
|
||||
|
||||
[LangOptions]
|
||||
; The following three entries are very important. Be sure to read and
|
||||
; understand the '[LangOptions] section' topic in the help file.
|
||||
LanguageName=<0939><093F><0902><0926><0940>
|
||||
LanguageID=$0439
|
||||
LanguageCodePage=0
|
||||
; If the language you are translating to requires special font faces or
|
||||
; sizes, uncomment any of the following entries and change them accordingly.
|
||||
;DialogFontName=
|
||||
;DialogFontSize=10
|
||||
;WelcomeFontName=
|
||||
WelcomeFontSize=12
|
||||
;TitleFontName=
|
||||
TitleFontSize=35
|
||||
;CopyrightFontName=
|
||||
CopyrightFontSize=9
|
||||
|
||||
[Messages]
|
||||
|
||||
; *** Application titles
|
||||
SetupAppTitle=स्थापना
|
||||
SetupWindowTitle=स्थापना - %1
|
||||
UninstallAppTitle=निस्कासन
|
||||
UninstallAppFullTitle=%1 कि निस्कासन
|
||||
|
||||
; *** Misc. common
|
||||
InformationTitle=सुचना
|
||||
ConfirmTitle=पुष्टिकरण
|
||||
ErrorTitle=त्रुटी
|
||||
|
||||
; *** SetupLdr messages
|
||||
SetupLdrStartupMessage=इस से %1 आपकि कल्पयन्त्र में अधिष्ठापन होगा. क्या आप आगे बढ़ना चाहते है?
|
||||
LdrCannotCreateTemp=अस्थाई फ़ाइल नही बना पा रहा. स्थापना को बिच में ही रोकना पड़ा.
|
||||
LdrCannotExecTemp=अस्थाई फोल्डर में से फ़ाइल कार्यान्वयन नही कर पाया. स्थापना को बिच में ही रोकना पड़ा.
|
||||
|
||||
; *** Startup error messages
|
||||
LastErrorMessage=%1.%n%nत्रुटी %2: %3
|
||||
SetupFileMissing=फ़ाइल %1 अधिष्ठापन सङ्ग्रहिका में नही है. कृपया या तो समस्या का निदान कीजिये या कार्यक्रम की नई प्रति लाइए.
|
||||
SetupFileCorrupt=स्थापना फाइल में त्रुटी है. कृपया नई कार्यक्रम की प्रति लाइए.
|
||||
SetupFileCorruptOrWrongVer=स्थापना फाइल में त्रुटी है या तो अलग प्रकार कि है. कृपया समस्या-निदान करे या कार्यक्रम की नई प्रति लाइए.
|
||||
InvalidParameter=फोल्डर का नाम वैध नही है.
|
||||
SetupAlreadyRunning=स्थापना तो पहले से हि चल रहा है
|
||||
WindowsVersionNotSupported=इस से [name/ver] आपके कल्पयन्त्र में अधिष्ठापन होगा.%n%nये बहेतर होगा आगे बढने से पहेले आप अन्य सभी कार्यक्रम हाल तुरत के लिए बंध कर दे.
|
||||
WindowsServicePackRequired=ये कार्यक्रम को %1 Service Pack %2 या पिछला संस्करण चाहिए.
|
||||
NotOnThisPlatform=ये कार्यक्रम %1 पे नही चलेगा.
|
||||
OnlyOnThisPlatform=ये कार्यक्रम केवल %1 पे ही चलेगा.
|
||||
OnlyOnTheseArchitectures=ये कार्यक्रम केवल इन प्रोसेसर :%n%n%1 से अनुरूप विन्डोज़ प्लेटफॉर्म पे ही चलेगा.
|
||||
MissingWOW64APIs=आपका विंडो प्लेटफॉर्म 64-bit अधिष्ठापन समर्थन नही करता. कृपया सर्विस पैक %1 अधिष्ठापन करे.
|
||||
WinVersionTooLowError=ये कार्यक्रम चलने के लिए %1 संस्करण %2 या उस से पिछला चाहिए.
|
||||
WinVersionTooHighError=ये कार्यक्रम नही अधिष्ठापन किया जा सकता %1 संस्करण %2 या पिछला पे.
|
||||
AdminPrivilegesRequired=अगर आप प्रशासक खाते से आरम्भ करे तो ही ये कार्यक्रम अधिष्ठापन कर पाओगे.
|
||||
PowerUserPrivilegesRequired=आप प्रशासक खाते या शक्ति-प्रयोग कर्ता समूह के खाते से आरम्भ करे तो ही ये कार्यक्रम अधिष्ठापन कर पाओगे.
|
||||
SetupAppRunningError=स्थापना ने पकड़ा की %1 हाल चालू है..%n%n कृपया उसे बंध करे अभी, और बाद में आगे बढने वास्ते ठीक या निकल जाने वास्ते रद्द करेँ पे क्लिक करे.
|
||||
UninstallAppRunningError=निस्कासन को ये ज्ञात हुआ की %1 अभी चालू है.%n%n कृपया उसे बंध करे और फिर आगे बढने के लिए ठीक या बाहर जाने के लिए रद्द करेँ पे क्लिक करे.
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorCreatingDir=स्थापना "%1" सङ्ग्रहिका बनाने में विफल रहा
|
||||
ErrorTooManyFilesInDir=%1 सङ्ग्रहिका में बहुत फाइल मौजूद होने के वजह से स्थापना फाइल बनाने में विफल रहा.
|
||||
|
||||
; *** Setup common messages
|
||||
ExitSetupTitle=स्थापना कि बहिर्गमन
|
||||
ExitSetupMessage=स्थापना कि कार्य पूर्ण नही हुआ, यदि आप अभी बाहर जाने कि ईराधा करेंगे तो कार्यक्रम सहि ढंग से अधिष्ठापन नही होगा.%n%nआप किसी ओर वक्त फिर से अधिष्ठापन कर सकते हो.%n%nक्या बाहर जाए?
|
||||
AboutSetupMenuItem=स्थापना के बारे में...
|
||||
AboutSetupTitle=स्थापना के बारे में
|
||||
AboutSetupMessage=%1 संस्करण %2%n%3%n%n%1 गृह पृष्ठ:%n%4
|
||||
AboutSetupNote=
|
||||
TranslatorNote= यह हिन्दी में अनुवाद कि कार्य हिम प्रसाद गौतम ने किया है.
|
||||
|
||||
; *** Buttons
|
||||
ButtonBack=< &पिछे हटो
|
||||
ButtonNext=&आगे बढो >
|
||||
ButtonInstall=&अधिष्ठापन
|
||||
ButtonOK=&ठीक
|
||||
ButtonCancel=&रद्द करेँ
|
||||
ButtonYes=&हाँ
|
||||
ButtonYesToAll=&सभी के लिए हाँ
|
||||
ButtonNo=&नही
|
||||
ButtonNoToAll=स&भी के लिए नही
|
||||
ButtonFinish=&समाप्त
|
||||
ButtonBrowse=&ब्राउज़...
|
||||
ButtonWizardBrowse=&ब्राउज़...
|
||||
ButtonNewFolder=&नया फोल्डर बनाए
|
||||
|
||||
; "Select Language" dialog messages
|
||||
SelectLanguageTitle=स्थापना भाषा चयन
|
||||
SelectLanguageLabel=अधिष्ठापन के दरम्यान इस्तेमाल होने वाली भाषा चयन करे:
|
||||
|
||||
; *** Common wizard text
|
||||
ClickNext=आगे बढने के लिए आगे बढो दबाए, या बाहर जाने के वास्ते रद्द करेँ दबाए.
|
||||
BeveledLabel= सौजन्यः हिम प्रसाद गौतम
|
||||
BrowseDialogTitle=फोल्डर के लिए ब्राउज़ करे
|
||||
BrowseDialogLabel=नीचे की सुची में से एक फोल्डर चयन करके ठीक दबाए.
|
||||
NewFolderName=नया फोल्डर
|
||||
|
||||
; "Welcome" wizard page
|
||||
WelcomeLabel1=यह [name] कि स्थापना हो रही समारोह में आपका स्वागत है
|
||||
WelcomeLabel2=इस से [name/ver] आपके कल्पयन्त्र में अधिष्ठापन होगा.%n%nये बहेतर होगा आगे बढने से पहले आप अन्य सभी खुलि हुई कार्यक्रम हाल के लिए बंध कर दे.
|
||||
|
||||
; "Password" wizard page
|
||||
WizardPassword=खुपियाशब्द
|
||||
PasswordLabel1=ये अधिष्ठापन खुपियाशब्द से लोक है.
|
||||
PasswordLabel3=कृपया खुपियाशब्द लिखें और बाद में 'आगे बढो' बटन दबाए. खुपियाशब्द case-सम्वेदनसील है.
|
||||
PasswordEditLabel=खुपियाशब्द:
|
||||
IncorrectPassword=आपने लिखा हुआ खुपियाशब्द गलत है. कृपया फिर से कोशिश करे.
|
||||
|
||||
; "License Agreement" wizard page
|
||||
WizardLicense=इजाजत करार
|
||||
LicenseLabel=आगे बढने से पहेले ये महत्वपूर्ण सूचनाए पढे.
|
||||
LicenseLabel3=ये इजाजत करार पढे. आगे बढने से पहेले आपको इसकी शर्तों को मानना ही होगा.
|
||||
LicenseAccepted=हाँ मुझे ये करारनामा कबूल है.
|
||||
LicenseNotAccepted=नही मुझे ये करारनामा कबूल नही है.
|
||||
|
||||
; "Information" wizard pages
|
||||
WizardInfoBefore=सुचना
|
||||
InfoBeforeLabel=आगे बढने से पहेले ये महत्वपूर्ण सूचनाए पढे.
|
||||
InfoBeforeClickLabel=जब आप तयार हो, 'आगे बढो' बटन दबाए.
|
||||
WizardInfoAfter=सुचना
|
||||
InfoAfterLabel=आगे बढने से पहेले ये महत्वपूर्ण सूचनाए पढे.
|
||||
InfoAfterClickLabel=जब आप तयार हो, 'आगे बढो' बटन दबाए.
|
||||
|
||||
; "User Information" wizard page
|
||||
WizardUserInfo=प्रयोग कर्ता की जानकारी
|
||||
UserInfoDesc=कृपया आपकी जानकारी अंदर डाले.
|
||||
UserInfoName=प्रयोग कर्ता का नाम:
|
||||
UserInfoOrg=संस्था:
|
||||
UserInfoSerial=क्रमाङ्क
|
||||
UserInfoNameRequired=आपको नाम तो डालना ही होगा.
|
||||
|
||||
; "Select Destination Location" wizard page
|
||||
WizardSelectDir=लक्ष्य पथ चयन करे
|
||||
SelectDirDesc=[name] को किधर अधिष्ठापन करना है?
|
||||
SelectDirLabel3=स्थापना [name] को निम्नलिखित फोल्डर में डालेगा.
|
||||
SelectDirBrowseLabel=आगे बढने वास्ते आगे बढो दबाए. यदि अन्य फोल्डर चयन करना है तो ब्राउज़ दबाए.
|
||||
DiskSpaceMBLabel=कमसेकम [mb] MB जितनी जगह तो जरूरी होगी.
|
||||
CannotInstallToNetworkDrive=श्थापना ने नेटवर्क ड्राइभ नहि रख पाया.
|
||||
CannotInstallToUNCPath=स्थापना ने UNC path नहि रख पाया.
|
||||
InvalidPath=आपको ड्राइव अक्षर के साथ पूर्ण पथ देना होगा उदाहरण:%n%nC:\APP%n%n या तो UNC रास्ता यह रूप में:%n%n\\server\share
|
||||
InvalidDrive=जो drive या UNC share आपने चयन की है उस मे हम पहुँच नही कर पा रहे कृपया अन्य चयन करे.
|
||||
DiskSpaceWarningTitle=जरूरी जगह नही है.
|
||||
DiskSpaceWarning=स्थापना कम से कम %1 KB जगह मंगता है, लेकिन चयनित ड्राइव में तो केवल %2 KB ही मौजूद है.%n%nक्या आप फिर भी आगे बढ़ना चाहते हो?
|
||||
DirNameTooLong=फोल्डर का नाम या पथ बहोत लंबा है.
|
||||
InvalidDirName=फोल्डर का नाम वैध नही है.
|
||||
BadDirName32=फोल्डर नाम में ये अक्षर नही इस्तेमाल कर सकते:%n%n%1
|
||||
DirExistsTitle=फोल्डर मौजूद है
|
||||
DirExists=फोल्डर:%n%n%1%n%nपहेले से ही मौजूद है, क्या आप फिर भी उसमे अधिष्ठापन करना चाहते है?
|
||||
DirDoesntExistTitle=फोल्डर मौजूद नही है
|
||||
DirDoesntExist=फोल्डर:%n%n%1%n%nमौजूद नही है. क्या आप ये फोल्डर बनाना चाहते है?
|
||||
|
||||
; "Select Components" wizard page
|
||||
WizardSelectComponents=सहयोगियोँ पसंद करे.
|
||||
SelectComponentsDesc=कोनसे सहयोगियोँ अधिष्ठापन करने है?
|
||||
SelectComponentsLabel2=जो सहयोगियोँ अधिष्ठापन करना है, उन्हें चयन करे; जिन्हें नही करना हो तो उन्हें साफ करे. जब आगे बढने के लिए तयार हो तो आगे बढो दबाए.
|
||||
FullInstallation=सम्पूर्ण अधिष्ठापन
|
||||
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
|
||||
CompactInstallation=मजबुत अधिष्ठापन
|
||||
CustomInstallation=रिवाजी अधिष्ठापन.
|
||||
NoUninstallWarningTitle=सहयोगियोँ मौजूद है.
|
||||
NoUninstallWarning=स्थापना को ये ज्ञात हुआ है की निम्नलिखित सहयोगियोँ पहेले से ही मोजूद है.:%n%n%1%n%nइन्हें डी-चयन करने से वे निस्कासन नही होगे.%n%nक्या आप ऐसे ही आगे बढ़ना चाहते है?
|
||||
ComponentSize1=%1 KB
|
||||
ComponentSize2=%1 MB
|
||||
ComponentsDiskSpaceMBLabel=इस चयन के साथ स्थापना वास्ते [mb] MB जगह चाहिए.
|
||||
|
||||
; "Select Additional Tasks" wizard page
|
||||
WizardSelectTasks=अतिरिक्त काम चयन करे.
|
||||
SelectTasksDesc=कोन से अतिरिक्त काम करने है?
|
||||
SelectTasksLabel2=[name] को अधिष्ठापन करते वक्त जो अतिरिक्त काम करने है उन्हें चयन करे और बाद में आगे बढो पे क्लिक करे.
|
||||
|
||||
; "Select Start Menu Folder" wizard page
|
||||
WizardSelectProgramGroup=सुरु मेनू फोल्डर चयन करे.
|
||||
SelectStartMenuFolderDesc=कार्यक्रम के छोटीरास्ता किधर रखने है?
|
||||
SelectStartMenuFolderLabel3=स्थापना कार्यक्रम के छोटीरास्ता निम्नलिखित सुरु-मेनू फोल्डर में डालेगा.
|
||||
SelectStartMenuFolderBrowseLabel=आगे बढने के लिए आगे बढो दबाए. यदि अलग फोल्डर में अधिष्ठापन करना है तो Browse दबाए.
|
||||
MustEnterGroupName=आपको फोल्डर का नाम तो डालना ही होगा.
|
||||
GroupNameTooLong=फोल्डर का नाम या पथ बहुत लंबा है.
|
||||
InvalidGroupName=फोल्डर का नाम वैध नही है.
|
||||
BadGroupName=फोल्डर नाम में ये वाले अक्षर नही डाल सकते:%n%n%1
|
||||
NoProgramGroupCheck2=सुरु मेनू फोल्डर नही बनाना है.
|
||||
|
||||
; "Ready to Install" wizard page
|
||||
WizardReady=अधिष्ठापन के लिए तयार
|
||||
ReadyLabel1=स्थापना अब [name] को आपके कल्पयन्त्रमें अधिष्ठापन करने के लिए तयार है.
|
||||
ReadyLabel2a=आगे बढने के लिए अधिष्ठापन दबाए, अगर कोई बदलाव करना है तो पिछे हटो दबाए.
|
||||
ReadyLabel2b=अधिष्ठापन में आगे बढने के लिए अधिष्ठापन दबाए.
|
||||
ReadyMemoUserInfo=प्रयोग कर्ता की सूचनाए:
|
||||
ReadyMemoDir=लक्ष्य सङ्ग्रहिका:
|
||||
ReadyMemoType=स्थापना का प्रकार:
|
||||
ReadyMemoComponents=चयन किये सहयोगियों:
|
||||
ReadyMemoGroup=सुरु मेनू फोल्डर:
|
||||
ReadyMemoTasks=अतिरिक्त काम:
|
||||
|
||||
; "Preparing to Install" wizard page
|
||||
WizardPreparing=अधिष्ठापन के लिए तैयारी कर रहा है.
|
||||
PreparingDesc=स्थापना [name] को आपके कल्पयन्त्र में डालने की तैयारीकर रहा है.
|
||||
PreviousInstallNotCompleted=पिछले कार्यक्रम का प्रतिस्थापन / अधिष्ठापन सही ढंग से पूरा नही हुआ था. आपको कल्पयन्त्र फिर सुरु करना पडेगा.%n%nकल्पयन्त्र फिर सुरु करने पश्चात आप फिर से [name] का अधिष्ठापन शुरू करे.
|
||||
CannotContinue=स्थापना आगे नही बढ़ सकता, कृपया रद्द करेँ बटन दबाएँ.
|
||||
ApplicationsFound=निचे वाली अनुप्रयोगो ने स्थापना द्वारा अपडेट किया जाने वाला फाइलों को इस्तेमाल किया है. आपको यह मसवरा दिया जा ता है कि आप स्थापना को यह अनुप्रयोगौं कि खुद ही बन्द करने कि इजाजत प्रदान करें.
|
||||
ApplicationsFound2=यह अनुप्रयोगो ने स्थापना द्वारा अपडेट किया जाने वाला फाइलों को इस्तेमाल किया है. आपको यह मसवरा दिया जा ता है कि आप स्थापना को यह अनुप्रयोगौं को खुद ही बन्द करने कि इजाजत प्रदान करें. अधिष्ठापन खतम होने के वाद, स्थापना यह अनुप्रयोग कों फिर सुरु करने कि कोसिस करेगा.
|
||||
CloseApplications=&खुद हि अनुप्रयोग कों बन्द करें
|
||||
DontCloseApplications=अनुप्रयोग कों बन्द &नहि करें
|
||||
ErrorCloseApplications=स्थापना खुद हि सभी अनुप्रयोगों को बन्द नहि कर सका. आपको यह मसवरा दिया जाता है कि स्थापना ने अपडेट करने वाली फाइलों को इस्तमाल कर रहे अनुप्रयोगौं को आगे बढ्ने से पहले आप खुद ही बन्द करें.
|
||||
|
||||
; "Installing" wizard page
|
||||
WizardInstalling=अधिष्ठापन हो रहा है.
|
||||
InstallingLabel=जब तक स्थापना आपके कल्पयन्त्र में [name] अधिष्ठापन करता है, उस वख्त तक कृपया प्रतीक्षा करे.
|
||||
|
||||
; "Setup Completed" wizard page
|
||||
FinishedHeadingLabel=[name] स्थापना कि कार्य पूरा हो रहा है.
|
||||
FinishedLabelNoIcons=स्थापना ने [name] को आपके कल्पयन्त्र में सफलतापूर्वक अधिष्ठापन कर दिया है.
|
||||
FinishedLabel=स्थापना ने [name] आपके कल्पयन्त्रमें अधिष्ठापन कर दिया है. आप उपयुक्त प्रतिमा पे क्लिक कर के कभी भी ये कार्यक्रम शुरू कर सकते है.
|
||||
ClickFinish=स्थापना से बाहर निकलने वास्ते समाप्त पे क्लिक करे.
|
||||
FinishedRestartLabel=[name] का अधिष्ठापन पूरा करने वास्ते कल्पयन्त्र फिर सुरु करना बेहद जरूरी है. %n%nक्या आप अभी रिसुरु करना चाहते है?
|
||||
FinishedRestartMessage=[name] का अधिष्ठापन पूरा करने हेतु कल्पयन्त्र फिर सुरु करना बेहद जरूरी है.%n%nक्या आप अभी फिर सुरु करना चाहते है?
|
||||
ShowReadmeCheck=हाँ मुझे हमें पढो file देखनी है.
|
||||
YesRadio=&हाँ, कल्पयन्त्र फिर सुरु कर दो.
|
||||
NoRadio=&नही मै अपना कल्पयन्त्र स्वयं बाद में फिर सुरु करूँगा.
|
||||
; used for example as 'Run MyProg.exe'
|
||||
RunEntryExec=रन %1
|
||||
; used for example as 'View Readme.txt'
|
||||
RunEntryShellExec=देखे %1
|
||||
|
||||
; "Setup Needs the Next Disk" stuff
|
||||
ChangeDiskTitle=स्थापना के लिए अगली डिस्क चाहिए.
|
||||
SelectDiskLabel2=कृपया डिस्क %1 डालके ठीक दबाए.%n%nयदि इस डिस्क की फाइल नही मिलती तो सही पथ बताए या ब्राउज़ पे क्लिक करे.
|
||||
PathLabel=पथ:
|
||||
FileNotInDir2=फाइल "%1" को "%2" में ढुढ नही पाए. कृपया सही डिस्क डाले या अलग फोल्डर चयन करे.
|
||||
SelectDirectoryLabel=अगली डिस्क का पता बताए.
|
||||
|
||||
; *** Installation phase messages
|
||||
SetupAborted=स्थापना पूरा नही हो पाया.%n%nकृपया त्रुटी ठीक करे और फिर से प्रयास करे.
|
||||
EntryAbortRetryIgnore=फिर से प्रयास करने वास्ते Retry दबाए, यदि ऐसे ही आगे बढ़ना है तो Ignore दबाए, या तो स्थापना रद्द करेँ करने वास्ते Abort दबाए.
|
||||
|
||||
; *** Installation status messages
|
||||
StatusClosingApplications=अनुप्रयोगकों बन्द किया जा रहा है.
|
||||
StatusCreateDirs=सङ्ग्रहिका बना रहा है...
|
||||
StatusExtractFiles=फाइल उत्खनन कर रहा है...
|
||||
StatusCreateIcons=छोटीरास्ता बना रहा है...
|
||||
StatusCreateIniEntries=INI एंट्री बना रहा है...
|
||||
StatusCreateRegistryEntries=पञ्जीका एंट्री बना रहा है...
|
||||
StatusRegisterFiles=फाइल पञ्जिकृत कर रहा है...
|
||||
StatusSavingUninstall=निस्कासन की सुचनाए बचतकर रहा है...
|
||||
StatusRunProgram=अधिष्ठापन पूरा कर रहा है...
|
||||
StatusRestartingApplications=अनुप्रयोगकों कि फिर सुरुवात
|
||||
StatusRollback=बदलावों को पिछे हट्ने कि काम कर रहा है...
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorInternal2=आंतरिक त्रुटी: %1
|
||||
ErrorFunctionFailedNoCode=%1 विफल
|
||||
ErrorFunctionFailed=%1 विफल; कोड %2
|
||||
ErrorFunctionFailedWithMessage=%1 विफल; कोड %2.%n%3
|
||||
ErrorExecutingProgram=फाइल को कार्यान्वयन नही कर पा रहा:%n%1
|
||||
|
||||
; *** Registry errors
|
||||
ErrorRegOpenKey=पञ्जीका कुञ्जी खोलते वक्त त्रुटी:%n%1\%2
|
||||
ErrorRegCreateKey=पञ्जीका कुञ्जी बनाते वक्त त्रुटी:%n%1\%2
|
||||
ErrorRegWriteKey=पञ्जीका कुञ्जी में लिखते वक्त त्रुटी:%n%1\%2
|
||||
|
||||
; *** INI errors
|
||||
ErrorIniEntry=फ़ाइल "%1" में INI एंट्री डालते वक्त त्रुटी.
|
||||
|
||||
; *** File copying errors
|
||||
FileAbortRetryIgnore=फिर से प्रयास करने हेतु Retry बटन दबाए, यदि ऐसे ही आगे बढ़ना है तो Ignore दबाए(हम ऐसा सुजाव नही देते),या तो Abort दबाए अधिष्ठापन को रद्द करेँ करने हेतु.
|
||||
FileAbortRetryIgnore2=फिर से प्रयास करने हेतु Retry बटन दबाए, यदि ऐसे ही आगे बढ़ना है तो Ignore दबाए(हम ऐसा सुजाव नही देते),या तो Abort दबाए अधिष्ठापन को रद्द करेँ करने हेतु.
|
||||
SourceIsCorrupted=श्रोत फ़ाइल में गडबड है.
|
||||
SourceDoesntExist=श्रोत फाइल "%1" मौजूद ही नही है.
|
||||
ExistingFileReadOnly=मौजदा फ़ाइल सिर्फ-पढो है.%n%n आप Retry पे क्लिक करे, उसका सिर्फ-पढो attribute हटाने के लिए और फिर दोबारा प्रयास करे. यदि इस फाइल को छोड़ देना है तो Ignore, और यदि अधिष्ठापन रद्द करेँ करना है तो Abort बटन दबाए.
|
||||
ErrorReadingExistingDest=मौजदा फाइल को पढते वक्त त्रुटी:
|
||||
FileExists=फाइल पहेले से मौजूद है.%n%nक्या आप उसको ओवर-राईट करना चाहते हो?
|
||||
ExistingFileNewer=मौजूदा फाइल स्थापना फ़ाइल से नई है. हमारा सुजाव है की आप इसे रखे.%n%nक्या आप फ़ाइल को रखना चाहते है?
|
||||
ErrorChangingAttr=मौजूदा फाइल के एट्रीब्यूट बदलते वक्त त्रुटी:
|
||||
ErrorCreatingTemp=फ़ाइल बनाते वख्त त्रुटी:
|
||||
ErrorReadingSource=श्रोत फाइल खोलते वक्त त्रुटी:
|
||||
ErrorCopying=फ़ाइल प्रति करने का प्रयास करते वक्त त्रुटी:
|
||||
ErrorReplacingExistingFile=मौजूद फाइल को प्रतिस्थापना करते वक्त त्रुटी:
|
||||
ErrorRestartReplace=प्रतिस्थापन कि फिर से सुरुवात विफल रहा:
|
||||
ErrorRenamingTemp=सङ्ग्रहिका में फाइल का नाम बदलते वक्त त्रुटी हुई:
|
||||
ErrorRegisterServer=इस को पञ्जिकृत नही कर पा रहा DLL/OCX: %1
|
||||
ErrorRegSvr32Failed=RegSvr32 असफल हो गयी, बाहर जाने कोड %1 के साथ
|
||||
ErrorRegisterTypeLib=इस टाइप लाइब्रेरी को पंजीकृत नही कर पा रहा: %1
|
||||
|
||||
; *** Post-installation errors
|
||||
ErrorOpeningReadme=मुझे पढो फ़ाइल खोलते वक्त त्रुटी हुई.
|
||||
ErrorRestartingComputer=स्थापना कल्पयन्त्र को फिर सुरु करने में असफल रहा. कृपया आप ही इसको फिर सुरु करे.
|
||||
|
||||
; *** Uninstaller messages
|
||||
UninstallNotFound=फाइल "%1" मौजूद ही नही है. निस्कासन करना असंभव.
|
||||
UninstallOpenError=फ़ाइल "%1" खुल नही रही. निस्कासन करना असंभव.
|
||||
UninstallUnsupportedVer=निस्कासन लोग फ़ाइल "%1" जिस फोर्मेट में है उसे हम पहचान नही पा रहे. आगे बढ़ना नामुमकिन.
|
||||
UninstallUnknownEntry=निस्कासन लोग में एक अज्ञात प्रविष्टी (%1)मिली.
|
||||
ConfirmUninstall=क्या पक्का आप %1 को निस्कासन करना चाहते हो?
|
||||
UninstallOnlyOnWin64=केवल 64-bit Windows से ही इसे निस्कासन किया जा सकता है.
|
||||
OnlyAdminCanUninstall=केवल प्रशासक खातों से ही इसे निस्कासन किया जा सकता है..
|
||||
UninstallStatusLabel=जब तक %1 नही हड्ता, धैर्य रखे.
|
||||
UninstalledAll=%1 सफलतापूर्वक निस्कासन हुआ.
|
||||
UninstalledMost=%1 निस्कासन पूरा हुआ.%n%nकुछ तत्वों को निकाल नही पाए लेकिन आप उन्हें अपनि तरह से हटा सकते हो.
|
||||
UninstalledAndNeedsRestart=%1 का निस्कासन पूरा करने वास्ते कल्पयन्त्र को फिर सुरु करना जरूरी है.%n%nक्या अभी फिर सुरु करे?
|
||||
UninstallDataCorrupted=%1 फ़ाइल में त्रुटी. निस्कासन नामुमकिन.
|
||||
|
||||
; *** Uninstallation phase messages
|
||||
ConfirmDeleteSharedFileTitle=क्या शेरेड-फाइल को निकाल देना है?
|
||||
ConfirmDeleteSharedFile2=प्रणाली से ये ज्ञात होता है की निम्नलिखिती शेरेड-फ़ाइल अब आगे इस्तेमाल में नही आएगी. क्या आप उन्हें भी निस्कासन करना चाहते है?%n%n यदि कोई अन्य कार्यक्रम इन फाइल पे आधारित है तो वो शायद इन्हें निकाल देने पर ढंग से काम ना भी करे. यदि आप फैसला नही कर पा रहे तो 'नही' पे क्लिक करे. इन फाइल को कल्पयन्त्र में पड़े रहेने दोगे तो भी कोई नुकसान नही होगा.
|
||||
SharedFileNameLabel=फाइल नाम:
|
||||
SharedFileLocationLabel=पता:
|
||||
WizardUninstalling=निस्कासन स्थिति
|
||||
StatusUninstalling=निस्कासन हो रहा है %1...
|
||||
|
||||
; *** Shutdown block reasons
|
||||
ShutdownBlockReasonInstallingApp= %1 कि अधिष्ठआपन हो रही है.
|
||||
ShutdownBlockReasonUninstallingApp=%1 कि निस्कासन हो रही है.
|
||||
|
||||
; The custom messages below aren't used by Setup itself, but if you make
|
||||
; use of them in your scripts, you'll want to translate them.
|
||||
|
||||
[CustomMessages]
|
||||
|
||||
NameAndVersion=%1 संस्करण %2
|
||||
AdditionalIcons=अतिरिक्त प्रतिमा:
|
||||
CreateDesktopIcon=डेस्कटॉप प्रतिमा बनाए
|
||||
CreateQuickLaunchIcon=जल्दि चलो प्रतिमा बनाए
|
||||
ProgramOnTheWeb=%1 इन्टरनेट पे
|
||||
UninstallProgram=निस्कासन करे %1
|
||||
LaunchProgram=लोंच करे %1
|
||||
AssocFileExtension=%1 को %2 फ़ाइल एक्सटेंशन के साथ आबद्ध करे
|
||||
AssocingFileExtension=%1 को %2 फ़ाइल एक्सटेंशन के साथ आबद्ध कर रहा है....
|
||||
AutoStartProgramGroupDescription=सुरुवात
|
||||
AutoStartProgram=%1 को %2 फ़ाइल एक्सटेंशन के साथ आबद्ध कर रहा है....
|
||||
AddonHostProgramNotFound=आपने चयन किया हुआफोल्डर में %1 नही मिला. %n%nक्या आप किसि हालत में यस कि निरन्तरता रख्ना चाहते है ?
|
||||
@@ -0,0 +1,350 @@
|
||||
; *** Inno Setup version 6.4.0+ Indonesian messages ***
|
||||
;
|
||||
; Untuk mengunduh terjemahan kontribusi-pengguna dari berkas ini, buka:
|
||||
; http://www.jrsoftware.org/files/istrans/
|
||||
;
|
||||
; Alih bahasa oleh: MozaikTM (mozaik.tm@gmail.com)
|
||||
|
||||
[LangOptions]
|
||||
LanguageName=Bahasa Indonesia
|
||||
LanguageID=$0421
|
||||
LanguageCodePage=0
|
||||
|
||||
[Messages]
|
||||
SetupAppTitle=Instalasi
|
||||
SetupWindowTitle=Instalasi - %1
|
||||
UninstallAppTitle=Pelepas
|
||||
UninstallAppFullTitle=Pelepasan %1
|
||||
|
||||
InformationTitle=Informasi
|
||||
ConfirmTitle=Konfirmasi
|
||||
ErrorTitle=Galat
|
||||
|
||||
SetupLdrStartupMessage=Kami akan memasang %1. Teruskan?
|
||||
LdrCannotCreateTemp=Tidak dapat membuat berkas sementara. Batal memasang
|
||||
LdrCannotExecTemp=Tidak dapat menjalankan berkas di direktori sementara. Batal memasang
|
||||
|
||||
LastErrorMessage=%1.%n%nGalat %2: %3
|
||||
SetupFileMissing=Berkas %1 hilang dari direktori instalasi. Silakan koreksi masalah atau dapatkan salinan program yang baru.
|
||||
SetupFileCorrupt=Berkas pemandu telah rusak. Silakan dapatkan salinan program yang baru.
|
||||
SetupFileCorruptOrWrongVer=Berkas pemandu telah rusak, atau tidak cocok dengan versi pemandu ini. Silakan koreksi masalah atau dapatkan salinan program yang baru.
|
||||
InvalidParameter=Parameter tak sah terdapat pada baris perintah: %n%n%1
|
||||
SetupAlreadyRunning=Pemandu sudah berjalan.
|
||||
WindowsVersionNotSupported=Program ini tidak mendukung versi Windows yang berjalan pada komputer Anda.
|
||||
WindowsServicePackRequired=Program ini memerlukan %1 Service Pack %2 atau yang terbaru.
|
||||
NotOnThisPlatform=Program ini tidak akan berjalan pada %1.
|
||||
OnlyOnThisPlatform=Program ini harus dijalankan pada %1.
|
||||
OnlyOnTheseArchitectures=Program ini hanya bisa dipasang pada versi Windows yang didesain untuk arsitektur prosesor berikut:%n%n%1
|
||||
WinVersionTooLowError=Program ini memerlukan %1 versi %2 atau yang terbaru.
|
||||
WinVersionTooHighError=Program ini tidak dapat dipasang pada %1 versi %2 atau yang terbaru.
|
||||
AdminPrivilegesRequired=Anda harus masuk sebagai seorang administrator saat memasang program ini.
|
||||
PowerUserPrivilegesRequired=Anda harus masuk sebagai seorang administrator atau anggota grup Power Users saat memasang program ini.
|
||||
SetupAppRunningError=Kami mendeteksi bahwa %1 sedang berjalan.%n%nSilakan tutup semua instansi bersangkutan, lalu klik OK untuk meneruskan, atau Cancel untuk keluar.
|
||||
UninstallAppRunningError=Pelepas mendeteksi bahwa %1 sedang berjalan.%n%nSilakan tutup semua instansi bersangkutan, lalu klik OK untuk meneruskan, atau Cancel untuk keluar.
|
||||
|
||||
;Inno6
|
||||
PrivilegesRequiredOverrideTitle=Pilih Mode Instalasi
|
||||
PrivilegesRequiredOverrideInstruction=Pilih mode instalasi
|
||||
PrivilegesRequiredOverrideText1=%1 bisa dipasang untuk semua pengguna (perlu izin administratif), atau hanya Anda.
|
||||
PrivilegesRequiredOverrideText2=%1 bisa dipasang hanya untuk Anda, atau semua pengguna (perlu izin administratif).
|
||||
PrivilegesRequiredOverrideAllUsers=Pasang untuk &semua pengguna
|
||||
PrivilegesRequiredOverrideAllUsersRecommended=Pasang untuk &semua pengguna (disarankan)
|
||||
PrivilegesRequiredOverrideCurrentUser=Pasang &hanya untuk saya
|
||||
PrivilegesRequiredOverrideCurrentUserRecommended=Pasang &hanya untuk saya (disarankan)
|
||||
;Inno6
|
||||
|
||||
ErrorCreatingDir=Kami tidak dapat membuat direktori "%1"
|
||||
ErrorTooManyFilesInDir=Tidak dapat membuat berkas di direktori "%1" karena berisi terlalu banyak berkas
|
||||
|
||||
ExitSetupTitle=Keluar Pemandu
|
||||
ExitSetupMessage=Instalasi tidak lengkap. Bila Anda keluar sekarang, program takkan terpasang.%n%nAnda bisa menjalankan Pemandu lagi lain kali untuk melengkapinya.%n%nKeluar?
|
||||
AboutSetupMenuItem=&Tentang Pemandu...
|
||||
AboutSetupTitle=Tentang Pemandu
|
||||
AboutSetupMessage=%1 versi %2%n%3%n%n%1 laman beranda:%n%4
|
||||
AboutSetupNote=
|
||||
TranslatorNote=
|
||||
|
||||
ButtonBack=&Kembali
|
||||
ButtonNext=&Maju
|
||||
ButtonInstall=&Pasang
|
||||
ButtonOK=Oke
|
||||
ButtonCancel=Batal
|
||||
ButtonYes=&Ya
|
||||
ButtonYesToAll=Y&a semuanya
|
||||
ButtonNo=&Tidak
|
||||
ButtonNoToAll=T&idak semuanya
|
||||
ButtonFinish=&Selesai
|
||||
ButtonBrowse=&Cari...
|
||||
ButtonWizardBrowse=C&ari...
|
||||
ButtonNewFolder=&Buat Map Baru
|
||||
|
||||
SelectLanguageTitle=Pilih Bahasa Pemandu
|
||||
SelectLanguageLabel=Pilih bahasa untuk digunakan ketika memasang.
|
||||
|
||||
ClickNext=Klik Maju untuk meneruskan, atau Batal untuk keluar.
|
||||
BeveledLabel=
|
||||
BrowseDialogTitle=Cari Map
|
||||
BrowseDialogLabel=Pilih map dari daftar berikut, lalu klik OK.
|
||||
NewFolderName=Map Baru
|
||||
|
||||
WelcomeLabel1=Selamat datang di Pemandu Instalasi [name]
|
||||
WelcomeLabel2=Kami akan memasang [name/ver] pada komputer Anda.%n%nDisarankan untuk menutup semua aplikasi lainnya sebelum meneruskan.
|
||||
|
||||
WizardPassword=Kata Sandi
|
||||
PasswordLabel1=Instalasi ini dilindungi kata sandi.
|
||||
PasswordLabel3=Silakan masukkan kata sandi, lalu klik Maju untuk meneruskan. Kata sandi bersifat sensitif-kapitalisasi.
|
||||
PasswordEditLabel=&Kata Sandi:
|
||||
IncorrectPassword=Kata sandi yang Anda masukkan salah. Silakan coba lagi.
|
||||
|
||||
WizardLicense=Kesepakatan Lisensi
|
||||
LicenseLabel=Silakan baca informasi berikut sebelum meneruskan.
|
||||
LicenseLabel3=Silakan baca Kesepakatan Lisensi berikut. Anda harus setuju dengan syarat dari kesepakatan ini sebelum meneruskan instalasi.
|
||||
LicenseAccepted=Saya &setujui kesepakatan ini
|
||||
LicenseNotAccepted=Saya &tidak setuju kesepakatan ini
|
||||
|
||||
WizardInfoBefore=Informasi
|
||||
InfoBeforeLabel=Silakan baca informasi penting berikut sebelum meneruskan.
|
||||
InfoBeforeClickLabel=Saat Anda siap meneruskan instalasi, klik Maju.
|
||||
WizardInfoAfter=Informasi
|
||||
InfoAfterLabel=Silakan baca informasi penting berikut sebelum meneruskan.
|
||||
InfoAfterClickLabel=Saat Anda siap meneruskan instalasi, klik Maju.
|
||||
|
||||
WizardUserInfo=Informasi Pengguna
|
||||
UserInfoDesc=Silakan masukkan informasi Anda.
|
||||
UserInfoName=&Nama Pengguna:
|
||||
UserInfoOrg=&Organisasi:
|
||||
UserInfoSerial=&Nomor Seri:
|
||||
UserInfoNameRequired=Wajib memasukkan nama.
|
||||
|
||||
WizardSelectDir=Pilih Lokasi Tujuan
|
||||
SelectDirDesc=Di manakah [name] sebaiknya dipasang?
|
||||
SelectDirLabel3=Kami akan memasang [name] ke dalam map berikut.
|
||||
SelectDirBrowseLabel=Untuk meneruskan, klik Maju. Bila Anda ingin memilih map lain, klik Cari.
|
||||
;Inno6
|
||||
DiskSpaceGBLabel=Diperlukan sedikitnya [gb] GB ruang bebas.
|
||||
;Inno6
|
||||
DiskSpaceMBLabel=Diperlukan sedikitnya [mb] MB ruang bebas.
|
||||
CannotInstallToNetworkDrive=Kami tidak bisa memasang ke diska jaringan.
|
||||
CannotInstallToUNCPath=Kami tidak bisa memasang ke alamat UNC.
|
||||
InvalidPath=Anda wajib memasukkan alamat lengkap dengan huruf diska; contohnya:%n%nC:\APP%n%natau alamat UNC dalam bentuk:%n%n\\server\share
|
||||
InvalidDrive=Diska atau alamat UNC yang Anda pilih tidak ada atau tidak dapat diakses. Silakan pilih yang lain.
|
||||
DiskSpaceWarningTitle=Ruang Bebas Tidak Cukup
|
||||
DiskSpaceWarning=Kami memerlukan sedikitnya %1 KB ruang bebas untuk memasang, namun diska yang Anda pilih hanya memiliki %2 KB tersedia.%n%nMaju terus?
|
||||
DirNameTooLong=Nama map atau alamat terlalu panjang.
|
||||
InvalidDirName=Nama map tidak sah.
|
||||
BadDirName32=Nama map dilarang berisi karakter-karakter berikut:%n%n%1
|
||||
DirExistsTitle=Map Sudah Ada
|
||||
DirExists=Map:%n%n%1%n%nsudah ada. Tetap pasang di map tersebut?
|
||||
DirDoesntExistTitle=Map Tidak Ada
|
||||
DirDoesntExist=Map:%n%n%1%n%ntidak ada. Buat map?
|
||||
|
||||
WizardSelectComponents=Pilih Komponen
|
||||
SelectComponentsDesc=Komponen mana sajakah yang sebaiknya dipasang?
|
||||
SelectComponentsLabel2=Centang komponen yang Anda inginkan; hapus centang dari komponen yang tidak Anda inginkan. Klik Maju saat Anda siap meneruskan.
|
||||
FullInstallation=Instalasi penuh
|
||||
CompactInstallation=Instalasi padat
|
||||
CustomInstallation=Instalasi kustom
|
||||
NoUninstallWarningTitle=Komponen Terpasang
|
||||
NoUninstallWarning=Kami mendeteksi bahwa komponen berikut telah terpasang pada komputer Anda:%n%n%1%n%nMembatalkan pilihan atas komponen berikut bukan berarti melepasnya.%n%nMaju terus?
|
||||
ComponentSize1=%1 KB
|
||||
ComponentSize2=%1 MB
|
||||
;Inno6
|
||||
ComponentsDiskSpaceGBLabel=Pilihan saat ini memerlukan sedikitnya [gb] GB ruang bebas.
|
||||
;Inno6
|
||||
ComponentsDiskSpaceMBLabel=Pilihan saat ini memerlukan sedikitnya [mb] MB ruang bebas.
|
||||
|
||||
WizardSelectTasks=Pilih Tugas Tambahan
|
||||
SelectTasksDesc=Tugas tambahan mana sajakah yang sebaiknya dijalankan?
|
||||
SelectTasksLabel2=Pilih tugas tambahan yang Anda ingin kami jalankan ketika memasang [name], lalu klik Maju.
|
||||
|
||||
WizardSelectProgramGroup=Pilih Map Menu Start
|
||||
SelectStartMenuFolderDesc=Di manakah sebaiknya kami letakkan pintasan program?
|
||||
SelectStartMenuFolderLabel3=Kami akan membuat pintasan program di map Menu Start berikut.
|
||||
SelectStartMenuFolderBrowseLabel=Untuk meneruskan, klik Maju. Bila Anda ingin memilih map lain, klik Cari.
|
||||
MustEnterGroupName=Anda wajib memasukkan nama map.
|
||||
GroupNameTooLong=Nama map atau alamat terlalu panjang.
|
||||
InvalidGroupName=Nama map tidak sah.
|
||||
BadGroupName=Nama map dilarang berisi karakter-karakter berikut:%n%n%1
|
||||
NoProgramGroupCheck2=&Jangan buat map Menu Start
|
||||
|
||||
WizardReady=Siap Memasang
|
||||
ReadyLabel1=Kami siap untuk memulai instalasi [name] pada komputer Anda.
|
||||
ReadyLabel2a=Klik Pasang untuk meneruskan instalasi, atau klik Kembali bila Anda ingin menilik atau mengubah setelan.
|
||||
ReadyLabel2b=Klik Pasang untuk meneruskan instalasi.
|
||||
ReadyMemoUserInfo=Informasi pengguna:
|
||||
ReadyMemoDir=Lokasi tujuan:
|
||||
ReadyMemoType=Tipe instalasi:
|
||||
ReadyMemoComponents=Komponen terpilih:
|
||||
ReadyMemoGroup=Map Menu Start:
|
||||
ReadyMemoTasks=Tugas Tambahan:
|
||||
|
||||
;Inno6
|
||||
DownloadingLabel=Mengunduh berkas tambahan...
|
||||
ButtonStopDownload=&Setop Unduhan
|
||||
StopDownload=Anda yakin ingin berhenti mengunduh?
|
||||
ErrorDownloadAborted=Unduhan dibatalkan
|
||||
ErrorDownloadFailed=Gagal mengunduh: %1 %2
|
||||
ErrorDownloadSizeFailed=Gagal mendapatkan ukuran: %1 %2
|
||||
ErrorFileHash1=Ceksum berkas gagal: %1
|
||||
ErrorFileHash2=Ceksum berkas tidak sah: seharusnya %1, yang kami dapatkan %2
|
||||
ErrorProgress=Langkah tidak sah: %1 dari %2
|
||||
ErrorFileSize=Ukuran berkas tidak sah: seharusnya %1, yang kami dapatkan %2
|
||||
|
||||
; *** TExtractionWizardPage wizard page and Extract7ZipArchive
|
||||
ExtractionLabel=Mengektrasi berkas tambahan...
|
||||
ButtonStopExtraction=&Hentikan ekstrasi
|
||||
StopExtraction=Anda yakin ingin menghentikan ekstrasi?
|
||||
ErrorExtractionAborted=Ekstrasi dibatalkan
|
||||
ErrorExtractionFailed=Ekstraksi gagal: %1
|
||||
;Inno6
|
||||
|
||||
WizardPreparing=Bersiap Memasang
|
||||
PreparingDesc=Kami sedang bersiap memasang [name] pada komputer Anda.
|
||||
PreviousInstallNotCompleted=Instalasi/pelepasan dari program sebelumnya tidak lengkap. Anda perlu memulai ulang komputer untuk melengkapinya nanti.%n%nSetelah itu, jalankan Pemandu kembali untuk melengkapi instalasi [name].
|
||||
CannotContinue=Kami tidak bisa meneruskan. Klik Batal untuk keluar.
|
||||
ApplicationsFound=Aplikasi berikut tengah memakai berkas-berkas yang perlu kami perbarui. Disarankan agar Anda mengizinkan kami untuk menutupnya secara otomatis.
|
||||
ApplicationsFound2=Aplikasi berikut tengah memakai berkas-berkas yang perlu kami perbarui. Disarankan agar Anda mengizinkan kami untuk menutupnya secara otomatis. Selengkapnya memasang, kami akan berusaha memulai ulang aplikasi-aplikasi tersebut.
|
||||
CloseApplications=&Otomatis tutup aplikasi
|
||||
DontCloseApplications=&Jangan tutup aplikasi
|
||||
ErrorCloseApplications=Kami tidak dapat menutup semua aplikasi secara otomatis. Disarankan agar Anda menutup semua aplikasi yang memakai berkas-berkas yang perlu kami perbarui sebelum meneruskan.
|
||||
;Inno6
|
||||
PrepareToInstallNeedsRestart=Kami perlu memulai ulang komputer Anda. Setelah itu, jalankan Pemandu kembali untuk melengkapi pemasangan [name].%n%nMulai ulang sekarang?
|
||||
;Inno6
|
||||
|
||||
WizardInstalling=Memasang
|
||||
InstallingLabel=Silakan tunggu selagi kami memasang [name] pada komputer Anda.
|
||||
|
||||
FinishedHeadingLabel=Mengakhiri Instalasi [name]
|
||||
FinishedLabelNoIcons=Kami telah selesai memasang [name] pada komputer Anda.
|
||||
FinishedLabel=Kami telah selesai memasang [name] pada komputer Anda. Aplikasi tersebut bisa dijalankan dengan cara memilih pintasan yang terpasang.
|
||||
ClickFinish=Klik Selesai untuk menutup instalasi.
|
||||
FinishedRestartLabel=Demi melengkapi instalasi [name], kami perlu memulai ulang komputer Anda. Lakukan sekarang?
|
||||
FinishedRestartMessage=Demi melengkapi instalasi [name], kami perlu memulai ulang komputer Anda.%n%nLakukan sekarang?
|
||||
ShowReadmeCheck=Ya, saya ingin melihat berkas README
|
||||
YesRadio=&Ya, mulai ulang komputer sekarang
|
||||
NoRadio=&Tidak, saya akan memulai ulang komputer nanti
|
||||
RunEntryExec=Jalankan %1
|
||||
RunEntryShellExec=Lihat %1
|
||||
|
||||
ChangeDiskTitle=Kami Memerlukan Diska Sambungan
|
||||
SelectDiskLabel2=Silakan masukkan Diska %1 dan klik OK.%n%nBila berkas-berkas di dalam diska ini dapat ditemukan di map lain selain yang ditampilkan di bawah, masukkan alamat yang benar atau klik Cari.
|
||||
PathLabel=&Alamat:
|
||||
FileNotInDir2=Berkas "%1" tidak dapat ditemukan di "%2". Silakan masukkan diska yang benar atau pilih map lain.
|
||||
SelectDirectoryLabel=Silakan tentukan lokasi diska berikutnya.
|
||||
|
||||
SetupAborted=Instalasi tidak lengkap.%n%nSilakan koreksi masalah dan jalankan Pemandu kembali.
|
||||
;Inno6
|
||||
AbortRetryIgnoreSelectAction=Pilih tindakan
|
||||
AbortRetryIgnoreRetry=&Coba lagi
|
||||
AbortRetryIgnoreIgnore=&Abaikan galat dan teruskan
|
||||
AbortRetryIgnoreCancel=Batalkan pemasangan
|
||||
;Inno6
|
||||
|
||||
StatusClosingApplications=Menutup aplikasi...
|
||||
StatusCreateDirs=Membuat direktori...
|
||||
StatusExtractFiles=Mengekstrak berkas...
|
||||
StatusCreateIcons=Membuat pintasan...
|
||||
StatusCreateIniEntries=Membuat catatan INI...
|
||||
StatusCreateRegistryEntries=Membuat catatan Registry...
|
||||
StatusRegisterFiles=Meregistrasi berkas...
|
||||
StatusSavingUninstall=Menyimpan informasi pelepas...
|
||||
StatusRunProgram=Mengakhiri instalasi...
|
||||
StatusRestartingApplications=Menjalankan ulang aplikasi...
|
||||
StatusRollback=Membatalkan perubahan...
|
||||
|
||||
ErrorInternal2=Galat internal: %1
|
||||
ErrorFunctionFailedNoCode=%1 gagal
|
||||
ErrorFunctionFailed=%1 gagal; kode %2
|
||||
ErrorFunctionFailedWithMessage=%1 gagal; kode %2.%n%3
|
||||
ErrorExecutingProgram=Tidak dapat mengeksekusi berkas:%n%1
|
||||
|
||||
ErrorRegOpenKey=Galat membuka kunci Registry:%n%1\%2
|
||||
ErrorRegCreateKey=Galat membuat kunci Registry:%n%1\%2
|
||||
ErrorRegWriteKey=Galat menulis kunci Registry:%n%1\%2
|
||||
|
||||
ErrorIniEntry=Galat membuat catatan INI dalam berkas "%1".
|
||||
|
||||
;Inno6
|
||||
FileAbortRetryIgnoreSkipNotRecommended=&Lewati berkas ini (tidak disarankan)
|
||||
FileAbortRetryIgnoreIgnoreNotRecommended=&Abaikan galat dan teruskan (tidak disarankan)
|
||||
;Inno6
|
||||
|
||||
SourceIsCorrupted=Berkas asal telah rusak
|
||||
SourceDoesntExist=Berkas asal "%1" tidak ada
|
||||
|
||||
;Inno6
|
||||
ExistingFileReadOnly2=Berkas yang sudah ada tidak bisa ditimpa karena telah ditandai hanya-baca.
|
||||
ExistingFileReadOnlyRetry=&Hapus atribut hanya-baca dan coba lagi
|
||||
ExistingFileReadOnlyKeepExisting=&Pertahankan berkas yang sudah ada
|
||||
ErrorReadingExistingDest=Terjadi galat saat berusaha membaca berkas yang sudah ada:
|
||||
FileExistsSelectAction=Pilih tindakan
|
||||
FileExists2=Berkas sudah ada.
|
||||
FileExistsOverwriteExisting=&Timpa berkas yang sudah ada
|
||||
FileExistsKeepExisting=&Pertahankan berkas yang sudah ada
|
||||
FileExistsOverwriteOrKeepAll=&Lakukan ini untuk konflik (bentrok) berikutnya
|
||||
ExistingFileNewerSelectAction=Pilih tindakan
|
||||
ExistingFileNewer2=Berkas yang sudah ada lebih baru dari yang akan kami coba pasang.
|
||||
ExistingFileNewerOverwriteExisting=&Timpa berkas yang sudah ada
|
||||
ExistingFileNewerKeepExisting=&Pertahankan berkas yang sudah ada (disarankan)
|
||||
ExistingFileNewerOverwriteOrKeepAll=&Lakukan ini untuk konflik (bentrok) berikutnya
|
||||
;Inno6
|
||||
ErrorReadingExistingDest=Terjadi galat saat berusaha membaca berkas yang sudah ada:
|
||||
ErrorChangingAttr=Terjadi galat saat berusaha mengubah atribusi berkas yang sudah ada:
|
||||
ErrorCreatingTemp=Terjadi galat saat berusaha membuat berkas di direktori tujuan:
|
||||
ErrorReadingSource=Terjadi galat saat berusaha membaca berkas asal:
|
||||
ErrorCopying=Terjadi galat saat berusaha menyalin berkas:
|
||||
ErrorReplacingExistingFile=Terjadi galat saat berusaha menimpa berkas yang sudah ada:
|
||||
ErrorRestartReplace=RestartReplace gagal:
|
||||
ErrorRenamingTemp=Terjadi galat saat berusaha mengubah nama berkas di direktori tujuan:
|
||||
ErrorRegisterServer=Tidak dapat meregistrasi DLL/OCX: %1
|
||||
ErrorRegSvr32Failed=RegSvr32 gagal dengan kode akhir %1
|
||||
ErrorRegisterTypeLib=Tidak dapat meregistrasi berkas referensi: %1
|
||||
|
||||
;Inno6
|
||||
UninstallDisplayNameMark=%1 (%2)
|
||||
UninstallDisplayNameMarks=%1 (%2, %3)
|
||||
UninstallDisplayNameMark32Bit=32-bita
|
||||
UninstallDisplayNameMark64Bit=64-bita
|
||||
UninstallDisplayNameMarkAllUsers=Semua pengguna
|
||||
UninstallDisplayNameMarkCurrentUser=Pengguna saat ini
|
||||
;Inno6
|
||||
|
||||
ErrorOpeningReadme=Terjadi galat saat berusaha membuka berkas README.
|
||||
ErrorRestartingComputer=Kami gagal memulai ulang komputer. Silakan lakukan secara manual.
|
||||
|
||||
UninstallNotFound=Berkas "%1" tidak ada. Tidak bisa melepas
|
||||
UninstallOpenError=Berkas "%1" tidak dapat dibuka. Tidak bisa melepas
|
||||
UninstallUnsupportedVer=Berkas catatan pelepas "%1" tidak dalam format yang kami kenali. Tidak bisa melepas
|
||||
UninstallUnknownEntry=Entri tak dikenal (%1) ditemukan dalam catatan pelepas
|
||||
ConfirmUninstall=Anda yakin ingin melepas %1 beserta semua komponennya?
|
||||
UninstallOnlyOnWin64=Instalasi ini hanya bisa dilepas pada Windows 64-bita.
|
||||
OnlyAdminCanUninstall=Instalasi ini hanya bisa dilepas oleh pengguna dengan izin administratif.
|
||||
UninstallStatusLabel=Silakan tunggu selagi %1 dihapus dari komputer Anda.
|
||||
UninstalledAll=%1 berhasil dihapus dari komputer Anda.
|
||||
UninstalledMost=Selesai melepas %1.%n%nBeberapa elemen tidak dapat dihapus. Anda bisa menghapusnya secara manual.
|
||||
UninstalledAndNeedsRestart=Untuk melengkapi pelepasan %1, komputer Anda perlu dimulai ulang.%n%nMulai ulang sekarang?
|
||||
UninstallDataCorrupted=Berkas "%1" rusak. Tidak bisa melepas
|
||||
|
||||
ConfirmDeleteSharedFileTitle=Hapus Berkas Bersama?
|
||||
ConfirmDeleteSharedFile2=Sistem mengindikasi bahwa berkas bersama di bawah ini tidak lagi dipakai oleh program mana pun. Apa Anda ingin agar kami menghapusnya?%n%nBila masih ada program yang memakainya dan berkas ini dihapus, program tersebut dapat tidak berfungsi dengan semestinya. Bila Anda ragu, pilih No. Membiarkan berkas ini pada sistem Anda takkan membahayakan.
|
||||
SharedFileNameLabel=Nama berkas:
|
||||
SharedFileLocationLabel=Lokasi:
|
||||
WizardUninstalling=Status Pelepasan
|
||||
StatusUninstalling=Melepas %1...
|
||||
|
||||
ShutdownBlockReasonInstallingApp=Memasang %1.
|
||||
ShutdownBlockReasonUninstallingApp=Melepas %1.
|
||||
|
||||
[CustomMessages]
|
||||
NameAndVersion=%1 versi %2
|
||||
AdditionalIcons=Pintasan tambahan:
|
||||
CreateDesktopIcon=Buat pintasan &desktop
|
||||
CreateQuickLaunchIcon=Buat pintasan Pelontar &Cepat
|
||||
ProgramOnTheWeb=%1 di Web
|
||||
UninstallProgram=Lepas %1
|
||||
LaunchProgram=Jalankan %1
|
||||
AssocFileExtension=&Kaitkan %1 dengan ekstensi berkas %2
|
||||
AssocingFileExtension=Mengaitkan %1 dengan ekstensi berkas %2...
|
||||
AutoStartProgramGroupDescription=Startup:
|
||||
AutoStartProgram=Otomatis jalankan %1
|
||||
AddonHostProgramNotFound=%1 tidak dapat ditemukan di map yang Anda pilih.%n%nMaju terus?
|
||||
@@ -0,0 +1,157 @@
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
|
||||
#define MyAppName "YTSage"
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0"
|
||||
#endif
|
||||
#define MyAppPublisher "oop7"
|
||||
#define MyAppURL "https://github.com/oop7/YTSage/"
|
||||
#ifndef MyAppExeName
|
||||
#define MyAppExeName "YTSage-ffmpeg.exe"
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#define SourceDir "..\dist\YTSage-FFmpeg"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{56997322-2A3A-4338-AEF1-C3C8BB28AC4F}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile=..\LICENSE
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
OutputBaseFilename=YTSage-v{#MyAppVersion}-ffmpeg-Setup
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
OutputDir=..\artifacts
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "arabic"; MessagesFile: "compiler:Languages\Arabic.isl"
|
||||
Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl"
|
||||
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
|
||||
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||
Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||
Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl"
|
||||
Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl"
|
||||
Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl"
|
||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||
Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"
|
||||
Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl"
|
||||
Name: "chinesesimplified"; MessagesFile: "Languages\Unofficial\ChineseSimplified.isl"
|
||||
Name: "hindi"; MessagesFile: "Languages\Unofficial\Hindi.islu"
|
||||
Name: "indonesian"; MessagesFile: "Languages\Unofficial\Indonesian.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
|
||||
Name: "addtopath"; Description: "Add ffmpeg to user PATH environment variable"; GroupDescription: "Additional options:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceDir}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; Tasks: addtopath; Check: NeedsAddPath('{app}')
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[Code]
|
||||
function NeedsAddPath(Param: string): boolean;
|
||||
var
|
||||
OrigPath: string;
|
||||
ParamExpanded: string;
|
||||
begin
|
||||
// Expand the setup constants like {app} from Param
|
||||
ParamExpanded := ExpandConstant(Param);
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', OrigPath) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
// Look for the path with leading and trailing semicolon
|
||||
Result := Pos(';' + UpperCase(ParamExpanded) + ';', ';' + UpperCase(OrigPath) + ';') = 0;
|
||||
if Result = True then
|
||||
Result := Pos(';' + UpperCase(ParamExpanded) + '\;', ';' + UpperCase(OrigPath) + ';') = 0;
|
||||
end;
|
||||
|
||||
procedure UpdateLanguageConfig;
|
||||
var
|
||||
LanguageCode: string;
|
||||
ConfigPath: string;
|
||||
FileContent: AnsiString;
|
||||
JsonContent: string;
|
||||
LangPattern: string;
|
||||
P_Start, P_End: Integer;
|
||||
begin
|
||||
if ActiveLanguage = 'english' then LanguageCode := 'en'
|
||||
else if ActiveLanguage = 'arabic' then LanguageCode := 'ar'
|
||||
else if ActiveLanguage = 'brazilianportuguese' then LanguageCode := 'pt-br'
|
||||
else if ActiveLanguage = 'french' then LanguageCode := 'fr'
|
||||
else if ActiveLanguage = 'german' then LanguageCode := 'de'
|
||||
else if ActiveLanguage = 'italian' then LanguageCode := 'it'
|
||||
else if ActiveLanguage = 'japanese' then LanguageCode := 'ja'
|
||||
else if ActiveLanguage = 'polish' then LanguageCode := 'pl'
|
||||
else if ActiveLanguage = 'portuguese' then LanguageCode := 'pt'
|
||||
else if ActiveLanguage = 'russian' then LanguageCode := 'ru'
|
||||
else if ActiveLanguage = 'spanish' then LanguageCode := 'es'
|
||||
else if ActiveLanguage = 'turkish' then LanguageCode := 'tr'
|
||||
else if ActiveLanguage = 'chinesesimplified' then LanguageCode := 'zh'
|
||||
else if ActiveLanguage = 'hindi' then LanguageCode := 'hi'
|
||||
else if ActiveLanguage = 'indonesian' then LanguageCode := 'id'
|
||||
else LanguageCode := 'en';
|
||||
|
||||
ConfigPath := ExpandConstant('{localappdata}\YTSage\data\ytsage_config.json');
|
||||
|
||||
if not DirExists(ExtractFilePath(ConfigPath)) then
|
||||
ForceDirectories(ExtractFilePath(ConfigPath));
|
||||
|
||||
if FileExists(ConfigPath) then
|
||||
begin
|
||||
if LoadStringFromFile(ConfigPath, FileContent) then
|
||||
begin
|
||||
JsonContent := String(FileContent);
|
||||
LangPattern := '"language": "';
|
||||
P_Start := Pos(LangPattern, JsonContent);
|
||||
if P_Start > 0 then
|
||||
begin
|
||||
P_Start := P_Start + Length(LangPattern);
|
||||
P_End := Pos('"', Copy(JsonContent, P_Start, Length(JsonContent)));
|
||||
if P_End > 0 then
|
||||
begin
|
||||
Delete(JsonContent, P_Start, P_End - 1);
|
||||
Insert(LanguageCode, JsonContent, P_Start);
|
||||
SaveStringToFile(ConfigPath, AnsiString(JsonContent), False);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end
|
||||
else
|
||||
begin
|
||||
JsonContent := '{' + #13#10 +
|
||||
' "language": "' + LanguageCode + '"' + #13#10 +
|
||||
'}';
|
||||
SaveStringToFile(ConfigPath, AnsiString(JsonContent), False);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
begin
|
||||
UpdateLanguageConfig();
|
||||
end;
|
||||
end;
|
||||
@@ -0,0 +1,135 @@
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
|
||||
#define MyAppName "YTSage"
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0"
|
||||
#endif
|
||||
#define MyAppPublisher "oop7"
|
||||
#define MyAppURL "https://github.com/oop7/YTSage/"
|
||||
#ifndef MyAppExeName
|
||||
#define MyAppExeName "YTSage.exe"
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#define SourceDir "..\dist\YTSage"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{AE618DBF-DD56-462D-9C09-2C2B7A41B201}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile=..\LICENSE
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
OutputBaseFilename=YTSage-v{#MyAppVersion}-Setup
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
OutputDir=..\artifacts
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "arabic"; MessagesFile: "compiler:Languages\Arabic.isl"
|
||||
Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl"
|
||||
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
|
||||
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||
Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||
Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl"
|
||||
Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl"
|
||||
Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl"
|
||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||
Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"
|
||||
Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl"
|
||||
Name: "chinesesimplified"; MessagesFile: "Languages\Unofficial\ChineseSimplified.isl"
|
||||
Name: "hindi"; MessagesFile: "Languages\Unofficial\Hindi.islu"
|
||||
Name: "indonesian"; MessagesFile: "Languages\Unofficial\Indonesian.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceDir}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[Code]
|
||||
procedure UpdateLanguageConfig;
|
||||
var
|
||||
LanguageCode: string;
|
||||
ConfigPath: string;
|
||||
FileContent: AnsiString;
|
||||
JsonContent: string;
|
||||
LangPattern: string;
|
||||
P_Start, P_End: Integer;
|
||||
begin
|
||||
if ActiveLanguage = 'english' then LanguageCode := 'en'
|
||||
else if ActiveLanguage = 'arabic' then LanguageCode := 'ar'
|
||||
else if ActiveLanguage = 'brazilianportuguese' then LanguageCode := 'pt-br'
|
||||
else if ActiveLanguage = 'french' then LanguageCode := 'fr'
|
||||
else if ActiveLanguage = 'german' then LanguageCode := 'de'
|
||||
else if ActiveLanguage = 'italian' then LanguageCode := 'it'
|
||||
else if ActiveLanguage = 'japanese' then LanguageCode := 'ja'
|
||||
else if ActiveLanguage = 'polish' then LanguageCode := 'pl'
|
||||
else if ActiveLanguage = 'portuguese' then LanguageCode := 'pt'
|
||||
else if ActiveLanguage = 'russian' then LanguageCode := 'ru'
|
||||
else if ActiveLanguage = 'spanish' then LanguageCode := 'es'
|
||||
else if ActiveLanguage = 'turkish' then LanguageCode := 'tr'
|
||||
else if ActiveLanguage = 'chinesesimplified' then LanguageCode := 'zh'
|
||||
else if ActiveLanguage = 'hindi' then LanguageCode := 'hi'
|
||||
else if ActiveLanguage = 'indonesian' then LanguageCode := 'id'
|
||||
else LanguageCode := 'en';
|
||||
|
||||
ConfigPath := ExpandConstant('{localappdata}\YTSage\data\ytsage_config.json');
|
||||
|
||||
if not DirExists(ExtractFilePath(ConfigPath)) then
|
||||
ForceDirectories(ExtractFilePath(ConfigPath));
|
||||
|
||||
if FileExists(ConfigPath) then
|
||||
begin
|
||||
if LoadStringFromFile(ConfigPath, FileContent) then
|
||||
begin
|
||||
JsonContent := String(FileContent);
|
||||
LangPattern := '"language": "';
|
||||
P_Start := Pos(LangPattern, JsonContent);
|
||||
if P_Start > 0 then
|
||||
begin
|
||||
P_Start := P_Start + Length(LangPattern);
|
||||
P_End := Pos('"', Copy(JsonContent, P_Start, Length(JsonContent)));
|
||||
if P_End > 0 then
|
||||
begin
|
||||
Delete(JsonContent, P_Start, P_End - 1);
|
||||
Insert(LanguageCode, JsonContent, P_Start);
|
||||
SaveStringToFile(ConfigPath, AnsiString(JsonContent), False);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end
|
||||
else
|
||||
begin
|
||||
JsonContent := '{' + #13#10 +
|
||||
' "language": "' + LanguageCode + '"' + #13#10 +
|
||||
'}';
|
||||
SaveStringToFile(ConfigPath, AnsiString(JsonContent), False);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
begin
|
||||
UpdateLanguageConfig();
|
||||
end;
|
||||
end;
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
SageTube - watch-first YouTube client
|
||||
|
||||
Search, browse, subscribe and stream in an embedded mpv player, with the full
|
||||
yt-dlp download feature set inherited from YTSage. Built with PySide6.
|
||||
|
||||
The version below is the single source of truth for the running application:
|
||||
the window title, the About dialog and the update checker all read it. It must
|
||||
match `version` in pyproject.toml and the repository's latest signed tag --
|
||||
this package's tag line continues YTSage's, which is why it starts at 5.x.
|
||||
"""
|
||||
|
||||
__version__ = "5.5.2"
|
||||
__author__ = "Houmeres"
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Core functionality modules for YTSage.
|
||||
|
||||
This package contains the core business logic and utility functions.
|
||||
"""
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Application update checks against SageTube's own releases
|
||||
=========================================================
|
||||
|
||||
SageTube is a fork of oop7/YTSage. It keeps upstream's git history, its
|
||||
internal `ytsage` package name and its inherited tags -- but it is a different
|
||||
program with a different release stream, and this module is the line between
|
||||
them.
|
||||
|
||||
**This checks SageTube. Nothing here touches upstream.** The three other
|
||||
updaters in this codebase legitimately track their own upstreams and are
|
||||
deliberately left alone:
|
||||
|
||||
- yt-dlp binary -> gui/ytsage_gui_dialogs/ytsage_dialogs_update.py
|
||||
- Deno runtime -> core/ytsage_deno.py
|
||||
- ffmpeg -> utils/ytsage_constants.py
|
||||
|
||||
They share only the word "update".
|
||||
|
||||
Gitea's release API is shaped like GitHub's -- `tag_name`, `body`, `html_url`,
|
||||
`draft`, `prerelease` -- so the consuming code needs no special cases. The
|
||||
repository is anonymously readable, so no token is involved.
|
||||
|
||||
Tag notes
|
||||
---------
|
||||
The repository carries upstream's tags up to `v5.3.0b` alongside SageTube's
|
||||
own. `packaging` reads a trailing `b` as a beta marker (`5.3.0b` -> `5.3.0b0`),
|
||||
so those inherited tags are correctly treated as pre-releases and are invisible
|
||||
to a stable instance. Parsing is still defensive: one unparseable tag must not
|
||||
take the whole check down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from packaging.version import InvalidVersion, Version, parse as parse_version
|
||||
|
||||
from .. import __version__
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
API_BASE: str = "https://git.houmeres.sk/api/v1/repos/Houmeres/SageTube"
|
||||
RELEASES_PAGE: str = "https://git.houmeres.sk/Houmeres/SageTube/releases"
|
||||
|
||||
DEFAULT_TIMEOUT: float = 8.0
|
||||
#: Don't ask the forge more than once a day, however often the app is started.
|
||||
CHECK_INTERVAL_SECONDS: int = 24 * 60 * 60
|
||||
#: How many releases to pull when the beta channel is on.
|
||||
BETA_PAGE_SIZE: int = 20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Release:
|
||||
"""One published release, normalised."""
|
||||
|
||||
version: Version
|
||||
tag: str
|
||||
url: str
|
||||
body: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
def _headers() -> Dict[str, str]:
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"SageTube/{__version__}",
|
||||
}
|
||||
|
||||
|
||||
def _to_release(payload: Dict[str, Any]) -> Optional[Release]:
|
||||
"""Normalise one API object, or None if it is unusable."""
|
||||
tag = (payload.get("tag_name") or "").strip()
|
||||
if not tag:
|
||||
return None
|
||||
try:
|
||||
version = parse_version(tag.lstrip("vV"))
|
||||
except (InvalidVersion, TypeError) as e:
|
||||
# A hand-made or inherited tag that isn't a version. Skip it rather
|
||||
# than letting it abort the whole check.
|
||||
logger.debug(f"Ignoring unparseable release tag {tag!r}: {e}")
|
||||
return None
|
||||
|
||||
return Release(
|
||||
version=version,
|
||||
tag=tag,
|
||||
url=payload.get("html_url") or f"{RELEASES_PAGE}/tag/{tag}",
|
||||
body=(payload.get("body") or "").strip(),
|
||||
prerelease=bool(payload.get("prerelease")) or version.is_prerelease,
|
||||
)
|
||||
|
||||
|
||||
def _get(path: str, timeout: float, **params: Any) -> Optional[Any]:
|
||||
"""GET a JSON endpoint. Returns None on any failure -- never raises."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{API_BASE}{path}",
|
||||
headers=_headers(),
|
||||
params=params or None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.debug(f"Update check request failed: {e}")
|
||||
return None
|
||||
|
||||
if response.status_code == 404:
|
||||
# No release matches (e.g. /releases/latest on a repo whose only
|
||||
# releases are pre-releases). That is "nothing to offer", not an error.
|
||||
logger.debug("Update check: no matching release published.")
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"Update check: API returned {response.status_code}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as e:
|
||||
logger.debug(f"Update check: malformed JSON: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest(include_prerelease: bool = False, timeout: float = DEFAULT_TIMEOUT) -> Optional[Release]:
|
||||
"""
|
||||
The newest published release, or None if there is nothing or the check failed.
|
||||
|
||||
With `include_prerelease` the whole release list is pulled and filtered
|
||||
here rather than trusting server-side filters, because the highest
|
||||
*version* is wanted -- not the most recently published, which is what the
|
||||
API orders by.
|
||||
"""
|
||||
if not include_prerelease:
|
||||
payload = _get("/releases/latest", timeout)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
release = _to_release(payload)
|
||||
# /releases/latest should never return a draft or pre-release, but a
|
||||
# stable instance must not be handed one even if it does.
|
||||
if release is None or release.prerelease:
|
||||
return None
|
||||
return release
|
||||
|
||||
payload = _get("/releases", timeout, limit=BETA_PAGE_SIZE, draft=False)
|
||||
if not isinstance(payload, list):
|
||||
return None
|
||||
|
||||
candidates: List[Release] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict) or item.get("draft"):
|
||||
continue
|
||||
release = _to_release(item)
|
||||
if release is not None:
|
||||
candidates.append(release)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda r: r.version)
|
||||
|
||||
|
||||
def is_newer(release: Release, current_version: str) -> bool:
|
||||
"""
|
||||
Whether `release` should be offered over the running version.
|
||||
|
||||
Honours the version the user chose to skip, so "Skip this version" is not
|
||||
silently undone by the next start.
|
||||
"""
|
||||
try:
|
||||
current = parse_version(str(current_version).lstrip("vV"))
|
||||
except (InvalidVersion, TypeError):
|
||||
logger.debug(f"Running version {current_version!r} is unparseable; skipping update check.")
|
||||
return False
|
||||
|
||||
if release.version <= current:
|
||||
return False
|
||||
|
||||
skipped = ConfigManager.get("skipped_update_version")
|
||||
if skipped and str(skipped) == str(release.version):
|
||||
logger.debug(f"Update {release.tag} available but skipped by the user.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def should_check(now: Optional[float] = None) -> bool:
|
||||
"""Rate-limit to CHECK_INTERVAL_SECONDS, so a restart is not a new check."""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
try:
|
||||
last = float(ConfigManager.get("last_update_check") or 0)
|
||||
except (TypeError, ValueError):
|
||||
last = 0.0
|
||||
# A clock moved backwards must not lock the check out until it catches up.
|
||||
if last > now:
|
||||
return True
|
||||
return (now - last) >= CHECK_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def mark_checked(now: Optional[float] = None) -> None:
|
||||
ConfigManager.set("last_update_check", now if now is not None else time.time())
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
YtdlpClient - shared yt-dlp invocation layer
|
||||
============================================
|
||||
|
||||
Single place that knows how to build and run yt-dlp commands for metadata
|
||||
purposes (analysis, search, channel/playlist browsing, subscription feeds).
|
||||
Every caller previously built its own command list; new features should go
|
||||
through this client so cookies, proxies, timeouts and process-group cleanup
|
||||
behave identically everywhere.
|
||||
|
||||
Downloads keep using DownloadThread (streaming progress parsing); this client
|
||||
is for JSON-returning invocations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from .ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
# Flat-entry results are cached briefly so tab switches and pagination don't
|
||||
# hammer YouTube with identical requests
|
||||
_CACHE_TTL_SECONDS = 600
|
||||
_cache: Dict[Tuple[str, ...], Tuple[float, Any]] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
class YtdlpNotInstalledError(FileNotFoundError):
|
||||
"""Raised when neither the managed binary nor an opted-in system yt-dlp exists."""
|
||||
|
||||
|
||||
def run_ytdlp_capture(cmd: List[str], timeout: int) -> subprocess.CompletedProcess:
|
||||
"""Run yt-dlp capturing output, killing the whole process group on timeout.
|
||||
|
||||
subprocess.run() only kills the direct child on TimeoutExpired, leaking
|
||||
grandchildren (deno); it also loses any stderr produced before the
|
||||
timeout. Use Popen with a new session so the entire group can be reaped,
|
||||
and surface the partial stderr on the raised exception.
|
||||
"""
|
||||
popen_kwargs: Dict[str, Any] = {
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.PIPE,
|
||||
"text": True,
|
||||
"encoding": "utf-8",
|
||||
"errors": "replace",
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
proc = subprocess.Popen(cmd, **popen_kwargs)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
stdout, stderr = proc.communicate()
|
||||
exc.stdout, exc.stderr = stdout, stderr
|
||||
raise
|
||||
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
||||
|
||||
|
||||
class YtdlpClient:
|
||||
"""Builds and runs JSON-returning yt-dlp commands with the app's
|
||||
cookie/proxy configuration applied."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookie_file_path: Optional[str] = None,
|
||||
browser_cookies: Optional[str] = None,
|
||||
use_config_auth: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
cookie_file_path / browser_cookies: explicit overrides for session
|
||||
state that differs from the persisted config.
|
||||
use_config_auth: when True (default) and no override is given,
|
||||
cookie and proxy options are read from ConfigManager.
|
||||
"""
|
||||
self._cookie_file_override = cookie_file_path
|
||||
self._browser_cookies_override = browser_cookies
|
||||
self._use_config_auth = use_config_auth
|
||||
|
||||
# ------------------------------------------------------------- commands
|
||||
|
||||
def resolve_binary(self) -> str:
|
||||
path = get_yt_dlp_path()
|
||||
if str(path) == "yt-dlp":
|
||||
raise YtdlpNotInstalledError("yt-dlp is not installed - run the yt-dlp setup first")
|
||||
return str(path)
|
||||
|
||||
def _auth_args(self) -> List[str]:
|
||||
args: List[str] = []
|
||||
cookie_file = self._cookie_file_override
|
||||
browser_cookies = self._browser_cookies_override
|
||||
|
||||
if cookie_file is None and browser_cookies is None and self._use_config_auth:
|
||||
if ConfigManager.get("cookie_active"):
|
||||
if ConfigManager.get("cookie_source") == "file":
|
||||
saved = ConfigManager.get("cookie_file_path")
|
||||
if saved and Path(saved).exists():
|
||||
cookie_file = str(saved)
|
||||
else:
|
||||
browser = ConfigManager.get("cookie_browser")
|
||||
profile = ConfigManager.get("cookie_browser_profile")
|
||||
if browser:
|
||||
browser_cookies = f"{browser}:{profile}" if profile else browser
|
||||
|
||||
if cookie_file:
|
||||
args.extend(["--cookies", str(cookie_file)])
|
||||
elif browser_cookies:
|
||||
args.extend(["--cookies-from-browser", browser_cookies])
|
||||
|
||||
if self._use_config_auth:
|
||||
proxy = ConfigManager.get("proxy_url")
|
||||
geo_proxy = ConfigManager.get("geo_proxy_url")
|
||||
if proxy:
|
||||
args.extend(["--proxy", str(proxy)])
|
||||
if geo_proxy:
|
||||
args.extend(["--geo-verification-proxy", str(geo_proxy)])
|
||||
return args
|
||||
|
||||
def build_base_cmd(self) -> List[str]:
|
||||
return [self.resolve_binary(), "--no-warnings", "--no-color"] + self._auth_args()
|
||||
|
||||
def run_json(self, extra_args: List[str], timeout: int = 60) -> Any:
|
||||
"""Run yt-dlp with --dump-single-json semantics and parse stdout."""
|
||||
cmd = self.build_base_cmd() + extra_args
|
||||
logger.debug(f"YtdlpClient executing: {cmd}")
|
||||
result = run_ytdlp_capture(cmd, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
stderr_tail = (result.stderr or "").strip()[-1000:]
|
||||
raise RuntimeError(stderr_tail or f"yt-dlp exited with code {result.returncode}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def fetch_video_info(self, url: str, timeout: int = 300) -> Dict[str, Any]:
|
||||
"""Full info dict for a single video, including formats[].url."""
|
||||
return self.run_json(["--dump-single-json", url], timeout=timeout)
|
||||
|
||||
def fetch_flat_info(self, url: str, timeout: int = 300, items: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Flat info dict (playlist/channel entries without per-video formats).
|
||||
|
||||
items: optional -I range like "1:1" to limit entries when only the
|
||||
top-level metadata (channel title/id) is needed.
|
||||
"""
|
||||
args = ["--dump-single-json", "--flat-playlist"]
|
||||
if items:
|
||||
args.extend(["-I", items])
|
||||
args.append(url)
|
||||
return self.run_json(args, timeout=timeout)
|
||||
|
||||
def fetch_flat_entries(
|
||||
self, url: str, start: int = 1, end: int = 30, timeout: int = 120, use_cache: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Flat entries start..end (1-based, inclusive) of a playlist/channel URL."""
|
||||
cache_key = ("flat", url, str(start), str(end), *self._auth_args())
|
||||
if use_cache:
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
info = self.run_json(
|
||||
["--dump-single-json", "--flat-playlist", "-I", f"{start}:{end}", url],
|
||||
timeout=timeout,
|
||||
)
|
||||
entries = [e for e in info.get("entries", []) if e] if isinstance(info, dict) else []
|
||||
_cache_put(cache_key, entries)
|
||||
return entries
|
||||
|
||||
def search(
|
||||
self, query: str, n: int = 25, offset: int = 0, timeout: int = 120, use_cache: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""YouTube search returning flat entries n results at a time."""
|
||||
cache_key = ("search", query, str(n), str(offset), *self._auth_args())
|
||||
if use_cache:
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
total = offset + n
|
||||
info = self.run_json(
|
||||
[
|
||||
"--dump-single-json",
|
||||
"--flat-playlist",
|
||||
"-I",
|
||||
f"{offset + 1}:{total}",
|
||||
f"ytsearch{total}:{query}",
|
||||
],
|
||||
timeout=timeout,
|
||||
)
|
||||
entries = [e for e in info.get("entries", []) if e] if isinstance(info, dict) else []
|
||||
_cache_put(cache_key, entries)
|
||||
return entries
|
||||
|
||||
def fetch_account_feed(self, n: int = 50, timeout: int = 180) -> List[Dict[str, Any]]:
|
||||
"""The logged-in account's subscription feed. Requires active cookies."""
|
||||
return self.fetch_flat_entries(
|
||||
"https://www.youtube.com/feed/subscriptions", start=1, end=n,
|
||||
timeout=timeout, use_cache=False,
|
||||
)
|
||||
|
||||
|
||||
class YtdlpWorker(QThread):
|
||||
"""Generic worker running one YtdlpClient call off the GUI thread.
|
||||
|
||||
Usage:
|
||||
worker = YtdlpWorker(lambda c: c.search("query", 25))
|
||||
worker.result.connect(...); worker.error.connect(...)
|
||||
worker.start()
|
||||
"""
|
||||
|
||||
result = Signal(object)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, fn: Callable[[YtdlpClient], Any], client: Optional[YtdlpClient] = None, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._fn = fn
|
||||
self._client = client or YtdlpClient()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.result.emit(self._fn(self._client))
|
||||
except Exception as e:
|
||||
logger.exception(f"YtdlpWorker failed: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ cache
|
||||
|
||||
def _cache_get(key: Tuple[str, ...]) -> Optional[Any]:
|
||||
with _cache_lock:
|
||||
hit = _cache.get(key)
|
||||
if hit and time.time() - hit[0] < _CACHE_TTL_SECONDS:
|
||||
return hit[1]
|
||||
if hit:
|
||||
del _cache[key]
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(key: Tuple[str, ...], value: Any) -> None:
|
||||
with _cache_lock:
|
||||
if len(_cache) > 256:
|
||||
_cache.clear()
|
||||
_cache[key] = (time.time(), value)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
with _cache_lock:
|
||||
_cache.clear()
|
||||
@@ -0,0 +1,780 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_constants import (
|
||||
APP_BIN_DIR,
|
||||
ICON_PATH,
|
||||
OS_FULL_NAME,
|
||||
OS_NAME,
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
DENO_APP_BIN_PATH,
|
||||
DENO_DOWNLOAD_URL,
|
||||
DENO_SHA256_URL,
|
||||
)
|
||||
from .ytsage_ffmpeg import get_file_sha256
|
||||
|
||||
|
||||
def verify_deno_sha256(file_path: Path, sha256_url: str) -> bool:
|
||||
"""
|
||||
Verify Deno file SHA256 hash against official checksums.
|
||||
|
||||
Args:
|
||||
file_path: Path to the downloaded Deno zip file
|
||||
sha256_url: URL to download the SHA256 checksum file
|
||||
|
||||
Returns:
|
||||
bool: True if verification successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Download the SHA256 checksum file
|
||||
logger.info(f"Downloading SHA256 checksum from: {sha256_url}")
|
||||
response = requests.get(sha256_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
checksum_content = response.text
|
||||
|
||||
# Parse the checksum file
|
||||
# Format can be either:
|
||||
# 1. Standard Unix format: "hash filename"
|
||||
# 2. Verbose format: "Hash : <HASH>"
|
||||
expected_hash = None
|
||||
for line in checksum_content.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Try standard Unix format first (hash followed by spaces and filename)
|
||||
if len(line) >= 64 and (" " in line or "\t" in line):
|
||||
# Extract first 64 characters as potential hash
|
||||
potential_hash = line.split()[0]
|
||||
if len(potential_hash) == 64 and all(c in "0123456789abcdefABCDEF" for c in potential_hash):
|
||||
expected_hash = potential_hash
|
||||
break
|
||||
|
||||
# Try verbose format
|
||||
if line.startswith("Hash"):
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
expected_hash = parts[1].strip()
|
||||
break
|
||||
|
||||
if not expected_hash:
|
||||
logger.error("Could not find SHA256 hash in checksum file")
|
||||
logger.debug(f"Checksum file content: {checksum_content}")
|
||||
return False
|
||||
|
||||
# Calculate actual hash of downloaded file
|
||||
logger.info("Calculating SHA256 hash of downloaded file...")
|
||||
actual_hash = get_file_sha256(file_path)
|
||||
|
||||
# Compare hashes (case-insensitive)
|
||||
if actual_hash.lower() == expected_hash.lower():
|
||||
logger.info("✓ SHA256 verification successful!")
|
||||
logger.info(f" Expected: {expected_hash}")
|
||||
logger.info(f" Actual: {actual_hash}")
|
||||
return True
|
||||
else:
|
||||
logger.error("✗ SHA256 verification failed!")
|
||||
logger.error(f" Expected: {expected_hash}")
|
||||
logger.error(f" Actual: {actual_hash}")
|
||||
return False
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to download SHA256 checksum: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during SHA256 verification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class DownloadDenoThread(QThread):
|
||||
progress_signal = Signal(int)
|
||||
status_signal = Signal(str)
|
||||
finished_signal = Signal(bool, str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def run(self) -> None:
|
||||
temp_zip_path = None
|
||||
try:
|
||||
# Create temporary file for zip download
|
||||
temp_zip_fd, temp_zip_path = tempfile.mkstemp(suffix=".zip")
|
||||
os.close(temp_zip_fd) # Close the file descriptor
|
||||
|
||||
# Download with progress reporting
|
||||
logger.info(f"Downloading Deno from: {DENO_DOWNLOAD_URL}")
|
||||
self.status_signal.emit(_("deno.downloading"))
|
||||
|
||||
response = requests.get(DENO_DOWNLOAD_URL, stream=True)
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
block_size = 8192 # 8KB blocks
|
||||
|
||||
if total_size == 0:
|
||||
self.progress_signal.emit(100)
|
||||
|
||||
with open(temp_zip_path, "wb") as f:
|
||||
downloaded = 0
|
||||
for data in response.iter_content(block_size):
|
||||
f.write(data)
|
||||
downloaded += len(data)
|
||||
if total_size > 0:
|
||||
progress = int(downloaded / total_size * 100)
|
||||
self.progress_signal.emit(progress)
|
||||
|
||||
logger.info("Download complete, verifying SHA256 hash...")
|
||||
self.status_signal.emit(_("deno.verifying"))
|
||||
|
||||
# Verify SHA256 hash
|
||||
if not verify_deno_sha256(Path(temp_zip_path), DENO_SHA256_URL):
|
||||
# Hash verification failed - delete the downloaded file
|
||||
logger.error("SHA256 verification failed! Removing downloaded file.")
|
||||
if Path(temp_zip_path).exists():
|
||||
Path(temp_zip_path).unlink()
|
||||
self.finished_signal.emit(
|
||||
False,
|
||||
_("deno.verification_failed")
|
||||
)
|
||||
return
|
||||
|
||||
# Extract deno executable from zip
|
||||
logger.info("Extracting Deno executable...")
|
||||
self.status_signal.emit(_("deno.extracting"))
|
||||
|
||||
with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref:
|
||||
# Deno zip contains just the executable at root
|
||||
executable_name = "deno.exe" if OS_NAME == "Windows" else "deno"
|
||||
|
||||
# Find the executable in the zip
|
||||
if executable_name not in zip_ref.namelist():
|
||||
logger.error(f"Executable '{executable_name}' not found in zip file")
|
||||
self.finished_signal.emit(False, f"Executable '{executable_name}' not found in zip")
|
||||
return
|
||||
|
||||
# Extract to app bin directory (while zip_ref is open)
|
||||
target_dir = DENO_APP_BIN_PATH.parent
|
||||
zip_ref.extract(executable_name, target_dir)
|
||||
|
||||
# Verify the extracted file exists
|
||||
exe_path = DENO_APP_BIN_PATH
|
||||
if not exe_path.exists():
|
||||
logger.error(f"Extraction failed: {exe_path} does not exist")
|
||||
self.finished_signal.emit(False, "Extraction failed")
|
||||
return
|
||||
|
||||
# Make executable on macOS and Linux
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(exe_path, 0o755)
|
||||
logger.info("Set executable permissions on Unix system")
|
||||
|
||||
# Clean up the temporary zip file
|
||||
if temp_zip_path and Path(temp_zip_path).exists():
|
||||
Path(temp_zip_path).unlink()
|
||||
logger.info("Cleaned up temporary zip file")
|
||||
|
||||
logger.info("Deno downloaded, verified, and extracted successfully!")
|
||||
self.finished_signal.emit(True, str(exe_path))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error downloading/extracting Deno: {e}")
|
||||
# Clean up temporary zip file on error
|
||||
if temp_zip_path and Path(temp_zip_path).exists():
|
||||
try:
|
||||
Path(temp_zip_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
self.finished_signal.emit(False, str(e))
|
||||
|
||||
|
||||
class DenoSetupDialog(QDialog):
|
||||
setup_complete = Signal(str) # Signal emitting the path to Deno
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("deno.setup_required"))
|
||||
self.setMinimumWidth(520)
|
||||
self.setMinimumHeight(300)
|
||||
self.resize(520, 320)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent and parent.windowIcon():
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
else:
|
||||
icon_path = ICON_PATH
|
||||
if Path.exists(icon_path):
|
||||
self.setWindowIcon(QIcon(str(icon_path)))
|
||||
|
||||
self.init_ui()
|
||||
|
||||
# Apply dark theme styling to match app
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
line-height: 1.4;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
color: #999999;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #1d1e22;
|
||||
height: 25px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stop: 0 #e60000, stop: 0.5 #ff3333, stop: 1 #c90000);
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(25, 25, 25, 25)
|
||||
|
||||
# Header title
|
||||
title_label = QLabel(_("deno.setup_required"))
|
||||
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Information label with improved styling
|
||||
info_label = QLabel(
|
||||
_("deno.setup_description", os_name=OS_FULL_NAME)
|
||||
)
|
||||
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 5px; line-height: 1.4;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Progress bar with proper sizing
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
self.progress_bar.setFixedHeight(20)
|
||||
self.progress_bar.setStyleSheet(
|
||||
"""
|
||||
QProgressBar {
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 8px;
|
||||
background-color: #1d1e22;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
height: 20px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #c90000;
|
||||
border-radius: 6px;
|
||||
margin: 1px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Status label with better spacing
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setStyleSheet("font-size: 12px; color: #cccccc; padding: 8px 0;")
|
||||
self.status_label.setWordWrap(True)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Add stretch to push buttons to bottom
|
||||
layout.addStretch()
|
||||
|
||||
# Button layout with improved spacing
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(15)
|
||||
button_layout.setContentsMargins(0, 10, 0, 0)
|
||||
|
||||
self.setup_button = QPushButton(_("deno.setup_button"))
|
||||
self.setup_button.clicked.connect(self.download_deno)
|
||||
|
||||
self.cancel_button = QPushButton(_("buttons.cancel"))
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addWidget(self.setup_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def download_deno(self) -> None:
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
self.status_label.setText(_("deno.downloading"))
|
||||
self.setup_button.setEnabled(False)
|
||||
self.cancel_button.setEnabled(False)
|
||||
|
||||
self.download_thread = DownloadDenoThread()
|
||||
self.download_thread.progress_signal.connect(self.update_progress)
|
||||
self.download_thread.status_signal.connect(self.update_status)
|
||||
self.download_thread.finished_signal.connect(self.download_finished)
|
||||
self.download_thread.start()
|
||||
|
||||
def update_progress(self, value) -> None:
|
||||
self.progress_bar.setValue(value)
|
||||
|
||||
def update_status(self, status: str) -> None:
|
||||
self.status_label.setText(status)
|
||||
|
||||
def download_finished(self, success, result) -> None:
|
||||
self.setup_button.setEnabled(True)
|
||||
self.cancel_button.setEnabled(True)
|
||||
|
||||
if success:
|
||||
self.status_label.setText(_("deno.success"))
|
||||
self.setup_complete.emit(result)
|
||||
self.accept()
|
||||
else:
|
||||
self.status_label.setText(f"{_('deno.download_error', error=result)}")
|
||||
error_dialog = QMessageBox(self)
|
||||
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
error_dialog.setWindowTitle(_("deno.download_failed"))
|
||||
error_dialog.setText(_("deno.download_error", error=result))
|
||||
error_dialog.setWindowIcon(self.windowIcon())
|
||||
error_dialog.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
error_dialog.exec()
|
||||
|
||||
|
||||
def check_deno_binary() -> Optional[Path]:
|
||||
"""
|
||||
Check if Deno binary exists in the app's bin directory ONLY.
|
||||
We only use our managed binary, not system PATH.
|
||||
|
||||
Returns:
|
||||
Path or None: Path to Deno binary if found in app bin, None otherwise
|
||||
"""
|
||||
exe_path = DENO_APP_BIN_PATH
|
||||
if exe_path.exists():
|
||||
# Make sure it's executable on Unix systems
|
||||
if OS_NAME != "Windows" and not os.access(exe_path, os.X_OK):
|
||||
try:
|
||||
os.chmod(exe_path, 0o755)
|
||||
logger.info(f"Fixed permissions on Deno at {exe_path}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Could not set executable permissions on {exe_path}: {e}")
|
||||
logger.info(f"Found Deno in app bin directory: {exe_path}")
|
||||
return exe_path
|
||||
|
||||
# Binary not found in app directory - return None to trigger setup
|
||||
logger.warning(f"Deno binary not found in app bin directory: {exe_path}")
|
||||
return None
|
||||
|
||||
|
||||
def check_deno_installed() -> bool:
|
||||
"""
|
||||
Check if Deno is installed and accessible.
|
||||
|
||||
Returns:
|
||||
bool: True if Deno is found and working, False otherwise
|
||||
"""
|
||||
try:
|
||||
deno_path = check_deno_binary()
|
||||
if deno_path:
|
||||
# Try to run deno --version to verify it's working
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(deno_path), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_deno_path() -> Path:
|
||||
"""
|
||||
Get the Deno path from the app's bin directory.
|
||||
|
||||
Returns:
|
||||
Path or str: Path to Deno binary, or "deno" as fallback command
|
||||
"""
|
||||
deno_path = check_deno_binary()
|
||||
if deno_path:
|
||||
logger.info(f"Using Deno from: {deno_path}")
|
||||
return deno_path
|
||||
|
||||
# If not found, fall back to the command name as a last resort
|
||||
logger.info("Deno not found in app directory, falling back to command name")
|
||||
return "deno" # type: ignore[return-value]
|
||||
|
||||
|
||||
def get_deno_version_direct(deno_path=None) -> str:
|
||||
"""
|
||||
Get Deno version directly without caching.
|
||||
|
||||
Args:
|
||||
deno_path: Optional path to Deno binary. If None, uses get_deno_path()
|
||||
|
||||
Returns:
|
||||
str: Version string or error message
|
||||
"""
|
||||
try:
|
||||
if deno_path is None:
|
||||
deno_path = get_deno_path()
|
||||
|
||||
if not deno_path or deno_path == "deno":
|
||||
return "Not found"
|
||||
|
||||
result = subprocess.run(
|
||||
[str(deno_path), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Deno outputs: "deno 1.38.0 (release, x86_64-pc-windows-msvc)"
|
||||
# Extract version from first line
|
||||
lines = result.stdout.strip().split("\n")
|
||||
if lines:
|
||||
first_line = lines[0]
|
||||
# Extract version number (e.g., "1.38.0" from "deno 1.38.0 ...")
|
||||
parts = first_line.split()
|
||||
if len(parts) >= 2 and parts[0] == "deno":
|
||||
return parts[1]
|
||||
return first_line.strip()
|
||||
return "Unknown version"
|
||||
else:
|
||||
return "Error getting version"
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting Deno version: {e}")
|
||||
return "Error getting version"
|
||||
|
||||
|
||||
def setup_deno(parent_widget=None):
|
||||
"""
|
||||
Show the Deno setup dialog and handle the result.
|
||||
|
||||
Returns:
|
||||
str: Path to Deno binary
|
||||
"""
|
||||
logger.debug("Starting Deno setup dialog")
|
||||
dialog = DenoSetupDialog(parent_widget)
|
||||
|
||||
# Store the setup result from the signal
|
||||
setup_result = {"path": None}
|
||||
|
||||
def on_setup_complete(path) -> None:
|
||||
logger.debug(f"Received setup_complete signal with path: {path}")
|
||||
setup_result["path"] = path
|
||||
|
||||
# Connect to the setup_complete signal
|
||||
dialog.setup_complete.connect(on_setup_complete)
|
||||
|
||||
# Show the dialog
|
||||
result = dialog.exec()
|
||||
logger.debug(f"Dialog result: {result} (Accepted={QDialog.DialogCode.Accepted})")
|
||||
|
||||
if result == QDialog.DialogCode.Accepted:
|
||||
# First check if we received a path from the signal
|
||||
if setup_result["path"]:
|
||||
path_obj = Path(setup_result["path"]) if isinstance(setup_result["path"], str) else setup_result["path"]
|
||||
if path_obj.exists():
|
||||
logger.debug(f"Using path from signal: {setup_result['path']}")
|
||||
return str(setup_result["path"])
|
||||
|
||||
# Get the expected path for verification as fallback
|
||||
expected_path = DENO_APP_BIN_PATH
|
||||
logger.debug(f"Expected Deno path: {expected_path}")
|
||||
|
||||
# Verify the path exists after dialog is accepted
|
||||
if expected_path.exists():
|
||||
logger.debug(f"Deno successfully found at expected path: {expected_path}")
|
||||
return str(expected_path)
|
||||
else:
|
||||
logger.debug(f"Expected path does not exist, trying alternate detection")
|
||||
# Try to use the get_deno_path function to find Deno elsewhere
|
||||
deno_path = get_deno_path()
|
||||
logger.debug(f"Alternate detection result: {deno_path}")
|
||||
if deno_path != "deno":
|
||||
path_obj = Path(deno_path) if isinstance(deno_path, str) else deno_path
|
||||
if path_obj.exists():
|
||||
logger.debug(f"Deno found at alternate location: {deno_path}")
|
||||
return str(deno_path)
|
||||
|
||||
# Something went wrong, show an error message
|
||||
logger.debug(f"Setup failed, showing error dialog")
|
||||
if parent_widget:
|
||||
error_dialog = QMessageBox(parent_widget)
|
||||
error_dialog.setIcon(QMessageBox.Icon.Warning)
|
||||
error_dialog.setWindowTitle(_("deno.setup_error"))
|
||||
error_dialog.setText(_("deno.setup_failed"))
|
||||
error_dialog.setWindowIcon(parent_widget.windowIcon())
|
||||
error_dialog.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
error_dialog.exec()
|
||||
logger.warning(f"Deno setup failed, path does not exist: {expected_path}")
|
||||
else:
|
||||
logger.debug("User cancelled the setup dialog")
|
||||
|
||||
# User cancelled or setup failed, return the fallback command
|
||||
logger.debug("Returning fallback command 'deno'")
|
||||
return "deno"
|
||||
|
||||
|
||||
def get_latest_deno_version() -> Optional[str]:
|
||||
"""
|
||||
Fetch the latest Deno version from GitHub API.
|
||||
|
||||
Returns:
|
||||
str: Version string (e.g., "2.5.6") or None if fetch failed
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.github.com/repos/denoland/deno/releases/latest",
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Get tag_name (e.g., "v2.5.6") and remove 'v' prefix
|
||||
tag_name = data.get("tag_name", "")
|
||||
if tag_name.startswith("v"):
|
||||
version = tag_name[1:]
|
||||
else:
|
||||
version = tag_name
|
||||
|
||||
logger.info(f"Latest Deno version: {version}")
|
||||
return version
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch latest Deno version: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error fetching Deno version: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def compare_deno_versions(current: str, latest: str) -> bool:
|
||||
"""
|
||||
Compare two Deno version strings.
|
||||
|
||||
Args:
|
||||
current: Current version string (e.g., "2.5.6")
|
||||
latest: Latest version string (e.g., "2.5.7")
|
||||
|
||||
Returns:
|
||||
bool: True if update is needed (latest > current), False otherwise
|
||||
"""
|
||||
try:
|
||||
import re
|
||||
|
||||
def parse_version(version_str: str) -> tuple:
|
||||
"""Parse version string into tuple of integers."""
|
||||
# Remove 'v' prefix if present
|
||||
if version_str.startswith('v'):
|
||||
version_str = version_str[1:]
|
||||
|
||||
# Extract version numbers
|
||||
match = re.search(r'(\d+\.\d+\.\d+)', version_str)
|
||||
if match:
|
||||
version_str = match.group(1)
|
||||
|
||||
parts = version_str.split('.')
|
||||
return tuple(int(p) for p in parts if p.isdigit())
|
||||
|
||||
current_tuple = parse_version(current)
|
||||
latest_tuple = parse_version(latest)
|
||||
|
||||
logger.debug(f"Comparing Deno versions: {current_tuple} vs {latest_tuple}")
|
||||
return latest_tuple > current_tuple
|
||||
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning(f"Could not compare Deno versions: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def upgrade_deno(progress_callback=None) -> tuple[bool, str]:
|
||||
"""
|
||||
Upgrade Deno to the latest version using 'deno upgrade' command.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional function to call with output lines for progress tracking
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, output: str) - Success status and command output
|
||||
"""
|
||||
try:
|
||||
deno_path = DENO_APP_BIN_PATH
|
||||
|
||||
if not deno_path.exists():
|
||||
error_msg = f"Deno binary not found at: {deno_path}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
logger.info(f"Upgrading Deno using: {deno_path}")
|
||||
|
||||
# Run deno upgrade command with output capturing
|
||||
process = subprocess.Popen(
|
||||
[str(deno_path), "upgrade"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
bufsize=1, # Line buffered
|
||||
encoding='utf-8',
|
||||
errors='replace'
|
||||
)
|
||||
|
||||
full_output = []
|
||||
|
||||
# Read output line by line as it is generated
|
||||
while True:
|
||||
line = process.stdout.readline()
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
|
||||
if line:
|
||||
line_str = line.strip()
|
||||
if line_str:
|
||||
full_output.append(line_str)
|
||||
logger.debug(f"Deno upgrade output: {line_str}")
|
||||
if progress_callback:
|
||||
progress_callback(line_str)
|
||||
|
||||
return_code = process.poll()
|
||||
output = "\n".join(full_output)
|
||||
|
||||
if return_code == 0:
|
||||
logger.info("Deno upgrade successful")
|
||||
return True, output
|
||||
else:
|
||||
logger.error(f"Deno upgrade failed with code {return_code}")
|
||||
logger.error(f"Output: {output}")
|
||||
return False, output
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "Deno upgrade timed out after 5 minutes"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Error upgrading Deno: {str(e)}"
|
||||
logger.exception(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
|
||||
def check_deno_update() -> tuple[bool, str, str]:
|
||||
"""
|
||||
Check if a Deno update is available.
|
||||
|
||||
Returns:
|
||||
tuple: (update_needed: bool, current_version: str, latest_version: str)
|
||||
"""
|
||||
try:
|
||||
# Get current version
|
||||
current_version = get_deno_version_direct()
|
||||
if current_version in ["Not found", "Error getting version"]:
|
||||
return False, current_version, "Unknown"
|
||||
|
||||
# Get latest version
|
||||
latest_version = get_latest_deno_version()
|
||||
if not latest_version:
|
||||
return False, current_version, "Error"
|
||||
|
||||
# Compare versions
|
||||
update_needed = compare_deno_versions(current_version, latest_version)
|
||||
|
||||
return update_needed, current_version, latest_version
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking Deno update: {e}")
|
||||
return False, "Error", "Error"
|
||||
@@ -0,0 +1,882 @@
|
||||
import gc
|
||||
import os
|
||||
import re
|
||||
import shlex # For safely parsing command arguments
|
||||
import signal
|
||||
import subprocess # For direct CLI command execution
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Set
|
||||
|
||||
from PySide6.QtCore import QObject, QThread, Signal
|
||||
|
||||
from .ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_constants import (
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
VIDEO_EXTENSIONS,
|
||||
AUDIO_EXTENSIONS,
|
||||
SUBTITLE_EXTENSIONS,
|
||||
MEDIA_EXTENSIONS,
|
||||
)
|
||||
from ..utils.ytsage_localization import LocalizationManager
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
# Shorthand for localization
|
||||
_ = LocalizationManager.get_text
|
||||
|
||||
|
||||
class SignalManager(QObject):
|
||||
update_formats = Signal(list)
|
||||
update_status = Signal(str)
|
||||
update_progress = Signal(float)
|
||||
playlist_info_label_visible = Signal(bool)
|
||||
playlist_info_label_text = Signal(str)
|
||||
selected_subs_label_text = Signal(str)
|
||||
playlist_select_btn_visible = Signal(bool)
|
||||
playlist_select_btn_text = Signal(str)
|
||||
|
||||
|
||||
class DownloadThread(QThread):
|
||||
progress_signal = Signal(float)
|
||||
status_signal = Signal(str)
|
||||
finished_signal = Signal()
|
||||
error_signal = Signal(str)
|
||||
file_exists_signal = Signal(str) # New signal for file existence
|
||||
update_details = Signal(str) # New signal for filename, speed, ETA
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url,
|
||||
path,
|
||||
format_id,
|
||||
is_audio_only=False,
|
||||
format_has_audio=False,
|
||||
subtitle_langs=None,
|
||||
is_playlist=False,
|
||||
merge_subs=False,
|
||||
enable_sponsorblock=False,
|
||||
sponsorblock_categories=None,
|
||||
resolution="",
|
||||
playlist_items=None,
|
||||
save_description=False,
|
||||
embed_chapters=False,
|
||||
cookie_file=None,
|
||||
browser_cookies=None,
|
||||
rate_limit=None,
|
||||
download_section=None,
|
||||
force_keyframes=False,
|
||||
proxy_url=None,
|
||||
geo_proxy_url=None,
|
||||
force_output_format=False,
|
||||
preferred_output_format="mp4",
|
||||
force_audio_format=False,
|
||||
preferred_audio_format="best",
|
||||
audio_normalization=False,
|
||||
filename_format=None,
|
||||
concurrent_fragments=1,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.path = Path(path)
|
||||
self.format_id = format_id
|
||||
self.is_audio_only = is_audio_only
|
||||
self.format_has_audio = format_has_audio
|
||||
self.subtitle_langs = subtitle_langs if subtitle_langs else []
|
||||
self.is_playlist = is_playlist
|
||||
self.merge_subs = merge_subs
|
||||
self.enable_sponsorblock = enable_sponsorblock
|
||||
self.sponsorblock_categories = sponsorblock_categories if sponsorblock_categories else ["sponsor"]
|
||||
self.resolution = resolution
|
||||
self.playlist_items = playlist_items
|
||||
self.save_description = save_description
|
||||
self.embed_chapters = embed_chapters
|
||||
self.cookie_file = cookie_file
|
||||
self.browser_cookies = browser_cookies
|
||||
self.rate_limit = rate_limit
|
||||
self.download_section = download_section
|
||||
self.force_keyframes = force_keyframes
|
||||
self.proxy_url = proxy_url
|
||||
self.geo_proxy_url = geo_proxy_url
|
||||
self.force_output_format = force_output_format
|
||||
self.preferred_output_format = preferred_output_format
|
||||
self.force_audio_format = force_audio_format
|
||||
self.preferred_audio_format = preferred_audio_format
|
||||
self.audio_normalization = audio_normalization
|
||||
self.filename_format = filename_format
|
||||
self.concurrent_fragments = concurrent_fragments
|
||||
self.paused: bool = False
|
||||
self.cancelled: bool = False
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.current_filename: Optional[str] = None # Initialize filename storage
|
||||
self.last_file_path: Optional[str] = None # Initialize full file path storage
|
||||
self.subtitle_files: List[str] = [] # Track subtitle files that are created
|
||||
self.initial_subtitle_files: Set[Path] = set() # Track initial subtitle files before download
|
||||
self.download_files: Set[Path] = set() # Every destination path this download wrote to
|
||||
self.expected_phases: int = 1 # 2 when video and audio download separately before merge
|
||||
self._media_phase: int = 0 # Index of the media stream currently downloading
|
||||
|
||||
def cleanup_partial_files(self) -> None:
|
||||
"""Delete partial files (.part/.ytdl and unmerged .fNNN. streams), but only
|
||||
those belonging to destinations this download actually wrote — the download
|
||||
directory may contain unrelated files from other applications."""
|
||||
try:
|
||||
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
|
||||
for dest in self.download_files:
|
||||
candidates = [dest.with_name(dest.name + ".part"), dest.with_name(dest.name + ".ytdl")]
|
||||
if dest.suffix == ".part" or pattern.search(dest.name):
|
||||
candidates.append(dest)
|
||||
for file_path in candidates:
|
||||
if file_path.exists():
|
||||
self._safe_delete_with_retry(file_path)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error cleaning partial files: {e}")
|
||||
|
||||
def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None:
|
||||
"""Safely delete a file with retry mechanism for file locking issues across platforms"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Force garbage collection to release any Python-held file handles
|
||||
gc.collect()
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink(missing_ok=True)
|
||||
logger.info(f"Successfully deleted {file_path.name}")
|
||||
return
|
||||
except PermissionError as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"File {file_path.name} is locked, retrying in {delay} seconds... (attempt {attempt + 1}/{max_retries})")
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.5, 5.0) # Exponential backoff, capped at 5 seconds
|
||||
else:
|
||||
logger.error(f"Failed to delete {file_path.name} after {max_retries} attempts: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting {file_path.name}: {e}")
|
||||
return
|
||||
|
||||
def _terminate_process_tree(self, process: subprocess.Popen) -> None:
|
||||
"""Terminate a process and all its children across platforms"""
|
||||
pid = process.pid
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# Windows: Use taskkill to kill the entire process tree
|
||||
# /T = kill child processes, /F = force kill
|
||||
# Use subprocess.run with no encoding to avoid codec issues
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
logger.debug(f"Killed process tree on Windows (PID: {pid})")
|
||||
else:
|
||||
# Unix-like systems: Kill the process group
|
||||
try:
|
||||
# Try to kill the process group
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
time.sleep(0.5)
|
||||
# Force kill if still running
|
||||
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
# Process already terminated or no permission
|
||||
pass
|
||||
logger.debug(f"Killed process group on Unix (PID: {pid})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error killing process tree: {e}")
|
||||
# Fallback to standard termination
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure process is waited on to avoid zombies
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup_subtitle_files(self) -> None:
|
||||
"""Delete subtitle files after they have been merged into the video file"""
|
||||
deleted_count: List[int] = [0, 0]
|
||||
|
||||
def safe_delete(path: Path) -> bool:
|
||||
try:
|
||||
# Check if file exists before trying to delete
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
logger.debug(f"Deleted subtitle file: {path.name}")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"Error deleting subtitle file {path}: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# --- Method 1: Delete tracked subtitle files ---
|
||||
for f in self.subtitle_files or []:
|
||||
deleted_count[0] += safe_delete(path=Path(f))
|
||||
else:
|
||||
logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files")
|
||||
|
||||
# --- Method 2: Delete new subtitle files not in initial set ---
|
||||
# Only touch subtitles belonging to files this download wrote; the
|
||||
# directory may contain subtitle files from other processes.
|
||||
download_stems = {p.stem for p in self.download_files} | {Path(f).stem for f in self.subtitle_files or []}
|
||||
new_subtitle_files: Set[Path] = {
|
||||
f
|
||||
for f in Path(self.path).rglob("*")
|
||||
if f.suffix in [".vtt", ".srt"]
|
||||
and f not in self.initial_subtitle_files
|
||||
and any(f.name.startswith(stem) for stem in download_stems if stem)
|
||||
}
|
||||
for subtitle_file in new_subtitle_files:
|
||||
deleted_count[1] += safe_delete(path=subtitle_file)
|
||||
else:
|
||||
logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error cleaning subtitle files: {e}")
|
||||
|
||||
def _build_yt_dlp_command(self) -> List[str]:
|
||||
"""Build the yt-dlp command line with all options for direct execution."""
|
||||
yt_dlp_path: str = get_yt_dlp_path()
|
||||
if str(yt_dlp_path) == "yt-dlp":
|
||||
# Sentinel: no managed binary and no opted-in system binary.
|
||||
# Never exec a bare command name from PATH.
|
||||
raise FileNotFoundError("yt-dlp is not installed - run the yt-dlp setup first")
|
||||
# Build the command line array
|
||||
cmd: List[str] = [str(yt_dlp_path)]
|
||||
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
|
||||
|
||||
# Add concurrent fragments setting
|
||||
if self.concurrent_fragments:
|
||||
cmd.extend(["-N", str(self.concurrent_fragments)])
|
||||
logger.debug(f"Using {self.concurrent_fragments} concurrent connections")
|
||||
|
||||
# Format selection strategy - use format ID if provided or fallback to resolution
|
||||
if self.is_playlist:
|
||||
# For playlists, specific format_id from the first video often fails for subsequent videos.
|
||||
# Instead, we rely on dynamic fallback/resolution limits.
|
||||
if self.is_audio_only:
|
||||
# For audio-only playlist, let yt-dlp pick best audio.
|
||||
cmd.extend(["-f", "bestaudio/best"])
|
||||
logger.debug(f"Playlist mode: using dynamic best audio fallback instead of format_id")
|
||||
else:
|
||||
# If a specific resolution is given, limit to it. Otherwise, select the overall best.
|
||||
# The resolution might be e.g. "1920x1080" or "1080". We want the height.
|
||||
try:
|
||||
if self.resolution and self.resolution != "default":
|
||||
res_str = str(self.resolution)
|
||||
h = min(map(int, res_str.split('x'))) if 'x' in res_str else int(res_str)
|
||||
cmd.extend(["-S", f"res:{h}"])
|
||||
logger.debug(f"Playlist mode: using resolution limiter -S res:{h}")
|
||||
else:
|
||||
cmd.extend(["-f", "bestvideo+bestaudio/best"])
|
||||
logger.debug("Playlist mode: using dynamic best quality overall")
|
||||
except ValueError:
|
||||
cmd.extend(["-f", "bestvideo+bestaudio/best"])
|
||||
logger.debug("Playlist mode: invalid resolution string, using dynamic best quality overall")
|
||||
elif self.format_id:
|
||||
clean_format_id: str = self.format_id.split("-drc")[0] if "-drc" in self.format_id else self.format_id
|
||||
|
||||
# If the selected format is audio-only, pass it directly.
|
||||
if self.is_audio_only:
|
||||
cmd.extend(["-f", clean_format_id])
|
||||
logger.debug(f"Using audio-only format selection: {clean_format_id}")
|
||||
# If the selected format already includes an audio track (progressive), no merge needed.
|
||||
elif self.format_has_audio:
|
||||
cmd.extend(["-f", clean_format_id])
|
||||
logger.debug(f"Using progressive format with bundled audio: {clean_format_id}")
|
||||
else:
|
||||
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
|
||||
self.expected_phases = 2 # separate video and audio downloads
|
||||
logger.debug(f"Using video-only format merged with best audio: {clean_format_id}+bestaudio/best")
|
||||
else:
|
||||
# If no specific format ID, use resolution-based sorting (-S)
|
||||
res_value: str = self.resolution if self.resolution else "720" # Default to 720p if no resolution specified
|
||||
cmd.extend(["-S", f"res:{res_value}"])
|
||||
|
||||
# Force output format if enabled and merging is needed (for video)
|
||||
if self.force_output_format and not self.is_audio_only:
|
||||
if self.format_has_audio:
|
||||
# Progressive format (video with audio) - use remux to convert container
|
||||
cmd.extend(["--remux-video", self.preferred_output_format])
|
||||
logger.debug(f"Using --remux-video to force progressive format to: {self.preferred_output_format}")
|
||||
else:
|
||||
# Merging video+audio - force merge output format
|
||||
cmd.extend(["--merge-output-format", self.preferred_output_format])
|
||||
logger.debug(f"Using --merge-output-format to force merged format to: {self.preferred_output_format}")
|
||||
|
||||
# Force audio format conversion for audio-only downloads
|
||||
if self.is_audio_only and self.force_audio_format:
|
||||
cmd.append("--extract-audio")
|
||||
if self.preferred_audio_format and self.preferred_audio_format != "best":
|
||||
cmd.extend(["--audio-format", self.preferred_audio_format])
|
||||
logger.debug(f"Using --extract-audio with --audio-format {self.preferred_audio_format} for audio-only download")
|
||||
else:
|
||||
logger.debug("Using --extract-audio with best quality (no conversion) for audio-only download")
|
||||
|
||||
# Add Audio Normalization if enabled (only applies to audio-only downloads)
|
||||
if self.audio_normalization and self.is_audio_only:
|
||||
# Normalization using FFmpeg filters requires re-encoding the audio stream.
|
||||
# If the user selected "Best (No conversion)", yt-dlp attempts to stream copy (-c:a copy),
|
||||
# which will cause FFmpeg to crash with "Invalid argument".
|
||||
# We fix this by forcing an explicit actual conversion (mp3) if no format was forced.
|
||||
if not self.force_audio_format or self.preferred_audio_format == "best":
|
||||
if "--extract-audio" not in cmd:
|
||||
cmd.append("--extract-audio")
|
||||
cmd.extend(["--audio-format", "mp3"])
|
||||
logger.debug("Forced audio format to mp3 since normalization requires re-encoding")
|
||||
|
||||
# Scope the argument specifically to ExtractAudio so it doesn't conflict with other PPs
|
||||
cmd.extend(["--postprocessor-args", "ExtractAudio:-af loudnorm=I=-16:LRA=11:TP=-1.5"])
|
||||
logger.debug("Added Audio Normalization (--postprocessor-args ExtractAudio:-af loudnorm=...)")
|
||||
|
||||
# Output template with resolution in filename
|
||||
# Use string concatenation instead of Path.joinpath to avoid Path object issues
|
||||
base_path: str = self.path.as_posix()
|
||||
|
||||
# Determine the filename part of the template
|
||||
filename_part = self.filename_format if self.filename_format else "%(title)s_%(resolution)s_[%(id)s].%(ext)s"
|
||||
|
||||
if self.is_playlist:
|
||||
# Create output template with playlist subfolder
|
||||
output_template: str = f"{base_path}/%(playlist_title)s/{filename_part}"
|
||||
else:
|
||||
# For single files, automatically ignore/remove playlist-specific preamble (like "%(playlist_index)s - ")
|
||||
import re
|
||||
filename_part = re.sub(r'%\(playlist_index[^)]*\)[a-zA-Z0-9]*\s*(?:[-_]\s*)?', '', filename_part)
|
||||
output_template: str = f"{base_path}/{filename_part}"
|
||||
|
||||
cmd.extend(["-o", str(output_template)])
|
||||
|
||||
# Add common options
|
||||
cmd.append("--force-overwrites")
|
||||
|
||||
# Add playlist items if specified
|
||||
if self.is_playlist and self.playlist_items:
|
||||
cmd.extend(["--playlist-items", self.playlist_items])
|
||||
|
||||
# Add subtitle options if subtitles are selected
|
||||
if self.subtitle_langs:
|
||||
# Subtitles work with both audio-only and video formats
|
||||
# For audio-only formats, subtitles will be downloaded as separate files
|
||||
cmd.append("--write-subs")
|
||||
|
||||
# Get language codes from subtitle selections
|
||||
lang_codes: List[str] = []
|
||||
has_auto_generated = False
|
||||
for sub_selection in self.subtitle_langs:
|
||||
try:
|
||||
# Extract just the language code (e.g., 'en' from 'en - Manual')
|
||||
lang_code = sub_selection.split(" - ")[0]
|
||||
lang_codes.append(lang_code)
|
||||
if "Auto-generated" in sub_selection:
|
||||
has_auto_generated = True
|
||||
except Exception as e:
|
||||
logger.exception(f"Could not parse subtitle selection '{sub_selection}': {e}")
|
||||
|
||||
if lang_codes:
|
||||
cmd.extend(["--sub-langs", ",".join(lang_codes)])
|
||||
if has_auto_generated:
|
||||
cmd.append("--write-auto-subs") # Include auto-generated subtitles
|
||||
|
||||
# Only embed subtitles if merge is enabled
|
||||
if self.merge_subs:
|
||||
cmd.append("--embed-subs")
|
||||
|
||||
# Add SponsorBlock if enabled
|
||||
if self.enable_sponsorblock and self.sponsorblock_categories:
|
||||
cmd.append("--sponsorblock-remove")
|
||||
cmd.append(",".join(self.sponsorblock_categories))
|
||||
|
||||
# Add description saving if enabled
|
||||
if self.save_description:
|
||||
cmd.append("--write-description")
|
||||
|
||||
# Add chapters embedding if enabled
|
||||
if self.embed_chapters:
|
||||
cmd.append("--embed-chapters")
|
||||
|
||||
# Add cookies if specified
|
||||
if self.cookie_file:
|
||||
cmd.extend(["--cookies", str(self.cookie_file)])
|
||||
elif self.browser_cookies:
|
||||
cmd.extend(["--cookies-from-browser", self.browser_cookies])
|
||||
|
||||
# Add proxy settings if specified
|
||||
if self.proxy_url:
|
||||
cmd.extend(["--proxy", self.proxy_url])
|
||||
|
||||
if self.geo_proxy_url:
|
||||
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
|
||||
|
||||
# Add rate limit if specified
|
||||
if self.rate_limit:
|
||||
cmd.extend(["-r", self.rate_limit])
|
||||
|
||||
# Add download section if specified
|
||||
if self.download_section:
|
||||
cmd.extend(["--download-sections", self.download_section])
|
||||
|
||||
# Add force keyframes option if enabled
|
||||
if self.force_keyframes:
|
||||
cmd.append("--force-keyframes-at-cuts")
|
||||
|
||||
logger.debug(f"Added download section: {self.download_section}, Force keyframes: {self.force_keyframes}")
|
||||
|
||||
# Add the URL as the final argument
|
||||
if self.is_playlist:
|
||||
cmd.append("--ignore-errors")
|
||||
cmd.append("--no-abort-on-error")
|
||||
cmd.append(self.url)
|
||||
|
||||
return cmd
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
logger.debug("Starting download thread")
|
||||
|
||||
# Get initial list of subtitle files to compare later
|
||||
self.initial_subtitle_files = set()
|
||||
if self.merge_subs:
|
||||
try:
|
||||
# Scan for existing subtitle files in the directory
|
||||
for file in self.path.rglob("*"):
|
||||
if file.suffix in {".vtt", ".srt"}:
|
||||
self.initial_subtitle_files.add(file)
|
||||
logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error scanning for initial subtitle files: {e}")
|
||||
|
||||
# Use direct CLI command
|
||||
self._run_direct_command()
|
||||
|
||||
except Exception as e:
|
||||
# Catch errors during setup
|
||||
logger.critical(f"Critical error in download thread: {e}", exc_info=True)
|
||||
self.error_signal.emit(f"Critical error in download thread: {e}")
|
||||
|
||||
def _run_direct_command(self) -> None:
|
||||
"""Run yt-dlp as a direct command line process instead of using Python API."""
|
||||
try:
|
||||
self.error_lines = [] # Initialize error capture list
|
||||
cmd: List[str] = self._build_yt_dlp_command()
|
||||
|
||||
cmd_str: str = " ".join(shlex.quote(str(arg)) for arg in cmd)
|
||||
logger.debug(f"Executing command: {cmd_str}")
|
||||
|
||||
self.status_signal.emit(_("download.starting"))
|
||||
self.progress_signal.emit(0)
|
||||
|
||||
# Start the process
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
# Use start_new_session on Unix to enable process group termination
|
||||
|
||||
popen_kwargs = {
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"bufsize": 1, # Line buffered
|
||||
"encoding": "utf-8",
|
||||
"errors": "replace",
|
||||
}
|
||||
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS
|
||||
else:
|
||||
# On Unix, start a new session so we can kill the entire process group
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
self.process = subprocess.Popen(cmd, **popen_kwargs)
|
||||
|
||||
# Process output line by line to update progress
|
||||
for line in iter(self.process.stdout.readline, ""): # type: ignore
|
||||
if self.cancelled:
|
||||
# Kill the entire process tree (yt-dlp + ffmpeg children)
|
||||
self._terminate_process_tree(self.process)
|
||||
|
||||
# Add delay before cleanup to allow file handles to be released
|
||||
time.sleep(2)
|
||||
self.cleanup_partial_files()
|
||||
self.status_signal.emit(_("download.cancelled"))
|
||||
self.finished_signal.emit()
|
||||
return
|
||||
|
||||
# Wait if paused
|
||||
while self.paused and not self.cancelled:
|
||||
time.sleep(0.1)
|
||||
|
||||
# Parse the line for download progress and status updates
|
||||
self._parse_output_line(line)
|
||||
|
||||
# Wait for process to complete
|
||||
return_code: int = self.process.wait()
|
||||
|
||||
# Special handling for specific errors
|
||||
# return code 127 typically means command not found
|
||||
if return_code == 127:
|
||||
self.error_signal.emit(
|
||||
_("errors.ytdlp_not_found_path")
|
||||
)
|
||||
return
|
||||
|
||||
if return_code == 0 or (self.is_playlist and return_code != 0 and self.current_filename is not None):
|
||||
self.progress_signal.emit(100)
|
||||
|
||||
# Robust file finding: Always search for the most recent file
|
||||
# This handles all post-processing scenarios (merging, remuxing, subtitle embedding, etc.)
|
||||
final_file_found = False
|
||||
|
||||
try:
|
||||
# First, check if last_file_path exists and is valid
|
||||
if self.last_file_path:
|
||||
last_path = Path(self.last_file_path)
|
||||
if last_path.exists() and last_path.is_file():
|
||||
# File exists at the tracked path
|
||||
self.current_filename = last_path.name
|
||||
final_file_found = True
|
||||
logger.info(f"Found file at tracked path: {self.last_file_path}")
|
||||
|
||||
# If not found at tracked path, search for the most recent file
|
||||
if not final_file_found:
|
||||
logger.info("Searching for most recent downloaded file...")
|
||||
potential_files = []
|
||||
|
||||
# Search in download directory and subdirectories (for playlists)
|
||||
for ext in MEDIA_EXTENSIONS:
|
||||
potential_files.extend(self.path.glob(f'*{ext}'))
|
||||
# Also check subdirectories (for playlist downloads)
|
||||
potential_files.extend(self.path.glob(f'*/*{ext}'))
|
||||
|
||||
if potential_files:
|
||||
# Sort by modification time and get the most recent
|
||||
most_recent = max(potential_files, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
# Verify it was modified recently (within last 30 seconds to account for post-processing)
|
||||
time_since_modification = time.time() - most_recent.stat().st_mtime
|
||||
|
||||
if time_since_modification < 30:
|
||||
self.last_file_path = str(most_recent)
|
||||
self.current_filename = most_recent.name
|
||||
final_file_found = True
|
||||
logger.info(f"Found most recent file (modified {time_since_modification:.1f}s ago): {self.last_file_path}")
|
||||
else:
|
||||
logger.warning(f"Most recent file is too old ({time_since_modification:.1f}s), might not be the right one")
|
||||
else:
|
||||
logger.warning("No video/audio files found in download directory")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding final file: {e}", exc_info=True)
|
||||
|
||||
# Set completion status
|
||||
if return_code != 0:
|
||||
self.status_signal.emit(_("download.completed") + " (with some errors)")
|
||||
else:
|
||||
self.status_signal.emit(_("download.completed"))
|
||||
|
||||
# Clean up subtitle files if they were merged, with a small delay
|
||||
# to ensure the embedding process has completed
|
||||
if self.merge_subs:
|
||||
# Add a significant delay to ensure ffmpeg has released all file handles
|
||||
# and any post-processing is complete
|
||||
self.status_signal.emit(_("download.completed_cleaning"))
|
||||
time.sleep(3) # Increased delay to 3 seconds
|
||||
self.cleanup_subtitle_files()
|
||||
|
||||
self.finished_signal.emit()
|
||||
else:
|
||||
# Check if it was cancelled
|
||||
if self.cancelled:
|
||||
self.status_signal.emit(_("download.cancelled"))
|
||||
self.finished_signal.emit()
|
||||
else:
|
||||
# Provide informative error message based on captured output
|
||||
if self.error_lines:
|
||||
# Use the captured error lines (last 2 for context)
|
||||
error_msg = "\n".join(self.error_lines[-2:])
|
||||
self.error_signal.emit(
|
||||
_("errors.ytdlp_failed", error=error_msg)
|
||||
)
|
||||
else:
|
||||
# Fallback to generic return code error
|
||||
self.error_signal.emit(
|
||||
_("errors.download_failed_return_code", return_code=return_code)
|
||||
)
|
||||
|
||||
# Add delay before cleanup to allow file handles to be released
|
||||
time.sleep(1)
|
||||
self.cleanup_partial_files()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in direct command: {e}")
|
||||
self.error_signal.emit(_("errors.direct_command_error", error=str(e)))
|
||||
# Add delay before cleanup to allow file handles to be released
|
||||
time.sleep(1)
|
||||
self.cleanup_partial_files()
|
||||
|
||||
def _parse_output_line(self, line: str) -> None:
|
||||
"""Parse yt-dlp command output to update progress and status."""
|
||||
line = line.strip()
|
||||
# logger.info(f"yt-dlp: {line}") # Log all output - OPTIONALLY UNCOMMENT FOR VERBOSE DEBUG
|
||||
|
||||
# Capture error lines
|
||||
if "ERROR:" in line:
|
||||
if hasattr(self, 'error_lines'):
|
||||
self.error_lines.append(line)
|
||||
|
||||
# Extract filename when the destination line appears
|
||||
# Use a slightly more robust regex looking for the start of the line
|
||||
dest_match = re.search(r"^\[download\] Destination:\s*(.*)", line)
|
||||
if dest_match:
|
||||
try:
|
||||
filepath = dest_match.group(1).strip()
|
||||
self.current_filename = Path(filepath).name
|
||||
self.last_file_path = filepath # Store the full path for later cleanup
|
||||
if Path(filepath) not in self.download_files and Path(filepath).suffix.lower() not in SUBTITLE_EXTENSIONS:
|
||||
self._media_phase += 1
|
||||
self.download_files.add(Path(filepath))
|
||||
logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG
|
||||
|
||||
# Check if this is an audio-only download by looking in the previous lines
|
||||
is_audio_download = False
|
||||
|
||||
# Look for audio format indicators in the current line or preceding output
|
||||
# yt-dlp typically mentions format like "Downloading format 251 - audio only"
|
||||
if " - audio only" in line:
|
||||
is_audio_download = True
|
||||
# Check if the format ID is mentioned earlier in the line
|
||||
format_match = re.search(r"Downloading format (\d+)", line)
|
||||
if format_match:
|
||||
format_id = format_match.group(1)
|
||||
logger.debug(f"Detected format ID: {format_id}")
|
||||
# Format IDs for audio typically have different patterns
|
||||
# (like 140, 251 for audio vs 137, 248 for video)
|
||||
# This is just a heuristic since format IDs can vary
|
||||
|
||||
# Determine file type based on extension and context
|
||||
ext = Path(self.current_filename).suffix.lower()
|
||||
|
||||
# Check if this is explicitly an audio stream download
|
||||
if is_audio_download or "Downloading audio" in line:
|
||||
self.status_signal.emit(_("download.downloading_audio"))
|
||||
# Video file extensions with likely video content
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
self.status_signal.emit(_("download.downloading_video"))
|
||||
# Audio file extensions
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
self.status_signal.emit(_("download.downloading_audio"))
|
||||
# Subtitle file extensions
|
||||
elif ext in SUBTITLE_EXTENSIONS:
|
||||
self.status_signal.emit(_("download.downloading_subtitle"))
|
||||
# Default case
|
||||
else:
|
||||
self.status_signal.emit(_("download.downloading"))
|
||||
except Exception as e:
|
||||
logger.exception(f"Error extracting filename from line '{line}': {e}")
|
||||
self.status_signal.emit(_("download.downloading_fallback")) # Fallback status
|
||||
return # Don't process this line further for speed/ETA
|
||||
|
||||
# Check for specific download types in the output
|
||||
if "Downloading video" in line:
|
||||
self.status_signal.emit(_("download.downloading_video"))
|
||||
return
|
||||
|
||||
elif "Downloading audio" in line:
|
||||
self.status_signal.emit(_("download.downloading_audio"))
|
||||
return
|
||||
|
||||
# Detect subtitle file creation
|
||||
# Look for lines like "[info] Writing video subtitles to: filename.xx.vtt"
|
||||
subtitle_match = re.search(
|
||||
r"(?:Writing|Downloading) (?:video )?subtitles.*?(?:to|:)\s*(.+\.(?:vtt|srt))(?:\s|$)",
|
||||
line,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if subtitle_match:
|
||||
subtitle_file = subtitle_match.group(1).strip()
|
||||
|
||||
# Clean up the path - remove any duplicated directory paths
|
||||
# Sometimes yt-dlp output contains malformed paths like "dir: dir/file"
|
||||
if ":" in subtitle_file and os.name == "nt": # Windows paths
|
||||
# Look for pattern like "C:\path: C:\path\file" and extract the latter
|
||||
colon_parts = subtitle_file.split(": ")
|
||||
if len(colon_parts) > 1:
|
||||
# Take the last part which should be the actual file path
|
||||
subtitle_file = colon_parts[-1].strip()
|
||||
|
||||
# Show subtitle download message
|
||||
self.status_signal.emit(_("download.downloading_subtitle"))
|
||||
# Store the subtitle file path for later deletion if merging is enabled
|
||||
if self.merge_subs:
|
||||
subtitle_path = Path(subtitle_file)
|
||||
if not subtitle_path.is_absolute():
|
||||
# If it's a relative path, make it absolute based on current path
|
||||
subtitle_path = self.path.joinpath(subtitle_file)
|
||||
self.subtitle_files.append(str(subtitle_path))
|
||||
logger.debug(f"Tracking subtitle file for later cleanup: {subtitle_path}")
|
||||
return
|
||||
|
||||
# Send status updates based on output line content
|
||||
if "Downloading webpage" in line or "Extracting URL" in line:
|
||||
self.status_signal.emit(_("download.fetching_info"))
|
||||
self.progress_signal.emit(0)
|
||||
elif "[download] Destination:" in line:
|
||||
# Extract the destination filename
|
||||
match = re.search(r"Destination: (.+)", line)
|
||||
if match:
|
||||
dest_path = match.group(1).strip()
|
||||
self.current_filename = Path(dest_path).name
|
||||
self.last_file_path = dest_path
|
||||
self.download_files.add(Path(dest_path))
|
||||
logger.debug(f"Captured destination filename: {self.current_filename}")
|
||||
elif "Downloading API JSON" in line:
|
||||
self.status_signal.emit(_("download.processing_playlist"))
|
||||
self.progress_signal.emit(0)
|
||||
elif "Downloading m3u8 information" in line:
|
||||
self.status_signal.emit(_("download.preparing_streams"))
|
||||
self.progress_signal.emit(0)
|
||||
elif "[download] Downloading video " in line:
|
||||
self.status_signal.emit(_("download.downloading_video"))
|
||||
elif "[download] Downloading audio " in line:
|
||||
self.status_signal.emit(_("download.downloading_audio"))
|
||||
elif "Downloading format" in line:
|
||||
# Try to detect if it's audio or video format
|
||||
if " - audio only" in line:
|
||||
self.status_signal.emit(_("download.downloading_audio"))
|
||||
elif " - video only" in line:
|
||||
self.status_signal.emit(_("download.downloading_video"))
|
||||
else:
|
||||
# Don't emit generic message - format is unclear
|
||||
pass
|
||||
|
||||
# Look for download percentage
|
||||
percent_match = re.search(r"(\d+\.\d+)%", line)
|
||||
if percent_match:
|
||||
try:
|
||||
percent = float(percent_match.group(1))
|
||||
# When video and audio download as separate streams, scale each
|
||||
# phase into its share of the bar instead of jumping 0-100 twice
|
||||
if self.expected_phases > 1 and not self.is_playlist:
|
||||
completed = max(0, min(self._media_phase - 1, self.expected_phases - 1))
|
||||
percent = (completed * 100.0 + percent) / self.expected_phases
|
||||
self.progress_signal.emit(percent)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Check for download speed and ETA
|
||||
if "[download]" in line and "%" in line:
|
||||
# Try to extract more detailed status info
|
||||
try:
|
||||
# Look for speed
|
||||
speed_match = re.search(r"at\s+(\d+\.\d+[KMG]iB/s)", line)
|
||||
speed_str = speed_match.group(1) if speed_match else "N/A"
|
||||
|
||||
# Look for ETA
|
||||
eta_match = re.search(r"ETA\s+(\d+:\d+)", line)
|
||||
eta_str = eta_match.group(1) if eta_match else "N/A"
|
||||
|
||||
# Simplify status message to only show the speed and ETA
|
||||
status = f"{_('download.speed')}: {speed_str} | {_('download.eta')}: {eta_str}"
|
||||
self.update_details.emit(status)
|
||||
except Exception as e:
|
||||
# If parsing fails, just show basic status (maybe log the error)
|
||||
logger.exception(f"Error parsing download details line: {line} -> {e}")
|
||||
pass # Keep basic status emission below if needed, or emit generic details
|
||||
|
||||
# Check for post-processing
|
||||
if "[Merger]" in line or "Merging formats" in line:
|
||||
self.status_signal.emit(_("download.merging_formats"))
|
||||
self.progress_signal.emit(95)
|
||||
# Extract the merged output filename
|
||||
merger_match = re.search(r"Merging formats into \"(.+?)\"", line)
|
||||
if merger_match:
|
||||
merged_filepath = merger_match.group(1).strip()
|
||||
self.current_filename = Path(merged_filepath).name
|
||||
self.last_file_path = merged_filepath
|
||||
self.download_files.add(Path(merged_filepath))
|
||||
logger.debug(f"Updated to merged filename: {self.current_filename}")
|
||||
elif "SponsorBlock" in line:
|
||||
self.status_signal.emit(_("download.removing_sponsor_segments"))
|
||||
self.progress_signal.emit(97)
|
||||
elif "Deleting original file" in line:
|
||||
self.progress_signal.emit(98)
|
||||
elif "has already been downloaded" in line:
|
||||
# File already exists - extract filename
|
||||
match = re.search(r"(.*?) has already been downloaded", line)
|
||||
if match:
|
||||
filename = Path(match.group(1)).name
|
||||
# Determine file type based on extension for existing file message
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
if ext in VIDEO_EXTENSIONS:
|
||||
self.status_signal.emit(f"⚠️ Video file already exists")
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
self.status_signal.emit(f"⚠️ Audio file already exists")
|
||||
elif ext in SUBTITLE_EXTENSIONS:
|
||||
self.status_signal.emit(f"⚠️ Subtitle file already exists")
|
||||
else:
|
||||
self.status_signal.emit(f"⚠️ File already exists")
|
||||
|
||||
self.file_exists_signal.emit(filename)
|
||||
else:
|
||||
logger.info(f"Could not extract filename from 'already downloaded' line: {line}")
|
||||
self.status_signal.emit(_("download.file_exists")) # Fallback status
|
||||
elif "Finished downloading" in line:
|
||||
self.progress_signal.emit(100)
|
||||
|
||||
# Show completion message based on file type
|
||||
if self.current_filename:
|
||||
ext = Path(self.current_filename).suffix.lower()
|
||||
|
||||
# Video file extensions
|
||||
if ext in VIDEO_EXTENSIONS:
|
||||
self.status_signal.emit(_("download.video_completed"))
|
||||
# Audio file extensions
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
self.status_signal.emit(_("download.audio_completed"))
|
||||
# Subtitle file extensions
|
||||
elif ext in SUBTITLE_EXTENSIONS:
|
||||
self.status_signal.emit(_("download.subtitle_completed"))
|
||||
# Default case
|
||||
else:
|
||||
self.status_signal.emit(_("download.completed"))
|
||||
else:
|
||||
self.status_signal.emit(_("download.completed"))
|
||||
|
||||
self.update_details.emit("") # Clear details label on completion
|
||||
|
||||
def pause(self) -> None:
|
||||
self.paused = True
|
||||
self._signal_process_group(signal.SIGSTOP if sys.platform != "win32" else None)
|
||||
|
||||
def resume(self) -> None:
|
||||
self.paused = False
|
||||
self._signal_process_group(signal.SIGCONT if sys.platform != "win32" else None)
|
||||
|
||||
def _signal_process_group(self, sig: Optional[int]) -> None:
|
||||
"""Send a signal to yt-dlp's whole process group (yt-dlp + ffmpeg children).
|
||||
|
||||
On Windows there is no SIGSTOP/SIGCONT; pausing there only stops output
|
||||
consumption, which is a known limitation.
|
||||
"""
|
||||
if sig is None or not self.process:
|
||||
return
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), sig)
|
||||
except (ProcessLookupError, PermissionError, OSError) as e:
|
||||
logger.debug(f"Could not signal process group: {e}")
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
# Terminate the subprocess if it's running
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,483 @@
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
from ..utils.ytsage_constants import (
|
||||
FFMPEG_7Z_DOWNLOAD_URL,
|
||||
FFMPEG_7Z_SHA256_URL,
|
||||
FFMPEG_ZIP_DOWNLOAD_URL,
|
||||
FFMPEG_ZIP_SHA256_URL,
|
||||
OS_NAME,
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
|
||||
|
||||
def check_7zip_installed() -> bool:
|
||||
"""Check if 7-Zip is installed on Windows."""
|
||||
try:
|
||||
subprocess.run(["7z", "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=SUBPROCESS_CREATIONFLAGS)
|
||||
return True
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def download_file(url, dest_path, progress_callback=None) -> bool:
|
||||
"""Download a file from URL to destination path with progress indication."""
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=30) # Added timeout
|
||||
response.raise_for_status() # Check for HTTP errors
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
|
||||
with open(dest_path, "wb") as f:
|
||||
if total_size == 0:
|
||||
f.write(response.content)
|
||||
else:
|
||||
downloaded = 0
|
||||
for data in response.iter_content(chunk_size=8192):
|
||||
downloaded += len(data)
|
||||
f.write(data)
|
||||
if progress_callback:
|
||||
progress = int((downloaded / total_size) * 100)
|
||||
progress_callback(f"⚡ Downloading FFmpeg components... {progress}%")
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.info(f"Download error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_file_sha256(file_path) -> str:
|
||||
"""Calculate SHA-256 hash of a file."""
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(chunk)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def verify_sha256(file_path, expected_hash_url) -> bool:
|
||||
"""Verify file SHA-256 hash against expected hash from URL."""
|
||||
try:
|
||||
# Download the SHA-256 hash
|
||||
response = requests.get(expected_hash_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
expected_hash = response.text.strip().split()[0] # Get just the hash part
|
||||
|
||||
# Calculate actual hash
|
||||
actual_hash = get_file_sha256(file_path)
|
||||
|
||||
# Compare hashes
|
||||
if actual_hash.lower() == expected_hash.lower():
|
||||
logger.info("SHA-256 verification successful!")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"SHA-256 verification failed!")
|
||||
logger.info(f"Expected: {expected_hash}")
|
||||
logger.info(f"Actual: {actual_hash}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.info(f"⚠️ SHA-256 verification error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_ffmpeg_install_path() -> Path:
|
||||
"""
|
||||
Get the FFmpeg installation path.
|
||||
For Windows, tries to find the latest essentials build dynamically.
|
||||
"""
|
||||
if OS_NAME == "Windows":
|
||||
ffmpeg_base = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
|
||||
|
||||
# If the directory exists, look for any ffmpeg-*-essentials_build folder
|
||||
if ffmpeg_base.exists():
|
||||
# Find all directories matching the pattern
|
||||
essentials_dirs = list(ffmpeg_base.glob("ffmpeg-*-essentials_build"))
|
||||
if essentials_dirs:
|
||||
# Sort by name (which includes version) and take the latest
|
||||
latest_dir = sorted(essentials_dirs, reverse=True)[0]
|
||||
bin_dir = latest_dir / "bin"
|
||||
if bin_dir.exists():
|
||||
return bin_dir
|
||||
|
||||
# Fallback: return default path (even if it doesn't exist yet)
|
||||
return ffmpeg_base / "ffmpeg-essentials_build" / "bin"
|
||||
|
||||
elif OS_NAME == "Darwin":
|
||||
paths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]
|
||||
for path in paths:
|
||||
if Path(path).joinpath("ffmpeg").exists():
|
||||
return Path(path)
|
||||
return Path("/usr/local/bin") # Default Homebrew path
|
||||
else:
|
||||
return Path("/usr/bin") # Standard Linux path
|
||||
|
||||
|
||||
|
||||
def get_ffmpeg_path() -> str | Path:
|
||||
"""
|
||||
Get the FFmpeg executable path, either from PATH or installation directory.
|
||||
Returns:
|
||||
str: Path to FFmpeg executable or 'ffmpeg' if found in PATH but path unknown
|
||||
"""
|
||||
try:
|
||||
# First try to find ffmpeg in PATH using 'where' on Windows or 'which' on Unix
|
||||
if OS_NAME == "Windows":
|
||||
# On Windows, use 'where' command and hide console window
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
|
||||
result = subprocess.run(
|
||||
["where", "ffmpeg"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
ffmpeg_path = result.stdout.strip().split("\n")[0]
|
||||
return ffmpeg_path
|
||||
else:
|
||||
# On Unix systems, use 'which' command
|
||||
result = subprocess.run(["which", "ffmpeg"], capture_output=True, text=True, check=False)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
ffmpeg_path = result.stdout.strip()
|
||||
return ffmpeg_path
|
||||
except Exception as e:
|
||||
logger.exception(f"Error finding ffmpeg in PATH: {e}")
|
||||
|
||||
# If not found in PATH, check the installation directory
|
||||
ffmpeg_install_path = get_ffmpeg_install_path()
|
||||
if OS_NAME == "Windows":
|
||||
ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg.exe")
|
||||
else:
|
||||
ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg")
|
||||
|
||||
if ffmpeg_exe.exists():
|
||||
return ffmpeg_exe
|
||||
|
||||
# Return command name as fallback
|
||||
return "ffmpeg"
|
||||
|
||||
|
||||
def check_ffmpeg_installed() -> bool:
|
||||
"""Check if FFmpeg is installed and accessible."""
|
||||
try:
|
||||
# First try the PATH
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
timeout=5,
|
||||
) # Added timeout
|
||||
return True
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
# If not in PATH, check the installation directory
|
||||
ffmpeg_path = get_ffmpeg_install_path()
|
||||
if OS_NAME == "Windows":
|
||||
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe")
|
||||
else:
|
||||
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg")
|
||||
|
||||
if ffmpeg_exe.exists():
|
||||
# Add to PATH if found
|
||||
os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.info(f"FFmpeg check error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_ffmpeg_windows(progress_callback=None) -> bool:
|
||||
"""Install FFmpeg on Windows using essentials build with 7z method primarily, with zip as fallback."""
|
||||
# Check if already installed
|
||||
if check_ffmpeg_installed():
|
||||
logger.info("FFmpeg is already installed!")
|
||||
if progress_callback:
|
||||
progress_callback("✅ FFmpeg is already installed!")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Define variables for essentials build
|
||||
extract_dir = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
|
||||
|
||||
# Create extraction directory if it doesn't exist
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Try 7z method first (smaller size)
|
||||
use_7zip = check_7zip_installed()
|
||||
success = False
|
||||
|
||||
if use_7zip:
|
||||
logger.info("Using 7-Zip method (smaller download size)...")
|
||||
if progress_callback:
|
||||
progress_callback("⚡ Using 7-Zip method...")
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".7z").name
|
||||
|
||||
# Download 7z file
|
||||
if progress_callback:
|
||||
progress_callback("⬇ Downloading FFmpeg (7z)...")
|
||||
if download_file(
|
||||
FFMPEG_7Z_DOWNLOAD_URL,
|
||||
temp_file,
|
||||
progress_callback=progress_callback,
|
||||
):
|
||||
# Verify SHA-256 hash for 7z file
|
||||
if progress_callback:
|
||||
progress_callback("🔐 Verifying download integrity...")
|
||||
if verify_sha256(temp_file, FFMPEG_7Z_SHA256_URL):
|
||||
logger.info("Extracting FFmpeg components from 7z archive...")
|
||||
if progress_callback:
|
||||
progress_callback("⚙ Extracting FFmpeg...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["7z", "x", temp_file, f"-o{extract_dir}", "-y"],
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
timeout=300,
|
||||
check=True,
|
||||
)
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.exception(f"7z extraction failed: {e}, trying zip fallback...")
|
||||
if progress_callback:
|
||||
progress_callback("❌ 7z extraction failed, trying zip fallback...")
|
||||
else:
|
||||
logger.error("SHA-256 verification failed for 7z file, trying zip fallback...")
|
||||
if progress_callback:
|
||||
progress_callback("❌ SHA-256 verification failed, trying zip fallback...")
|
||||
|
||||
# Clean up temp file
|
||||
try:
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to zip method if 7z failed or not available
|
||||
if not success:
|
||||
logger.info("Using ZIP method as fallback...")
|
||||
if progress_callback:
|
||||
progress_callback("📦 Using ZIP method...")
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip").name
|
||||
|
||||
# Download zip file
|
||||
if progress_callback:
|
||||
progress_callback("⬇ Downloading FFmpeg (zip)...")
|
||||
if not download_file(
|
||||
FFMPEG_ZIP_DOWNLOAD_URL,
|
||||
temp_file,
|
||||
progress_callback=progress_callback,
|
||||
):
|
||||
logger.error("Failed to download FFmpeg (both 7z and zip methods failed)")
|
||||
if progress_callback:
|
||||
progress_callback("❌ Failed to download FFmpeg")
|
||||
return False
|
||||
|
||||
# Verify SHA-256 hash for zip
|
||||
if progress_callback:
|
||||
progress_callback("🔐 Verifying download integrity...")
|
||||
if not verify_sha256(temp_file, FFMPEG_ZIP_SHA256_URL):
|
||||
logger.error("SHA-256 verification failed for zip file, aborting installation")
|
||||
if progress_callback:
|
||||
progress_callback("❌ SHA-256 verification failed, aborting installation")
|
||||
try:
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
logger.info("Extracting FFmpeg components from zip archive...")
|
||||
if progress_callback:
|
||||
progress_callback("⚙ Extracting FFmpeg...")
|
||||
try:
|
||||
import zipfile
|
||||
with zipfile.ZipFile(temp_file, "r") as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.exception(f"Extraction failed: {e}")
|
||||
if progress_callback:
|
||||
progress_callback(f"❌ Extraction failed: {e}")
|
||||
return False
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not success:
|
||||
logger.error("Both 7z and zip methods failed")
|
||||
if progress_callback:
|
||||
progress_callback("❌ Installation failed")
|
||||
return False
|
||||
|
||||
# Find the bin directory in the extracted essentials build
|
||||
logger.info("Locating FFmpeg binaries...")
|
||||
if progress_callback:
|
||||
progress_callback("🔍 Locating FFmpeg binaries...")
|
||||
|
||||
bin_dir = None
|
||||
# Look for any directory matching ffmpeg-*-essentials_build pattern
|
||||
for item in extract_dir.iterdir():
|
||||
if item.is_dir() and "essentials_build" in item.name.lower():
|
||||
potential_bin = item / "bin"
|
||||
if potential_bin.exists():
|
||||
bin_dir = potential_bin
|
||||
logger.info(f"Found FFmpeg bin directory: {bin_dir}")
|
||||
break
|
||||
|
||||
if not bin_dir:
|
||||
logger.error("Could not locate FFmpeg bin directory")
|
||||
if progress_callback:
|
||||
progress_callback("❌ Could not locate FFmpeg bin directory")
|
||||
return False
|
||||
|
||||
logger.info("Configuring system paths...")
|
||||
if progress_callback:
|
||||
progress_callback("🔧 Configuring system paths...")
|
||||
|
||||
# Persist to the user PATH via the registry. setx must not be used here:
|
||||
# it truncates values at 1024 characters, and os.environ["PATH"] is the
|
||||
# merged system+user PATH, so writing it back would permanently duplicate
|
||||
# every system entry into the user hive.
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
|
||||
try:
|
||||
stored_path, value_type = winreg.QueryValueEx(key, "Path")
|
||||
except FileNotFoundError:
|
||||
stored_path, value_type = "", winreg.REG_EXPAND_SZ
|
||||
user_parts = [p for p in stored_path.split(os.pathsep) if p]
|
||||
# Drop stale ffmpeg entries we previously added, then prepend the new one
|
||||
user_parts = [p for p in user_parts if "ffmpeg" not in p.lower() or p == str(bin_dir)]
|
||||
if str(bin_dir) not in user_parts:
|
||||
user_parts.insert(0, str(bin_dir))
|
||||
winreg.SetValueEx(key, "Path", 0, value_type, os.pathsep.join(user_parts))
|
||||
|
||||
# Broadcast the change so new shells pick it up without relogin
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.user32.SendMessageTimeoutW(0xFFFF, 0x001A, 0, "Environment", 0x0002, 5000, None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update PATH permanently: {e}")
|
||||
|
||||
# Update for the current session regardless
|
||||
if str(bin_dir) not in os.environ.get("PATH", "").split(os.pathsep):
|
||||
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
# Verify installation
|
||||
if progress_callback:
|
||||
progress_callback("✅ Verifying installation...")
|
||||
if not check_ffmpeg_installed():
|
||||
logger.error("FFmpeg installation verification failed")
|
||||
if progress_callback:
|
||||
progress_callback("⚠️ Installation completed but verification failed")
|
||||
return True # Still return True as files were extracted
|
||||
|
||||
logger.info("FFmpeg installation completed successfully!")
|
||||
if progress_callback:
|
||||
progress_callback("✅ FFmpeg installation completed successfully!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error installing FFmpeg: {e}")
|
||||
if progress_callback:
|
||||
progress_callback(f"❌ Error installing FFmpeg: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_ffmpeg_macos() -> bool:
|
||||
"""Install FFmpeg on macOS using Homebrew."""
|
||||
try:
|
||||
# Check if Homebrew is installed
|
||||
try:
|
||||
subprocess.run(
|
||||
["brew", "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
# Never curl|bash a remote script without the user's say-so.
|
||||
# Homebrew installation is the user's decision to make in a
|
||||
# terminal, where the script can also prompt for sudo properly.
|
||||
logger.error(
|
||||
"Homebrew is not installed. Install it from https://brew.sh and retry, "
|
||||
"or install ffmpeg another way."
|
||||
)
|
||||
return False
|
||||
|
||||
# Install FFmpeg
|
||||
logger.info("Installing FFmpeg...")
|
||||
subprocess.run(["brew", "install", "ffmpeg"], check=True, timeout=300)
|
||||
|
||||
# Verify installation
|
||||
if not check_ffmpeg_installed():
|
||||
logger.error("FFmpeg installation verification failed")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error installing FFmpeg: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_ffmpeg_linux() -> bool:
|
||||
"""Install FFmpeg on Linux using appropriate package manager."""
|
||||
try:
|
||||
# Detect the package manager
|
||||
if shutil.which("apt"):
|
||||
# Debian/Ubuntu
|
||||
subprocess.run(["sudo", "apt", "update"], check=True, timeout=60)
|
||||
subprocess.run(["sudo", "apt", "install", "-y", "ffmpeg"], check=True, timeout=300)
|
||||
elif shutil.which("dnf"):
|
||||
# Fedora
|
||||
subprocess.run(["sudo", "dnf", "install", "-y", "ffmpeg"], check=True, timeout=300)
|
||||
elif shutil.which("pacman"):
|
||||
# Arch Linux
|
||||
subprocess.run(
|
||||
["sudo", "pacman", "-S", "--noconfirm", "ffmpeg"],
|
||||
check=True,
|
||||
timeout=300,
|
||||
)
|
||||
elif shutil.which("snap"):
|
||||
# Universal snap package
|
||||
subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300)
|
||||
else:
|
||||
logger.error("No supported package manager found")
|
||||
return False
|
||||
|
||||
# Verify installation
|
||||
if not check_ffmpeg_installed():
|
||||
logger.error("FFmpeg installation verification failed")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error installing FFmpeg: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def auto_install_ffmpeg(progress_callback=None) -> bool:
|
||||
"""Automatically install FFmpeg based on the operating system."""
|
||||
if OS_NAME == "Windows":
|
||||
return install_ffmpeg_windows(progress_callback=progress_callback)
|
||||
elif OS_NAME == "Darwin":
|
||||
return install_ffmpeg_macos()
|
||||
elif OS_NAME == "Linux":
|
||||
return install_ffmpeg_linux()
|
||||
else:
|
||||
logger.info(f"Unsupported operating system: {OS_NAME}")
|
||||
if progress_callback:
|
||||
progress_callback(f"❌ Unsupported operating system: {OS_NAME}")
|
||||
return False
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
libmpv availability probe
|
||||
=========================
|
||||
|
||||
python-mpv is a ctypes binding: importing it raises OSError when the libmpv
|
||||
shared library is missing from the system. All player code must import mpv
|
||||
through probe_player() so the rest of the app (search, browse, downloads)
|
||||
keeps working without libmpv installed.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ..utils.ytsage_constants import OS_NAME
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
_probe_result: Optional[Tuple[bool, str]] = None
|
||||
|
||||
|
||||
def probe_player() -> Tuple[bool, str]:
|
||||
"""Return (available, hint). hint explains how to install libmpv when absent."""
|
||||
global _probe_result
|
||||
if _probe_result is not None:
|
||||
return _probe_result
|
||||
|
||||
try:
|
||||
import mpv # noqa: F401
|
||||
|
||||
_probe_result = (True, "")
|
||||
except (ImportError, OSError, AttributeError) as e:
|
||||
logger.warning(f"libmpv unavailable, watch features disabled: {e}")
|
||||
if OS_NAME == "Windows":
|
||||
hint = "Place libmpv-2.dll next to the application, or install mpv and add it to PATH."
|
||||
elif OS_NAME == "Darwin":
|
||||
hint = "Install mpv with Homebrew: brew install mpv"
|
||||
else:
|
||||
hint = "Install mpv with your package manager, e.g. sudo pacman -S mpv or sudo apt install libmpv2"
|
||||
_probe_result = (False, hint)
|
||||
return _probe_result
|
||||
@@ -0,0 +1,846 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
import requests
|
||||
from packaging import version
|
||||
|
||||
from .ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path
|
||||
from .ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_constants import (
|
||||
APP_CONFIG_FILE,
|
||||
OS_NAME,
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
USER_HOME_DIR,
|
||||
YTDLP_APP_BIN_PATH,
|
||||
YTDLP_DOWNLOAD_URL,
|
||||
)
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError
|
||||
from importlib.metadata import version as importlib_version
|
||||
|
||||
def get_version(package_name: str) -> str:
|
||||
return importlib_version(package_name)
|
||||
|
||||
PackageNotFoundError = ImportlibPackageNotFoundError
|
||||
except ImportError:
|
||||
# Fallback for older Python versions
|
||||
import pkg_resources
|
||||
|
||||
def get_version(package_name: str) -> str:
|
||||
return pkg_resources.get_distribution(package_name).version
|
||||
|
||||
PackageNotFoundError = pkg_resources.DistributionNotFound
|
||||
|
||||
|
||||
# Cache for version information to avoid delays
|
||||
_version_cache: Dict[str, Dict[str, Any]] = {
|
||||
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||
"deno": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||
}
|
||||
|
||||
# Cache expiry time in seconds (5 minutes)
|
||||
CACHE_EXPIRY: int = 300
|
||||
|
||||
|
||||
def get_file_mtime(filepath: Optional[Union[str, Path]]) -> float:
|
||||
"""Get file modification time safely."""
|
||||
try:
|
||||
if filepath and Path(filepath).exists():
|
||||
return Path(filepath).stat().st_mtime
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def should_refresh_cache(tool_name: str, current_path: Optional[str]) -> bool:
|
||||
"""Determine if cache should be refreshed for a tool."""
|
||||
cache: Dict[str, Any] = _version_cache.get(tool_name, {})
|
||||
current_time: float = time.time()
|
||||
|
||||
# Always refresh if no cached data
|
||||
if not cache.get("version"):
|
||||
return True
|
||||
|
||||
# Refresh if path changed (cache stores str; callers may pass Path -
|
||||
# compare normalized strings or the cache would never hit)
|
||||
cached_path = cache.get("path")
|
||||
normalized_current = str(current_path) if current_path is not None else None
|
||||
if cached_path != normalized_current:
|
||||
return True
|
||||
|
||||
# Refresh if file was modified
|
||||
current_mtime = get_file_mtime(current_path)
|
||||
if current_mtime > cache.get("path_mtime", 0):
|
||||
return True
|
||||
|
||||
# Refresh if cache expired
|
||||
if current_time - cache.get("last_check", 0) > CACHE_EXPIRY:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def update_version_cache(tool_name: str, version_info: str, path: Optional[str], force_save: bool = False) -> None:
|
||||
"""Update the version cache and optionally save to config."""
|
||||
current_time: float = time.time()
|
||||
current_mtime: float = get_file_mtime(path)
|
||||
|
||||
_version_cache[tool_name] = {
|
||||
"version": str(version_info) if version_info else "",
|
||||
"path": str(path) if path else None,
|
||||
"last_check": current_time,
|
||||
"path_mtime": current_mtime,
|
||||
}
|
||||
|
||||
# Save to persistent config
|
||||
if force_save:
|
||||
save_version_cache_to_config()
|
||||
|
||||
|
||||
def load_version_cache_from_config() -> None:
|
||||
"""Load cached version info from config file."""
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
try:
|
||||
cached_versions = ConfigManager.get("cached_versions") or {}
|
||||
|
||||
for tool_name, cache_data in cached_versions.items():
|
||||
if tool_name in _version_cache:
|
||||
_version_cache[tool_name].update(cache_data)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error loading version cache: {e}")
|
||||
|
||||
|
||||
def save_version_cache_to_config() -> None:
|
||||
"""Save version cache to config file."""
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
try:
|
||||
ConfigManager.set("cached_versions", _version_cache.copy())
|
||||
except Exception as e:
|
||||
logger.exception(f"Error saving version cache: {e}")
|
||||
|
||||
|
||||
def get_ytdlp_version_cached() -> str:
|
||||
"""Get yt-dlp version with caching support."""
|
||||
try:
|
||||
current_path = get_yt_dlp_path()
|
||||
|
||||
# Check if we need to refresh cache
|
||||
if not should_refresh_cache("ytdlp", current_path):
|
||||
cached_version = _version_cache["ytdlp"].get("version")
|
||||
if cached_version:
|
||||
return cached_version
|
||||
|
||||
# Get fresh version info
|
||||
version_info = get_ytdlp_version_direct(current_path)
|
||||
|
||||
# Update cache
|
||||
update_version_cache("ytdlp", version_info, current_path)
|
||||
|
||||
return version_info
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting cached yt-dlp version: {e}")
|
||||
return "Error getting version"
|
||||
|
||||
|
||||
def get_ffmpeg_version_cached() -> str:
|
||||
"""Get FFmpeg version with caching support."""
|
||||
try:
|
||||
# Try to find ffmpeg path
|
||||
current_path = "ffmpeg" # Default to system PATH
|
||||
|
||||
# Check if we need to refresh cache
|
||||
if not should_refresh_cache("ffmpeg", current_path):
|
||||
cached_version = _version_cache["ffmpeg"].get("version")
|
||||
if cached_version:
|
||||
return cached_version
|
||||
|
||||
# Get fresh version info
|
||||
version_info = get_ffmpeg_version_direct()
|
||||
|
||||
# Update cache
|
||||
update_version_cache("ffmpeg", version_info, current_path)
|
||||
|
||||
return version_info
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting cached FFmpeg version: {e}")
|
||||
return "Error getting version"
|
||||
|
||||
|
||||
def get_deno_version_cached() -> str:
|
||||
"""Get Deno version with caching support."""
|
||||
try:
|
||||
from .ytsage_deno import get_deno_path
|
||||
|
||||
current_path = get_deno_path()
|
||||
|
||||
# Check if we need to refresh cache
|
||||
if not should_refresh_cache("deno", current_path):
|
||||
cached_version = _version_cache["deno"].get("version")
|
||||
if cached_version:
|
||||
return cached_version
|
||||
|
||||
# Get fresh version info
|
||||
from .ytsage_deno import get_deno_version_direct
|
||||
version_info = get_deno_version_direct(current_path)
|
||||
|
||||
# Update cache
|
||||
update_version_cache("deno", version_info, current_path)
|
||||
|
||||
return version_info
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting cached Deno version: {e}")
|
||||
return "Error getting version"
|
||||
|
||||
|
||||
def refresh_version_cache(force=False) -> bool:
|
||||
"""Manually refresh version cache for all tools."""
|
||||
try:
|
||||
# Refresh yt-dlp
|
||||
current_path = get_yt_dlp_path()
|
||||
version_info = get_ytdlp_version_direct(current_path)
|
||||
update_version_cache("ytdlp", version_info, current_path, force_save=True)
|
||||
|
||||
# Refresh FFmpeg
|
||||
version_info = get_ffmpeg_version_direct()
|
||||
update_version_cache("ffmpeg", version_info, "ffmpeg", force_save=True)
|
||||
|
||||
# Refresh Deno
|
||||
from .ytsage_deno import get_deno_path, get_deno_version_direct
|
||||
deno_path = get_deno_path()
|
||||
version_info = get_deno_version_direct(deno_path)
|
||||
update_version_cache("deno", version_info, deno_path, force_save=True)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error refreshing version cache: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_ytdlp_version() -> str:
|
||||
"""Get the version of yt-dlp (uses cached version for performance)."""
|
||||
return get_ytdlp_version_cached()
|
||||
|
||||
|
||||
def get_ffmpeg_version() -> str:
|
||||
"""Get the version of FFmpeg (uses cached version for performance)."""
|
||||
return get_ffmpeg_version_cached()
|
||||
|
||||
|
||||
def get_deno_version() -> str:
|
||||
"""Get the version of Deno (uses cached version for performance)."""
|
||||
return get_deno_version_cached()
|
||||
|
||||
|
||||
def get_ytdlp_version_direct(yt_dlp_path: Optional[str] = None) -> str:
|
||||
"""Get yt-dlp version directly without caching."""
|
||||
try:
|
||||
if yt_dlp_path is None:
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
|
||||
if not yt_dlp_path or yt_dlp_path == "yt-dlp":
|
||||
return "Not found"
|
||||
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
result = subprocess.run(
|
||||
[yt_dlp_path, "--version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
return "Error getting version"
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting yt-dlp version: {e}")
|
||||
return "Error getting version"
|
||||
|
||||
|
||||
def get_ffmpeg_version_direct() -> str:
|
||||
"""Get FFmpeg version directly without caching."""
|
||||
try:
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Parse the first line to get version info
|
||||
lines = result.stdout.split("\n")
|
||||
if lines:
|
||||
first_line = lines[0]
|
||||
# Extract version from something like "ffmpeg version 4.4.2 Copyright..."
|
||||
if "version" in first_line:
|
||||
parts = first_line.split()
|
||||
for i, part in enumerate(parts):
|
||||
if part == "version" and i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return first_line.strip()
|
||||
return "Unknown version"
|
||||
else:
|
||||
return "Not found"
|
||||
except FileNotFoundError:
|
||||
# If ffmpeg is not in PATH, try the installation directory
|
||||
try:
|
||||
ffmpeg_path = get_ffmpeg_install_path()
|
||||
if OS_NAME == "Windows":
|
||||
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe")
|
||||
else:
|
||||
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg")
|
||||
|
||||
if ffmpeg_exe.exists():
|
||||
result = subprocess.run(
|
||||
[ffmpeg_exe, "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.split("\n")
|
||||
if lines:
|
||||
first_line = lines[0]
|
||||
if "version" in first_line:
|
||||
parts = first_line.split()
|
||||
for i, part in enumerate(parts):
|
||||
if part == "version" and i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return first_line.strip()
|
||||
return "Unknown version"
|
||||
return "Not found"
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting FFmpeg version from install path: {e}")
|
||||
return "Not found"
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting FFmpeg version: {e}")
|
||||
return "Error getting version"
|
||||
|
||||
|
||||
# get_app_data_dir() moved to src\utils\ytsage_constants.py
|
||||
# get_config_file_path() moved to src\utils\ytsage_constants.py
|
||||
# ensure_app_data_dir() moved to src\utils\ytsage_constants.py
|
||||
# load_config() and save_config() removed - use ConfigManager instead
|
||||
|
||||
|
||||
def check_ffmpeg() -> bool:
|
||||
"""Check if FFmpeg is installed and accessible with enhanced error handling."""
|
||||
try:
|
||||
# Use the enhanced FFmpeg check from ytsage_ffmpeg
|
||||
if check_ffmpeg_installed():
|
||||
return True
|
||||
|
||||
# For Windows, try to add the FFmpeg path to environment
|
||||
if OS_NAME == "Windows":
|
||||
ffmpeg_path = get_ffmpeg_install_path()
|
||||
if ffmpeg_path.joinpath("ffmpeg.exe").exists():
|
||||
try:
|
||||
# Add to current session PATH
|
||||
os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error updating PATH: {e}")
|
||||
return False
|
||||
|
||||
# For macOS, check common paths
|
||||
elif OS_NAME == "Darwin":
|
||||
common_paths = [
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"/usr/bin/ffmpeg",
|
||||
]
|
||||
for path in common_paths:
|
||||
if Path(path).exists():
|
||||
try:
|
||||
ffmpeg_dir = Path(path).parent
|
||||
os.environ["PATH"] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error updating PATH: {e}")
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking FFmpeg: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def load_saved_path(main_window_instance: Any) -> None:
|
||||
"""Load saved download path with enhanced error handling."""
|
||||
try:
|
||||
if APP_CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(APP_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
saved_path = config.get("download_path", "")
|
||||
if Path(saved_path).exists() and os.access(saved_path, os.W_OK):
|
||||
main_window_instance.last_path = saved_path
|
||||
return
|
||||
except (json.JSONDecodeError, UnicodeError) as e:
|
||||
logger.exception(f"Error reading config file: {e}")
|
||||
# If config file is corrupted, try to remove it
|
||||
try:
|
||||
APP_CONFIG_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to Downloads folder
|
||||
downloads_path = USER_HOME_DIR / "Downloads"
|
||||
if downloads_path.exists() and os.access(downloads_path, os.W_OK):
|
||||
main_window_instance.last_path = downloads_path
|
||||
else:
|
||||
# Final fallback to temp directory if Downloads is not accessible
|
||||
main_window_instance.last_path = tempfile.gettempdir()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error loading saved settings: {e}")
|
||||
main_window_instance.last_path = tempfile.gettempdir()
|
||||
|
||||
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
def save_path(main_window_instance: Any, path: Union[str, Path]) -> bool:
|
||||
"""Save download path with enhanced error handling."""
|
||||
try:
|
||||
# Verify the path is valid and writable
|
||||
path_str = str(path)
|
||||
if not Path(path_str).exists():
|
||||
try:
|
||||
Path(path_str).mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating directory: {e}")
|
||||
return False
|
||||
|
||||
if not os.access(path_str, os.W_OK):
|
||||
logger.error("Path is not writable")
|
||||
return False
|
||||
|
||||
# Save the config using ConfigManager
|
||||
ConfigManager.set("download_path", path_str)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error saving settings: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_yt_dlp() -> bool:
|
||||
"""Check for yt-dlp updates and update if a newer version is available."""
|
||||
try:
|
||||
# Get the yt-dlp path (may be the bare "yt-dlp" sentinel string when
|
||||
# not installed - normalize to Path so .exists()/.samefile() work)
|
||||
yt_dlp_path: Path = Path(get_yt_dlp_path())
|
||||
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
|
||||
# For binaries downloaded with our app, use direct binary update approach
|
||||
# Check if this is an app-managed binary by comparing paths safely
|
||||
is_app_managed: bool = False
|
||||
try:
|
||||
# Only compare if both files exist
|
||||
if yt_dlp_path.exists() and YTDLP_APP_BIN_PATH.exists():
|
||||
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
|
||||
elif str(yt_dlp_path) == str(YTDLP_APP_BIN_PATH):
|
||||
# If paths are identical as strings, consider it app-managed
|
||||
is_app_managed = True
|
||||
else:
|
||||
# If app binary doesn't exist, this is definitely not app-managed
|
||||
is_app_managed = False
|
||||
except (OSError, IOError) as e:
|
||||
logger.debug(f"Error comparing paths in update_yt_dlp: {e}")
|
||||
is_app_managed = False
|
||||
|
||||
if is_app_managed:
|
||||
# We're using a binary installed by our app, update directly
|
||||
logger.info(f"Updating yt-dlp binary at {yt_dlp_path}")
|
||||
|
||||
# Determine the URL based on OS
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
|
||||
# Download the latest version
|
||||
try:
|
||||
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True, timeout=60)
|
||||
if response.status_code == 200:
|
||||
# Create a temporary file
|
||||
temp_file = f"{yt_dlp_path}.new"
|
||||
|
||||
with open(temp_file, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
# Verify against the official checksums before touching the
|
||||
# trusted binary (same check as the first-install path)
|
||||
from ytsage.core.ytsage_yt_dlp import verify_ytdlp_sha256
|
||||
|
||||
if not verify_ytdlp_sha256(Path(temp_file), YTDLP_DOWNLOAD_URL):
|
||||
logger.error("SHA256 verification failed for yt-dlp update, keeping current binary")
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(temp_file, 0o755)
|
||||
|
||||
# Replace the old file with the new one (os.replace is atomic
|
||||
# and overwrites on Windows too)
|
||||
try:
|
||||
os.replace(temp_file, yt_dlp_path)
|
||||
logger.info("yt-dlp binary successfully updated")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error replacing yt-dlp binary: {e}")
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
return False
|
||||
else:
|
||||
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"Error downloading yt-dlp update: {e}")
|
||||
return False
|
||||
else:
|
||||
# We're using a system-installed yt-dlp, use pip to update
|
||||
logger.info("Using pip to update yt-dlp")
|
||||
|
||||
# Get current version
|
||||
try:
|
||||
current_version = get_version("yt-dlp")
|
||||
logger.info(f"Current yt-dlp version: {current_version}")
|
||||
except PackageNotFoundError:
|
||||
logger.info("yt-dlp not installed via pip, attempting update anyway")
|
||||
current_version = "0.0.0" # Assume very old version to force update
|
||||
|
||||
# Get the latest version from PyPI JSON API
|
||||
try:
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
latest_version = data["info"]["version"]
|
||||
logger.info(f"Latest available yt-dlp version: {latest_version}")
|
||||
|
||||
# Compare versions and update if needed
|
||||
if version.parse(latest_version) > version.parse(current_version):
|
||||
logger.info(f"Updating yt-dlp from {current_version} to {latest_version}...")
|
||||
update_result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"yt-dlp",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
if update_result.returncode == 0:
|
||||
logger.info("yt-dlp successfully updated")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Error updating yt-dlp: {update_result.stderr}")
|
||||
else:
|
||||
logger.info("yt-dlp is already up to date")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"Failed to get latest version info: HTTP {response.status_code}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking for yt-dlp updates: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error during yt-dlp update: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def should_check_for_auto_update() -> bool:
|
||||
"""Check if auto-update should be performed based on user settings."""
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
try:
|
||||
# Check if auto-update is enabled
|
||||
if not ConfigManager.get("auto_update_ytdlp"):
|
||||
return False
|
||||
|
||||
frequency: str = ConfigManager.get("auto_update_frequency") or "daily"
|
||||
last_check: float = ConfigManager.get("last_update_check") or 0
|
||||
current_time: float = time.time()
|
||||
|
||||
# Calculate time since last check
|
||||
time_diff: float = current_time - last_check
|
||||
|
||||
if frequency == "startup":
|
||||
# Always check on startup if we haven't checked in the last hour
|
||||
return time_diff > 3600 # 1 hour
|
||||
elif frequency == "daily":
|
||||
return time_diff > 86400 # 24 hours
|
||||
elif frequency == "weekly":
|
||||
return time_diff > 604800 # 7 days
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking auto-update schedule: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_and_update_ytdlp_auto() -> bool:
|
||||
"""Perform automatic yt-dlp update check and update if needed."""
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
try:
|
||||
logger.info("Performing automatic yt-dlp update check...")
|
||||
|
||||
# Get current version
|
||||
current_version = get_ytdlp_version()
|
||||
if "Error" in current_version:
|
||||
logger.info("Could not determine current yt-dlp version, skipping auto-update")
|
||||
return False
|
||||
|
||||
# Get latest version from PyPI
|
||||
try:
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
response.raise_for_status()
|
||||
latest_version = response.json()["info"]["version"]
|
||||
|
||||
# Clean up version strings
|
||||
current_version = current_version.replace("_", ".")
|
||||
latest_version = latest_version.replace("_", ".")
|
||||
|
||||
logger.info(f"Current yt-dlp version: {current_version}")
|
||||
logger.info(f"Latest yt-dlp version: {latest_version}")
|
||||
|
||||
# Compare versions
|
||||
if version.parse(latest_version) > version.parse(current_version):
|
||||
logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...")
|
||||
|
||||
# Perform the update
|
||||
if update_yt_dlp():
|
||||
logger.info("Auto-update completed successfully!")
|
||||
# Update the last check timestamp
|
||||
ConfigManager.set("last_update_check", time.time())
|
||||
return True
|
||||
else:
|
||||
logger.info("Auto-update failed")
|
||||
return False
|
||||
else:
|
||||
logger.info("yt-dlp is already up to date")
|
||||
# Still update the timestamp even if no update was needed
|
||||
ConfigManager.set("last_update_check", time.time())
|
||||
return True
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.info(f"Network error during auto-update check: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during auto-update check: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"Critical error in auto-update: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def get_auto_update_settings() -> Dict[str, Any]:
|
||||
"""Get current auto-update settings from config."""
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
enabled: Optional[bool] = ConfigManager.get("auto_update_ytdlp")
|
||||
frequency: Optional[str] = ConfigManager.get("auto_update_frequency")
|
||||
last_check: Optional[float] = ConfigManager.get("last_update_check")
|
||||
|
||||
return {
|
||||
"enabled": enabled if enabled is not None else True,
|
||||
"frequency": frequency if frequency is not None else "daily",
|
||||
"last_check": last_check if last_check is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def update_auto_update_settings(enabled: bool, frequency: str) -> bool:
|
||||
"""Update auto-update settings in config."""
|
||||
try:
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
ConfigManager.set("auto_update_ytdlp", enabled)
|
||||
ConfigManager.set("auto_update_frequency", frequency)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error updating auto-update settings: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def parse_yt_dlp_error(error_message: str) -> str:
|
||||
"""
|
||||
Parse yt-dlp error messages and return user-friendly error messages.
|
||||
|
||||
Args:
|
||||
error_message: The raw error message from yt-dlp
|
||||
|
||||
Returns:
|
||||
str: A user-friendly error message with actionable advice
|
||||
"""
|
||||
error_str = error_message.lower()
|
||||
|
||||
# Private video errors
|
||||
if any(keyword in error_str for keyword in ["private video", "login_required", "sign in if you"]):
|
||||
return _("ytdlp_errors.private_video")
|
||||
|
||||
# Age-restricted content
|
||||
if any(keyword in error_str for keyword in ["age restricted", "age-restricted", "confirm your age"]):
|
||||
return _("ytdlp_errors.age_restricted")
|
||||
|
||||
# Geo-blocked content
|
||||
if any(
|
||||
keyword in error_str
|
||||
for keyword in [
|
||||
"not available in your country",
|
||||
"geo-blocked",
|
||||
"video is not available",
|
||||
"not made this video available in your country",
|
||||
]
|
||||
):
|
||||
return _("ytdlp_errors.geo_blocked")
|
||||
|
||||
# Removed/deleted videos
|
||||
if any(keyword in error_str for keyword in ["video unavailable", "this video has been removed", "video does not exist"]):
|
||||
return _("ytdlp_errors.video_unavailable")
|
||||
|
||||
# Live stream errors
|
||||
if any(keyword in error_str for keyword in ["live stream", "livestream", "is live"]):
|
||||
return _("ytdlp_errors.live_stream")
|
||||
|
||||
# Playlist errors
|
||||
if any(keyword in error_str for keyword in ["playlist", "no entries"]):
|
||||
return _("ytdlp_errors.playlist_error")
|
||||
|
||||
# Network/connection errors
|
||||
if any(keyword in error_str for keyword in ["network error", "connection", "timeout", "unable to download"]):
|
||||
return _("ytdlp_errors.network_error")
|
||||
|
||||
# Invalid URL
|
||||
if any(keyword in error_str for keyword in ["invalid url", "unsupported url", "no video found"]):
|
||||
return _("ytdlp_errors.invalid_url")
|
||||
|
||||
# YouTube premium content
|
||||
if any(keyword in error_str for keyword in ["youtube premium", "premium", "members only"]):
|
||||
return _("ytdlp_errors.premium_content")
|
||||
|
||||
# Copyright/DMCA
|
||||
if any(keyword in error_str for keyword in ["copyright", "dmca", "blocked"]):
|
||||
return _("ytdlp_errors.copyright_blocked")
|
||||
|
||||
# Extraction errors (could be temporary)
|
||||
if any(keyword in error_str for keyword in ["unable to extract", "extraction failed"]):
|
||||
return _("ytdlp_errors.extraction_failed")
|
||||
|
||||
# Generic fallback with the original error for debugging
|
||||
return _("ytdlp_errors.generic_error", error=error_message)
|
||||
|
||||
|
||||
def validate_video_url(url: str, generic_mode: bool = False) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate a video URL for supported platforms.
|
||||
|
||||
Args:
|
||||
url: The URL string to validate
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (is_valid, error_message)
|
||||
- is_valid: True if URL is valid, False otherwise
|
||||
- error_message: Empty string if valid, error description if invalid
|
||||
|
||||
Args:
|
||||
url: URL entered by the user.
|
||||
generic_mode: When True, allow any http/https URL with a valid domain.
|
||||
|
||||
Example:
|
||||
>>> is_valid, error = validate_video_url("https://youtube.com/watch?v=xxx")
|
||||
>>> if not is_valid:
|
||||
... print(error)
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Check if URL is empty
|
||||
if not url or not url.strip():
|
||||
return False, _("url_validation.empty_url")
|
||||
|
||||
url = url.strip()
|
||||
|
||||
# Check basic URL structure
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception as e:
|
||||
logger.debug(f"URL parsing error: {e}")
|
||||
return False, _("url_validation.invalid_format")
|
||||
|
||||
# Check if scheme is http or https
|
||||
if parsed.scheme not in ['http', 'https']:
|
||||
return False, _("url_validation.invalid_scheme")
|
||||
|
||||
# Check if netloc (domain) exists
|
||||
if not parsed.netloc:
|
||||
return False, _("url_validation.missing_domain")
|
||||
|
||||
if generic_mode:
|
||||
logger.info(f"Generic mode enabled, allowing URL: {url}")
|
||||
return True, ""
|
||||
|
||||
# YTSage focuses on YouTube and YouTube Music only
|
||||
# Supported YouTube domains
|
||||
youtube_domains = [
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'youtu.be',
|
||||
'm.youtube.com',
|
||||
'music.youtube.com', # YouTube Music
|
||||
'gaming.youtube.com', # YouTube Gaming (redirects to main)
|
||||
]
|
||||
|
||||
# Check if domain is YouTube
|
||||
netloc_lower = parsed.netloc.lower()
|
||||
is_youtube = any(
|
||||
netloc_lower == domain or netloc_lower.endswith('.' + domain)
|
||||
for domain in youtube_domains
|
||||
)
|
||||
|
||||
if not is_youtube:
|
||||
return False, _("url_validation.unsupported_platform", domain=parsed.netloc)
|
||||
|
||||
# Optional: Validate YouTube URL patterns
|
||||
# Common YouTube URL patterns:
|
||||
# - /watch?v=VIDEO_ID
|
||||
# - /playlist?list=PLAYLIST_ID
|
||||
# - /shorts/VIDEO_ID
|
||||
# - youtu.be/VIDEO_ID
|
||||
valid_patterns = [
|
||||
'/watch',
|
||||
'/playlist',
|
||||
'/shorts/',
|
||||
'/live/',
|
||||
'/channel/',
|
||||
'/c/',
|
||||
'/user/',
|
||||
'@', # New handle format
|
||||
]
|
||||
|
||||
# For youtu.be, the path itself is the video ID
|
||||
if 'youtu.be' in netloc_lower:
|
||||
if not parsed.path or parsed.path == '/':
|
||||
return False, _("url_validation.invalid_youtu_be")
|
||||
return True, ""
|
||||
|
||||
# For youtube.com domains, check for valid patterns
|
||||
if any(pattern in url.lower() for pattern in valid_patterns):
|
||||
return True, ""
|
||||
|
||||
# If it's a YouTube domain but doesn't match known patterns, still allow it
|
||||
# (yt-dlp might support formats we don't know about)
|
||||
logger.info(f"YouTube URL doesn't match known patterns but allowing: {url}")
|
||||
return True, ""
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
from ..utils.ytsage_constants import (
|
||||
APP_BIN_DIR,
|
||||
ICON_PATH,
|
||||
OS_FULL_NAME,
|
||||
OS_NAME,
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
YTDLP_APP_BIN_PATH,
|
||||
YTDLP_DOWNLOAD_URL,
|
||||
YTDLP_SHA256_URL,
|
||||
)
|
||||
from .ytsage_ffmpeg import get_file_sha256
|
||||
from ..utils.ytsage_localization import _
|
||||
|
||||
# YTDLP_URLS moved to src\utils\ytsage_constants.py
|
||||
# get_ytdlp_install_dir() moved to src\utils\ytsage_constants.py
|
||||
# get_ytdlp_executable_path() moved to src\utils\ytsage_constants.py
|
||||
# get_os_type() moved to src\utils\ytsage_constants.py
|
||||
# ensure_install_dir_exists() moved to src\utils\ytsage_constants.py
|
||||
|
||||
|
||||
def verify_ytdlp_sha256(file_path: Path, download_url: str) -> bool:
|
||||
"""
|
||||
Verify yt-dlp file SHA256 hash against official checksums.
|
||||
|
||||
Args:
|
||||
file_path: Path to the downloaded yt-dlp file
|
||||
download_url: The URL used to download the file (to determine the filename)
|
||||
|
||||
Returns:
|
||||
bool: True if verification successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Download the SHA2-256SUMS file
|
||||
logger.info(f"Downloading SHA256 checksums from: {YTDLP_SHA256_URL}")
|
||||
response = requests.get(YTDLP_SHA256_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
checksum_content = response.text
|
||||
|
||||
# Extract filename from download URL (e.g., yt-dlp.exe, yt-dlp_macos, yt-dlp)
|
||||
filename = download_url.split("/")[-1]
|
||||
logger.info(f"Looking for checksum for file: {filename}")
|
||||
|
||||
# Parse the checksum file to find the matching hash
|
||||
expected_hash = None
|
||||
for line in checksum_content.strip().split("\n"):
|
||||
if filename in line:
|
||||
# Format: "hash filename"
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2 and parts[1] == filename:
|
||||
expected_hash = parts[0]
|
||||
break
|
||||
|
||||
if not expected_hash:
|
||||
logger.error(f"Could not find SHA256 hash for {filename} in checksums file")
|
||||
return False
|
||||
|
||||
# Calculate actual hash of downloaded file
|
||||
logger.info("Calculating SHA256 hash of downloaded file...")
|
||||
actual_hash = get_file_sha256(file_path)
|
||||
|
||||
# Compare hashes
|
||||
if actual_hash.lower() == expected_hash.lower():
|
||||
logger.info("✓ SHA256 verification successful!")
|
||||
logger.info(f" Expected: {expected_hash}")
|
||||
logger.info(f" Actual: {actual_hash}")
|
||||
return True
|
||||
else:
|
||||
logger.error("✗ SHA256 verification failed!")
|
||||
logger.error(f" Expected: {expected_hash}")
|
||||
logger.error(f" Actual: {actual_hash}")
|
||||
return False
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to download SHA256 checksums: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during SHA256 verification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class DownloadYtdlpThread(QThread):
|
||||
progress_signal = Signal(int)
|
||||
finished_signal = Signal(bool, str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
exe_path = YTDLP_APP_BIN_PATH
|
||||
# Download to a temp name and only move to the trusted path after
|
||||
# the hash checks out, so a crash mid-download can never leave an
|
||||
# unverified binary where the app will execute it.
|
||||
part_path = exe_path.with_name(exe_path.name + ".part")
|
||||
|
||||
# Download with progress reporting
|
||||
logger.info(f"Downloading yt-dlp from: {YTDLP_DOWNLOAD_URL}")
|
||||
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
block_size = 1024 # 1 Kibibyte
|
||||
|
||||
if total_size == 0:
|
||||
self.progress_signal.emit(100)
|
||||
|
||||
with open(part_path, "wb") as f:
|
||||
downloaded = 0
|
||||
for data in response.iter_content(block_size):
|
||||
f.write(data)
|
||||
downloaded += len(data)
|
||||
if total_size > 0:
|
||||
progress = int(downloaded / total_size * 100)
|
||||
self.progress_signal.emit(progress)
|
||||
|
||||
logger.info("Download complete, verifying SHA256 hash...")
|
||||
|
||||
# Verify SHA256 hash
|
||||
if not verify_ytdlp_sha256(part_path, YTDLP_DOWNLOAD_URL):
|
||||
# Hash verification failed - delete the downloaded file
|
||||
logger.error("SHA256 verification failed! Removing downloaded file.")
|
||||
part_path.unlink(missing_ok=True)
|
||||
self.finished_signal.emit(
|
||||
False,
|
||||
"SHA256 verification failed. The downloaded file may be corrupted or tampered with."
|
||||
)
|
||||
return
|
||||
|
||||
# Make executable on macOS and Linux
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(part_path, 0o755)
|
||||
|
||||
os.replace(part_path, exe_path)
|
||||
|
||||
logger.info("yt-dlp downloaded and verified successfully!")
|
||||
self.finished_signal.emit(True, str(exe_path))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error downloading yt-dlp: {e}")
|
||||
self.finished_signal.emit(False, str(e))
|
||||
|
||||
|
||||
class YtdlpSetupDialog(QDialog):
|
||||
setup_complete = Signal(str) # Signal emitting the path to yt-dlp
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("ytdlp_setup.required_title"))
|
||||
self.setMinimumWidth(520)
|
||||
self.setMinimumHeight(350)
|
||||
self.resize(520, 380)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent and parent.windowIcon():
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
else:
|
||||
# icon_path logic moved to src\utils\ytsage_constants.py
|
||||
icon_path = ICON_PATH
|
||||
if Path.exists(icon_path):
|
||||
self.setWindowIcon(QIcon(str(icon_path)))
|
||||
|
||||
self.init_ui()
|
||||
|
||||
# Apply dark theme styling to match app
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
line-height: 1.4;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
color: #999999;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #1d1e22;
|
||||
height: 25px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stop: 0 #e60000, stop: 0.5 #ff3333, stop: 1 #c90000);
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
}
|
||||
QRadioButton {
|
||||
color: #ffffff;
|
||||
spacing: 10px;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
}
|
||||
QRadioButton::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(25, 25, 25, 25)
|
||||
|
||||
# Header title
|
||||
title_label = QLabel(_("ytdlp_setup.required_title"))
|
||||
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Information label with improved styling
|
||||
# os_name logic moved to src\utils\ytsage_constants.py
|
||||
|
||||
info_label = QLabel(
|
||||
_("ytdlp_setup.description", os_name=OS_FULL_NAME)
|
||||
)
|
||||
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 5px; line-height: 1.4;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Radio buttons with minimal spacing
|
||||
option_widget = QWidget()
|
||||
option_layout = QVBoxLayout(option_widget)
|
||||
option_layout.setSpacing(8)
|
||||
option_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.auto_radio = QRadioButton(_("ytdlp_setup.option_auto"))
|
||||
self.auto_radio.setChecked(True)
|
||||
self.manual_radio = QRadioButton(_("ytdlp_setup.option_manual"))
|
||||
|
||||
option_layout.addWidget(self.auto_radio)
|
||||
option_layout.addWidget(self.manual_radio)
|
||||
layout.addWidget(option_widget)
|
||||
|
||||
# Progress bar with proper sizing
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
self.progress_bar.setFixedHeight(20) # Fixed height for consistency
|
||||
self.progress_bar.setStyleSheet(
|
||||
"""
|
||||
QProgressBar {
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 8px;
|
||||
background-color: #1d1e22;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
height: 20px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #c90000;
|
||||
border-radius: 6px;
|
||||
margin: 1px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Status label with better spacing
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setStyleSheet("font-size: 12px; color: #cccccc; padding: 8px 0;")
|
||||
self.status_label.setWordWrap(True)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Add stretch to push buttons to bottom
|
||||
layout.addStretch()
|
||||
|
||||
# Button layout with improved spacing
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(15)
|
||||
button_layout.setContentsMargins(0, 10, 0, 0) # Add top margin for buttons
|
||||
|
||||
self.setup_button = QPushButton(_("ytdlp_setup.setup_button"))
|
||||
self.setup_button.clicked.connect(self.setup_ytdlp)
|
||||
|
||||
self.cancel_button = QPushButton(_("buttons.cancel"))
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addWidget(self.setup_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def setup_ytdlp(self) -> None:
|
||||
if self.auto_radio.isChecked():
|
||||
self.download_ytdlp()
|
||||
else:
|
||||
self.select_ytdlp_path()
|
||||
|
||||
def download_ytdlp(self) -> None:
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
self.status_label.setText(_("ytdlp_setup.downloading"))
|
||||
self.setup_button.setEnabled(False)
|
||||
self.cancel_button.setEnabled(False)
|
||||
|
||||
self.download_thread = DownloadYtdlpThread()
|
||||
self.download_thread.progress_signal.connect(self.update_progress)
|
||||
self.download_thread.finished_signal.connect(self.download_finished)
|
||||
self.download_thread.start()
|
||||
|
||||
def update_progress(self, value) -> None:
|
||||
self.progress_bar.setValue(value)
|
||||
|
||||
def download_finished(self, success, result) -> None:
|
||||
self.setup_button.setEnabled(True)
|
||||
self.cancel_button.setEnabled(True)
|
||||
|
||||
if success:
|
||||
self.status_label.setText(_("ytdlp_setup.success"))
|
||||
self.setup_complete.emit(result)
|
||||
self.accept()
|
||||
else:
|
||||
self.status_label.setText(_("ytdlp_setup.error", error=result))
|
||||
error_dialog = QMessageBox(self)
|
||||
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
error_dialog.setWindowTitle(_("ytdlp_setup.download_failed_title"))
|
||||
error_dialog.setText(_("ytdlp_setup.download_failed_message", error=result))
|
||||
# Set the window icon to match the main dialog
|
||||
error_dialog.setWindowIcon(self.windowIcon())
|
||||
error_dialog.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
error_dialog.exec()
|
||||
|
||||
def select_ytdlp_path(self) -> None:
|
||||
if OS_NAME == "Windows":
|
||||
file_filter = _("ytdlp_setup.file_filter_windows")
|
||||
else:
|
||||
file_filter = _("ytdlp_setup.file_filter_all")
|
||||
|
||||
# Apply style to QFileDialog
|
||||
file_dialog = QFileDialog(self)
|
||||
file_dialog.setStyleSheet(
|
||||
"""
|
||||
QFileDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel, QCheckBox, QListView, QTreeView, QComboBox, QLineEdit {
|
||||
color: #ffffff;
|
||||
background-color: #1b2021;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
file_path, _selected_filter = file_dialog.getOpenFileName(
|
||||
self, _("ytdlp_setup.select_executable_title"), "", file_filter
|
||||
)
|
||||
|
||||
if file_path:
|
||||
logger.debug(f"User selected file: {file_path}")
|
||||
# Verify the selected file
|
||||
try:
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
# Try to run yt-dlp --version
|
||||
logger.debug(f"Verifying file with --version command")
|
||||
result = subprocess.run(
|
||||
[file_path, "--version"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
logger.debug(f"Version check result: {result.returncode}, Output: {result.stdout.strip()}")
|
||||
|
||||
if result.returncode == 0:
|
||||
# File is valid, copy it to our app's bin directory
|
||||
try:
|
||||
# Ensure the bin directory exists
|
||||
logger.debug(f"Install directory: {APP_BIN_DIR}")
|
||||
|
||||
# Determine the target filename based on OS
|
||||
target_path = YTDLP_APP_BIN_PATH
|
||||
logger.debug(f"Target path: {target_path}")
|
||||
|
||||
# Copy the file
|
||||
shutil.copy2(file_path, target_path)
|
||||
logger.debug(f"File copied successfully")
|
||||
|
||||
# Set executable permissions on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(target_path, 0o755)
|
||||
logger.debug(f"Permissions set on Unix system")
|
||||
|
||||
# Return the path of the copied file
|
||||
self.status_label.setText(_("ytdlp_setup.copied_to", path=target_path))
|
||||
logger.debug(f"Emitting setup_complete signal with path: {target_path}")
|
||||
self.setup_complete.emit(target_path)
|
||||
self.accept()
|
||||
except Exception as copy_error:
|
||||
logger.debug(f"Error copying file: {copy_error}", exc_info=True)
|
||||
error_dialog = QMessageBox(self)
|
||||
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
error_dialog.setWindowTitle(_("ytdlp_setup.setup_error_title"))
|
||||
error_dialog.setText(_("ytdlp_setup.copy_error", error=copy_error))
|
||||
error_dialog.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
error_dialog.exec()
|
||||
else:
|
||||
logger.debug(f"File verification failed with return code: {result.returncode}")
|
||||
error_dialog = QMessageBox(self)
|
||||
error_dialog.setIcon(QMessageBox.Icon.Warning)
|
||||
error_dialog.setWindowTitle(_("ytdlp_setup.invalid_executable_title"))
|
||||
error_dialog.setText(_("ytdlp_setup.invalid_executable_message"))
|
||||
error_dialog.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
error_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.debug(f"Exception during verification: {e}", exc_info=True)
|
||||
error_dialog = QMessageBox(self)
|
||||
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
error_dialog.setWindowTitle(_("main_ui.error_title"))
|
||||
error_dialog.setText(_("ytdlp_setup.verify_error", error=e))
|
||||
error_dialog.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
error_dialog.exec()
|
||||
|
||||
|
||||
def check_ytdlp_binary() -> Optional[Path]:
|
||||
"""
|
||||
Check if yt-dlp binary exists in the app's bin directory ONLY.
|
||||
We now ignore system PATH and only use our managed binary.
|
||||
Returns:
|
||||
Path or None: Path to yt-dlp binary if found in app bin, None otherwise
|
||||
"""
|
||||
exe_path = YTDLP_APP_BIN_PATH
|
||||
if exe_path.exists():
|
||||
# Make sure it's executable on Unix systems
|
||||
if OS_NAME != "Windows" and not os.access(exe_path, os.X_OK):
|
||||
try:
|
||||
os.chmod(exe_path, 0o755)
|
||||
logger.info(f"Fixed permissions on yt-dlp at {exe_path}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Could not set executable permissions on {exe_path}: {e}")
|
||||
logger.info(f"Found yt-dlp in app bin directory: {exe_path}")
|
||||
return exe_path
|
||||
|
||||
# Binary not found in app directory - return None to trigger setup
|
||||
logger.warning(f"yt-dlp binary not found in app bin directory: {exe_path}")
|
||||
return None
|
||||
|
||||
|
||||
def check_ytdlp_installed() -> bool:
|
||||
"""
|
||||
Check if yt-dlp is installed and accessible.
|
||||
Returns:
|
||||
bool: True if yt-dlp is found and working, False otherwise
|
||||
"""
|
||||
try:
|
||||
ytdlp_path = check_ytdlp_binary()
|
||||
if ytdlp_path:
|
||||
# Try to run yt-dlp --version to verify it's working
|
||||
try:
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
result = subprocess.run(
|
||||
[ytdlp_path, "--version"], capture_output=True, text=True, timeout=5, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_yt_dlp_path() -> Path:
|
||||
"""
|
||||
Get the yt-dlp path. Prefers the app-managed, SHA256-verified binary;
|
||||
a system-installed yt-dlp is only used when the user explicitly opts in
|
||||
via the advanced.allow_system_ytdlp config key (resolved to an absolute
|
||||
path so Windows' implicit CWD lookup can never pick up a planted binary).
|
||||
Returns:
|
||||
Path to yt-dlp binary, or the bare string "yt-dlp" sentinel meaning
|
||||
"not installed" (triggers the setup dialog).
|
||||
"""
|
||||
ytdlp_path = check_ytdlp_binary()
|
||||
if ytdlp_path:
|
||||
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
||||
return ytdlp_path
|
||||
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
if ConfigManager.get("advanced.allow_system_ytdlp"):
|
||||
system_ytdlp = shutil.which("yt-dlp")
|
||||
if system_ytdlp:
|
||||
resolved = Path(system_ytdlp).resolve()
|
||||
logger.info(f"Using system yt-dlp (advanced.allow_system_ytdlp): {resolved}")
|
||||
return resolved
|
||||
|
||||
# Not installed - return the sentinel that triggers the setup dialog
|
||||
logger.info("yt-dlp not found in app directory, returning setup sentinel")
|
||||
return "yt-dlp" # type: ignore[return-value]
|
||||
|
||||
|
||||
def setup_ytdlp(parent_widget=None):
|
||||
"""
|
||||
Show the yt-dlp setup dialog and handle the result.
|
||||
Returns:
|
||||
str: Path to yt-dlp binary
|
||||
"""
|
||||
logger.debug("Starting yt-dlp setup dialog")
|
||||
dialog = YtdlpSetupDialog(parent_widget)
|
||||
|
||||
# Store the setup result from the signal
|
||||
setup_result = {"path": None}
|
||||
|
||||
def on_setup_complete(path) -> None:
|
||||
logger.debug(f"Received setup_complete signal with path: {path}")
|
||||
setup_result["path"] = path
|
||||
|
||||
# Connect to the setup_complete signal
|
||||
dialog.setup_complete.connect(on_setup_complete)
|
||||
|
||||
# Show the dialog
|
||||
result = dialog.exec()
|
||||
logger.debug(f"Dialog result: {result} (Accepted={QDialog.DialogCode.Accepted})")
|
||||
|
||||
if result == QDialog.DialogCode.Accepted:
|
||||
# First check if we received a path from the signal
|
||||
if setup_result["path"]:
|
||||
path_obj = Path(setup_result["path"]) if isinstance(setup_result["path"], str) else setup_result["path"]
|
||||
if path_obj.exists():
|
||||
logger.debug(f"Using path from signal: {setup_result['path']}")
|
||||
return str(setup_result["path"])
|
||||
|
||||
# Get the expected path for verification as fallback
|
||||
expected_path = YTDLP_APP_BIN_PATH
|
||||
logger.debug(f"Expected yt-dlp path: {expected_path}")
|
||||
|
||||
# Verify the path exists after dialog is accepted
|
||||
if expected_path.exists():
|
||||
logger.debug(f"yt-dlp successfully found at expected path: {expected_path}")
|
||||
return str(expected_path)
|
||||
else:
|
||||
logger.debug(f"Expected path does not exist, trying alternate detection")
|
||||
# Try to use the get_yt_dlp_path function to find yt-dlp elsewhere
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
logger.debug(f"Alternate detection result: {yt_dlp_path}")
|
||||
if yt_dlp_path != "yt-dlp":
|
||||
path_obj = Path(yt_dlp_path) if isinstance(yt_dlp_path, str) else yt_dlp_path
|
||||
if path_obj.exists():
|
||||
logger.debug(f"yt-dlp found at alternate location: {yt_dlp_path}")
|
||||
return str(yt_dlp_path)
|
||||
|
||||
# Something went wrong, show an error message
|
||||
logger.debug(f"Setup failed, showing error dialog")
|
||||
if parent_widget:
|
||||
error_dialog = QMessageBox(parent_widget)
|
||||
error_dialog.setIcon(QMessageBox.Icon.Warning)
|
||||
error_dialog.setWindowTitle(_("ytdlp_setup.setup_failed_title"))
|
||||
error_dialog.setText(_("ytdlp_setup.setup_failed_message"))
|
||||
# Set the window icon to match the parent
|
||||
error_dialog.setWindowIcon(parent_widget.windowIcon())
|
||||
error_dialog.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
error_dialog.exec()
|
||||
logger.warning(f"yt-dlp setup failed, path does not exist: {expected_path}")
|
||||
else:
|
||||
logger.debug("User cancelled the setup dialog")
|
||||
|
||||
# User cancelled or setup failed, return the fallback command
|
||||
logger.debug("Returning fallback command 'yt-dlp'")
|
||||
return "yt-dlp"
|
||||
|
||||
|
||||
def check_ytdlp_deno_integration() -> bool:
|
||||
"""
|
||||
Check if yt-dlp is integrated with Deno by running 'yt-dlp --verbose'.
|
||||
Returns:
|
||||
bool: True if Deno is detected in JS runtimes, False otherwise
|
||||
"""
|
||||
try:
|
||||
ytdlp_path = get_yt_dlp_path()
|
||||
if not ytdlp_path or ytdlp_path == "yt-dlp":
|
||||
return False
|
||||
|
||||
# Run yt-dlp --verbose to check JS runtimes
|
||||
# We use a dummy URL or just --verbose with no URL (which might error but should print debug info)
|
||||
# However, yt-dlp might not print debug info if no URL is provided and it errors out immediately with "usage".
|
||||
# But per user example: "yt-dlp.exe: error: You must provide at least one URL." comes AFTER debug info.
|
||||
|
||||
result = subprocess.run(
|
||||
[str(ytdlp_path), "--verbose"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
|
||||
# Check stderr for "[debug] JS runtimes: deno"
|
||||
output = result.stderr
|
||||
if "[debug] JS runtimes:" in output and "deno" in output:
|
||||
# Find the line
|
||||
for line in output.splitlines():
|
||||
if "[debug] JS runtimes:" in line and "deno" in line:
|
||||
logger.info(f"Deno integration detected: {line.strip()}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check yt-dlp Deno integration: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
GUI modules for YTSage.
|
||||
|
||||
This package contains all user interface components and related functionality.
|
||||
"""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
The YouTube account indicator
|
||||
=============================
|
||||
|
||||
SageTube can use browser cookies to reach a signed-in YouTube: the account
|
||||
feed, age-restricted and members-only videos, and premium formats. All of that
|
||||
worked -- but the only way to switch it on was Downloads tab -> Custom Options
|
||||
-> the "Login with Cookies" tab, and Downloads is one tab among five. The three
|
||||
features that need it (the Feed's account mode, Search, Browse) had no route to
|
||||
it at all, and `show_cookie_login_dialog()` existed with no caller anywhere in
|
||||
the codebase.
|
||||
|
||||
So this is a button in the tab bar's corner, visible from every tab, that says
|
||||
whether cookies are active and opens the existing dialog. Nothing about the
|
||||
cookie handling itself changes; it just became findable.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QPushButton, QWidget
|
||||
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_localization import _
|
||||
|
||||
|
||||
class AccountStatusButton(QPushButton):
|
||||
"""Shows the cookie state and opens the login dialog when pressed."""
|
||||
|
||||
loginRequested = Signal()
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("accountStatusButton")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
# Not a call to action -- it sits in the chrome, so it should not
|
||||
# compete with the accent-coloured buttons in the pages.
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QPushButton#accountStatusButton {{
|
||||
background-color: transparent;
|
||||
color: {theme.TEXT_MUTED};
|
||||
border: 1px solid {theme.BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
margin: 4px 8px 4px 4px;
|
||||
font-weight: normal;
|
||||
}}
|
||||
QPushButton#accountStatusButton:hover {{
|
||||
color: {theme.TEXT};
|
||||
background-color: {theme.SURFACE_HOVER};
|
||||
border-color: {theme.BORDER_STRONG};
|
||||
}}
|
||||
QPushButton#accountStatusButton:pressed {{
|
||||
background-color: {theme.BORDER};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.clicked.connect(self.loginRequested)
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Re-read the cookie config and restate what it says."""
|
||||
active = bool(ConfigManager.get("cookie_active"))
|
||||
if not active:
|
||||
self.setIcon(icons.icon("user", theme.TEXT_MUTED))
|
||||
self.setText(_("account.signed_out"))
|
||||
self.setToolTip(_("account.signed_out_tooltip"))
|
||||
return
|
||||
|
||||
if (ConfigManager.get("cookie_source") or "browser") == "file":
|
||||
source = _("account.source_file")
|
||||
else:
|
||||
browser = ConfigManager.get("cookie_browser") or "?"
|
||||
profile = ConfigManager.get("cookie_browser_profile")
|
||||
source = f"{browser}:{profile}" if profile else str(browser)
|
||||
|
||||
self.setIcon(icons.icon("user-check", theme.TEXT))
|
||||
self.setText(_("account.signed_in", source=source))
|
||||
self.setToolTip(_("account.signed_in_tooltip", source=source))
|
||||
@@ -0,0 +1,470 @@
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from ..core.ytsage_client import run_ytdlp_capture
|
||||
from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error
|
||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class AnalysisThread(QThread):
|
||||
"""
|
||||
Thread-safe QThread for URL analysis.
|
||||
All results are passed back via signals to ensure thread safety.
|
||||
"""
|
||||
# Signals for status updates
|
||||
status_update = Signal(str)
|
||||
progress_update = Signal(int) # Signal for progress bar updates
|
||||
|
||||
# Signals for playlist UI
|
||||
playlist_info_visible = Signal(bool)
|
||||
playlist_info_text = Signal(str)
|
||||
playlist_select_btn_visible = Signal(bool)
|
||||
playlist_select_btn_text = Signal(str)
|
||||
|
||||
# Signal for analysis results - passes all data at once
|
||||
analysis_complete = Signal(dict)
|
||||
|
||||
# Signal for errors
|
||||
analysis_error = Signal(str)
|
||||
|
||||
# Signal when thread finishes (success or failure)
|
||||
analysis_finished = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
cookie_file_path: Optional[str] = None,
|
||||
browser_cookies_option: Optional[str] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
geo_proxy_url: Optional[str] = None,
|
||||
parent=None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.url = url
|
||||
self.cookie_file_path = cookie_file_path
|
||||
self.browser_cookies_option = browser_cookies_option
|
||||
self.proxy_url = proxy_url
|
||||
self.geo_proxy_url = geo_proxy_url
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Request cancellation of the analysis."""
|
||||
self._cancelled = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Main thread execution - performs URL analysis."""
|
||||
try:
|
||||
self.status_update.emit(_("main_ui.analyzing_extracting_basic"))
|
||||
self.progress_update.emit(15)
|
||||
|
||||
url = self.url
|
||||
# Clean up the URL to handle both playlist and video URLs
|
||||
if "list=" in url and "watch?v=" in url:
|
||||
playlist_id = url.split("list=")[1].split("&")[0]
|
||||
url = f"https://www.youtube.com/playlist?list={playlist_id}"
|
||||
|
||||
self._analyze_url_with_subprocess(url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in analysis: {e}")
|
||||
self.analysis_error.emit(_("errors.generic_error", error=str(e)))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
finally:
|
||||
self.analysis_finished.emit()
|
||||
|
||||
def _add_auth_options(self, cmd: List[str]) -> None:
|
||||
"""Add authentication and proxy options to command."""
|
||||
if self.cookie_file_path:
|
||||
cmd.extend(["--cookies", str(self.cookie_file_path)])
|
||||
elif self.browser_cookies_option:
|
||||
cmd.extend(["--cookies-from-browser", self.browser_cookies_option])
|
||||
|
||||
if self.proxy_url:
|
||||
cmd.extend(["--proxy", self.proxy_url])
|
||||
|
||||
if self.geo_proxy_url:
|
||||
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
|
||||
|
||||
@staticmethod
|
||||
def _run_ytdlp(cmd: list, timeout: int) -> subprocess.CompletedProcess:
|
||||
"""Run yt-dlp via the shared client runner (process-group-safe)."""
|
||||
return run_ytdlp_capture(cmd, timeout=timeout)
|
||||
|
||||
def _analyze_url_with_subprocess(self, url: str) -> None:
|
||||
"""Analyze URL using yt-dlp executable."""
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
if not yt_dlp_path or str(yt_dlp_path) == "yt-dlp":
|
||||
# Sentinel: no managed binary and no opted-in system binary.
|
||||
# Never exec a bare command name from PATH.
|
||||
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
|
||||
self.analysis_error.emit(_("errors.ytdlp_not_found"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_extracting_ytdlp"))
|
||||
self.progress_update.emit(30) # This will trigger fake progress in UI
|
||||
|
||||
# Build command for basic info extraction
|
||||
cmd = [yt_dlp_path, "--dump-single-json", "--flat-playlist", "--no-warnings", url]
|
||||
self._add_auth_options(cmd)
|
||||
|
||||
logger.debug(f"Executing yt-dlp command: {cmd}")
|
||||
|
||||
try:
|
||||
result = self._run_ytdlp(cmd, timeout=300)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
stderr_tail = (e.stderr or "")[-500:] if isinstance(e.stderr, str) else ""
|
||||
logger.error(f"Analysis timed out. Partial stderr: {stderr_tail}")
|
||||
self.analysis_error.emit(_("errors.timeout"))
|
||||
return
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
if result.returncode != 0:
|
||||
if "Private video" in result.stderr or "Sign in" in result.stderr:
|
||||
logger.error(f"yt-dlp failed (private video): {result.stderr}")
|
||||
self.analysis_error.emit(_("errors.private_video"))
|
||||
else:
|
||||
logger.error(f"yt-dlp failed: {result.stderr}")
|
||||
self.analysis_error.emit(_("errors.ytdlp_failed", error=result.stderr))
|
||||
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
||||
|
||||
if not json_lines:
|
||||
logger.error("No data returned from yt-dlp")
|
||||
self.analysis_error.emit(_("errors.no_data_returned"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
try:
|
||||
first_info = json.loads(json_lines[0])
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse yt-dlp output: {e}")
|
||||
self.analysis_error.emit(_("errors.parse_failed", error=str(e)))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_processing_data"))
|
||||
self.progress_update.emit(60)
|
||||
|
||||
# Prepare result data
|
||||
result_data: Dict[str, Any] = {
|
||||
"is_playlist": False,
|
||||
"playlist_info": None,
|
||||
"playlist_entries": [],
|
||||
"video_info": None,
|
||||
"all_formats": [],
|
||||
"available_subtitles": {},
|
||||
"available_automatic_subtitles": {},
|
||||
"thumbnail_url": None,
|
||||
}
|
||||
|
||||
if first_info.get("_type") == "playlist":
|
||||
result_data["is_playlist"] = True
|
||||
result_data["playlist_info"] = first_info
|
||||
# Private/deleted videos can appear as None entries in flat playlists
|
||||
playlist_entries = [e for e in first_info.get("entries", []) if e]
|
||||
result_data["playlist_entries"] = playlist_entries
|
||||
|
||||
if not playlist_entries:
|
||||
logger.error("Playlist contains no valid videos.")
|
||||
self.analysis_error.emit(_("errors.playlist_no_videos"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
# Fetch full info for the first video to get formats
|
||||
self.status_update.emit(_("main_ui.analyzing_fetching_first_video"))
|
||||
self.progress_update.emit(70)
|
||||
first_video_entry = playlist_entries[0]
|
||||
first_video_url = first_video_entry.get("url")
|
||||
|
||||
cmd_single = [yt_dlp_path, "--dump-single-json", "--no-warnings", first_video_url]
|
||||
self._add_auth_options(cmd_single)
|
||||
|
||||
try:
|
||||
result_single = self._run_ytdlp(cmd_single, timeout=60)
|
||||
if result_single.returncode == 0:
|
||||
result_data["video_info"] = json.loads(result_single.stdout)
|
||||
else:
|
||||
result_data["video_info"] = first_video_entry
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||
result_data["video_info"] = first_video_entry
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Update playlist UI via signals
|
||||
playlist_text = _("playlist.display_format",
|
||||
title=first_info.get('title', _('playlist.unknown')),
|
||||
count=len(playlist_entries))
|
||||
self.playlist_info_text.emit(playlist_text)
|
||||
self.playlist_info_visible.emit(True)
|
||||
self.playlist_select_btn_text.emit(_("main_ui.select_videos_all"))
|
||||
self.playlist_select_btn_visible.emit(True)
|
||||
|
||||
else:
|
||||
# Handle single video
|
||||
result_data["is_playlist"] = False
|
||||
result_data["video_info"] = first_info
|
||||
result_data["playlist_entries"] = []
|
||||
result_data["playlist_info"] = None
|
||||
|
||||
# Hide playlist UI
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
|
||||
# Verify we have format information
|
||||
video_info = result_data["video_info"]
|
||||
if not video_info or "formats" not in video_info:
|
||||
logger.error("No format information available")
|
||||
self.analysis_error.emit(_("errors.no_format_info"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_processing_formats_ytdlp"))
|
||||
self.progress_update.emit(85)
|
||||
result_data["all_formats"] = video_info.get("formats", [])
|
||||
|
||||
# Get thumbnail URL
|
||||
self.status_update.emit(_("main_ui.analyzing_loading_thumbnail_ytdlp"))
|
||||
self.progress_update.emit(90)
|
||||
playlist_info = result_data.get("playlist_info") or {}
|
||||
thumbnail_url = playlist_info.get("thumbnail") or video_info.get("thumbnail")
|
||||
result_data["thumbnail_url"] = thumbnail_url
|
||||
|
||||
# Handle subtitles
|
||||
self.status_update.emit(_("main_ui.analyzing_processing_subtitles_ytdlp"))
|
||||
self.progress_update.emit(92)
|
||||
result_data["available_subtitles"] = video_info.get("subtitles", {})
|
||||
result_data["available_automatic_subtitles"] = video_info.get("automatic_captions", {})
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_updating_table"))
|
||||
self.progress_update.emit(95)
|
||||
|
||||
# Emit all results at once
|
||||
self.analysis_complete.emit(result_data)
|
||||
|
||||
|
||||
class AnalysisMixin:
|
||||
"""Mixin class providing URL analysis functionality for YTSageApp."""
|
||||
|
||||
# Track the current analysis thread
|
||||
_analysis_thread: Optional[AnalysisThread] = None
|
||||
_analysis_timer: Optional[QTimer] = None
|
||||
_fake_progress: int = 0
|
||||
|
||||
def analyze_url(self) -> None:
|
||||
"""Start URL analysis in a background thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
if self.is_updating_ytdlp:
|
||||
QMessageBox.warning(self, _("update.update_in_progress_title"), _("update.update_in_progress_message"))
|
||||
return
|
||||
|
||||
url = self.url_input.text().strip()
|
||||
if not url:
|
||||
self.signals.update_status.emit(_("main_ui.invalid_url_or_enter"))
|
||||
if hasattr(self, "animate_widget_shake"):
|
||||
self.animate_widget_shake(self.url_input)
|
||||
return
|
||||
|
||||
# Validate URL before processing
|
||||
is_valid, error_message = validate_video_url(url, generic_mode=self.generic_mode_enabled)
|
||||
if not is_valid:
|
||||
QMessageBox.warning(self, _("main_ui.error_title"), error_message)
|
||||
if hasattr(self, "animate_widget_shake"):
|
||||
self.animate_widget_shake(self.url_input)
|
||||
return
|
||||
|
||||
# Cancel any existing analysis thread
|
||||
if self._analysis_thread is not None and self._analysis_thread.isRunning():
|
||||
self._analysis_thread.cancel()
|
||||
self._analysis_thread.wait(1000) # Wait up to 1 second
|
||||
|
||||
# Reset analysis state and disable controls
|
||||
self.analysis_completed = False
|
||||
self.toggle_analysis_dependent_controls(enabled=False)
|
||||
|
||||
self.signals.update_status.emit(_("main_ui.analyzing_preparing"))
|
||||
self.signals.update_progress.emit(0) # Reset progress
|
||||
self.is_analyzing = True
|
||||
|
||||
# Stop any existing timer
|
||||
if self._analysis_timer:
|
||||
self._analysis_timer.stop()
|
||||
self._analysis_timer = None
|
||||
|
||||
# Create and configure the analysis thread
|
||||
self._analysis_thread = AnalysisThread(
|
||||
url=url,
|
||||
# ... arguments will be filled by ... usage below ...
|
||||
cookie_file_path=self.cookie_file_path,
|
||||
browser_cookies_option=self.browser_cookies_option,
|
||||
proxy_url=self.proxy_url,
|
||||
geo_proxy_url=self.geo_proxy_url,
|
||||
parent=self
|
||||
)
|
||||
|
||||
# Connect signals to handlers
|
||||
self._analysis_thread.status_update.connect(self.signals.update_status.emit)
|
||||
self._analysis_thread.progress_update.connect(self._handle_analysis_progress)
|
||||
self._analysis_thread.playlist_info_visible.connect(self.signals.playlist_info_label_visible.emit)
|
||||
self._analysis_thread.playlist_info_text.connect(self.signals.playlist_info_label_text.emit)
|
||||
self._analysis_thread.playlist_select_btn_visible.connect(self.signals.playlist_select_btn_visible.emit)
|
||||
self._analysis_thread.playlist_select_btn_text.connect(self.signals.playlist_select_btn_text.emit)
|
||||
self._analysis_thread.analysis_complete.connect(self._on_analysis_complete)
|
||||
self._analysis_thread.analysis_error.connect(self._on_analysis_error)
|
||||
self._analysis_thread.analysis_finished.connect(self._on_analysis_finished)
|
||||
|
||||
# Start the thread
|
||||
self._analysis_thread.start()
|
||||
|
||||
def _handle_analysis_progress(self, value: int) -> None:
|
||||
"""Handle progress updates from analysis thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
# Stop fake timer if running on any real update
|
||||
if self._analysis_timer:
|
||||
self._analysis_timer.stop()
|
||||
self._analysis_timer = None
|
||||
|
||||
self.signals.update_progress.emit(value)
|
||||
|
||||
# If we hit the extraction phase (30%), start fake progress
|
||||
if value == 30:
|
||||
self._fake_progress = 30
|
||||
self._analysis_timer = QTimer(self)
|
||||
self._analysis_timer.timeout.connect(self._update_fake_progress)
|
||||
self._analysis_timer.start(200) # Every 200ms
|
||||
|
||||
def _update_fake_progress(self) -> None:
|
||||
"""Increment progress bar slowly during long operations."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
# Asymptotically approach 85%
|
||||
if self._fake_progress < 85:
|
||||
# Slow down as we get higher
|
||||
increment = 1
|
||||
if self._fake_progress > 60:
|
||||
if self._fake_progress % 3 == 0: # Slower
|
||||
increment = 1
|
||||
else:
|
||||
increment = 0
|
||||
|
||||
if increment > 0:
|
||||
self._fake_progress += increment
|
||||
self.signals.update_progress.emit(self._fake_progress)
|
||||
|
||||
def _on_analysis_complete(self, result_data: Dict[str, Any]) -> None:
|
||||
"""Handle successful analysis completion - runs in main thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
# Stop fake timer
|
||||
if self._analysis_timer:
|
||||
self._analysis_timer.stop()
|
||||
self._analysis_timer = None
|
||||
|
||||
self.signals.update_progress.emit(100)
|
||||
|
||||
# Update instance variables with results (safe - we're in main thread)
|
||||
self.is_playlist = result_data["is_playlist"]
|
||||
self.playlist_info = result_data["playlist_info"]
|
||||
self.playlist_entries = result_data["playlist_entries"]
|
||||
self.video_info = result_data["video_info"]
|
||||
self.available_subtitles = result_data["available_subtitles"]
|
||||
self.available_automatic_subtitles = result_data["available_automatic_subtitles"]
|
||||
self.selected_playlist_items = None
|
||||
self.selected_subtitles = []
|
||||
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
default_sub = ConfigManager.get("default_subtitle_language")
|
||||
if default_sub:
|
||||
if isinstance(default_sub, str):
|
||||
default_sub_list = [s.strip() for s in default_sub.split(",")]
|
||||
else:
|
||||
default_sub_list = []
|
||||
|
||||
for lang in default_sub_list:
|
||||
if lang in self.available_subtitles:
|
||||
self.selected_subtitles.append(f"{lang} - Manual")
|
||||
elif lang in self.available_automatic_subtitles:
|
||||
self.selected_subtitles.append(f"{lang} - Auto-generated")
|
||||
|
||||
count = len(self.selected_subtitles)
|
||||
try:
|
||||
self.signals.selected_subs_label_text.emit(_("subtitle_selection.count_selected", count=count))
|
||||
self.subtitle_select_btn.setProperty("subtitlesSelected", count > 0)
|
||||
self.subtitle_select_btn.style().unpolish(self.subtitle_select_btn)
|
||||
self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update UI components (safe - we're in main thread)
|
||||
self.update_video_info(self.video_info)
|
||||
|
||||
# Download thumbnail
|
||||
thumbnail_url = result_data.get("thumbnail_url")
|
||||
if thumbnail_url:
|
||||
self.download_thumbnail(thumbnail_url)
|
||||
|
||||
# Save thumbnail if enabled
|
||||
if self.save_thumbnail:
|
||||
self.download_thumbnail_file(self.video_url, self.last_path)
|
||||
|
||||
# Update subtitle UI
|
||||
count = len(self.selected_subtitles)
|
||||
if count > 0:
|
||||
self.signals.selected_subs_label_text.emit(_("subtitle_selection.count_selected", count=count))
|
||||
else:
|
||||
self.signals.selected_subs_label_text.emit(_("main_ui.zero_selected"))
|
||||
|
||||
# Update format table
|
||||
self.video_button.setChecked(True)
|
||||
self.audio_button.setChecked(False)
|
||||
self.update_format_table(result_data["all_formats"])
|
||||
|
||||
self.signals.update_status.emit(_("main_ui.analysis_complete"))
|
||||
|
||||
# Mark analysis as complete and enable controls
|
||||
self.analysis_completed = True
|
||||
self.toggle_analysis_dependent_controls(enabled=True)
|
||||
|
||||
def _on_analysis_error(self, error_message: str) -> None:
|
||||
"""Handle analysis error - runs in main thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
if self._analysis_timer:
|
||||
self._analysis_timer.stop()
|
||||
self._analysis_timer = None
|
||||
self.signals.update_progress.emit(0)
|
||||
self.signals.update_status.emit(error_message)
|
||||
|
||||
def _on_analysis_finished(self) -> None:
|
||||
"""Handle analysis thread completion - runs in main thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
self.is_analyzing = False
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Browse tab - channel and playlist browsing
|
||||
==========================================
|
||||
|
||||
Paste (or route) a channel/playlist URL. Channels get Videos / Shorts / Live
|
||||
sub-tabs backed by {channel_url}/videos|shorts|streams flat fetches with
|
||||
-I range pagination; playlists get a single grid with Play all and a
|
||||
Download deep-link into the Downloads tab.
|
||||
|
||||
The Subscribe button emits subscribeRequested; the Feed page owns the
|
||||
subscription store and completes the wiring.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ytsage_gui_cards import VideoCardGrid
|
||||
from ..core.ytsage_client import YtdlpWorker
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
PAGE_SIZE = 24
|
||||
|
||||
CHANNEL_URL_RE = re.compile(r"(youtube\.com/(@[\w.-]+|channel/|c/|user/))", re.IGNORECASE)
|
||||
PLAYLIST_URL_RE = re.compile(r"(youtube\.com/playlist\?|[?&]list=)", re.IGNORECASE)
|
||||
|
||||
CHANNEL_TABS = [
|
||||
("browse.tab_videos", "videos"),
|
||||
("browse.tab_shorts", "shorts"),
|
||||
("browse.tab_live", "streams"),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_channel_base(url: str) -> str:
|
||||
"""Strip a trailing /videos|/shorts|/streams|/featured segment."""
|
||||
return re.sub(r"/(videos|shorts|streams|featured|playlists|community|about)/?(\?.*)?$", "", url.rstrip("/"))
|
||||
|
||||
|
||||
class _EntriesSection(QWidget):
|
||||
"""One grid fed by a flat-entries URL with pagination."""
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._worker: Optional[YtdlpWorker] = None
|
||||
self._url: Optional[str] = None
|
||||
self._offset = 0
|
||||
self._loaded_once = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 4, 0, 0)
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #9aa0a6; padding: 2px;")
|
||||
layout.addWidget(self.status_label)
|
||||
self.grid = VideoCardGrid(router, self)
|
||||
self.grid.loadMoreRequested.connect(self._load_more)
|
||||
layout.addWidget(self.grid, stretch=1)
|
||||
|
||||
def set_url(self, url: Optional[str]) -> None:
|
||||
self._url = url
|
||||
self._offset = 0
|
||||
self._loaded_once = False
|
||||
self.grid.clear()
|
||||
self.status_label.setText("")
|
||||
|
||||
def ensure_loaded(self) -> None:
|
||||
if not self._loaded_once and self._url and self._worker is None:
|
||||
self._loaded_once = True
|
||||
self._fetch(append=False)
|
||||
|
||||
def _load_more(self) -> None:
|
||||
if self._worker is None and self._url:
|
||||
self._offset += PAGE_SIZE
|
||||
self._fetch(append=True)
|
||||
|
||||
def _fetch(self, append: bool) -> None:
|
||||
self.status_label.setText(_("browse.loading"))
|
||||
url, start, end = self._url, self._offset + 1, self._offset + PAGE_SIZE
|
||||
self._worker = YtdlpWorker(lambda c: c.fetch_flat_entries(url, start=start, end=end), parent=self)
|
||||
self._worker.result.connect(lambda entries: self._on_results(entries, append))
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.finished.connect(self._on_finished)
|
||||
self._worker.start()
|
||||
|
||||
def _on_results(self, entries: List[Dict[str, Any]], append: bool) -> None:
|
||||
show_more = len(entries) >= PAGE_SIZE
|
||||
if append:
|
||||
self.grid.append_entries(entries, show_load_more=show_more)
|
||||
else:
|
||||
self.grid.set_entries(entries, show_load_more=show_more)
|
||||
self.status_label.setText(_("browse.entry_count", count=self.grid.card_count()))
|
||||
|
||||
def _on_error(self, message: str) -> None:
|
||||
logger.error(f"Browse fetch failed: {message}")
|
||||
self.status_label.setText(_("browse.failed", error=message[:200]))
|
||||
|
||||
def _on_finished(self) -> None:
|
||||
if self._worker is not None:
|
||||
self._worker.deleteLater()
|
||||
self._worker = None
|
||||
|
||||
|
||||
class BrowsePage(QWidget):
|
||||
subscribeRequested = Signal(dict) # {channel_id?, title?, url}
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._current_url: Optional[str] = None
|
||||
self._is_channel = False
|
||||
self._channel_meta: Dict[str, Any] = {}
|
||||
self._meta_worker: Optional[YtdlpWorker] = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.url_input = QLineEdit()
|
||||
self.url_input.setPlaceholderText(_("browse.placeholder"))
|
||||
self.url_input.returnPressed.connect(self._open_from_input)
|
||||
self.url_input.setMinimumHeight(38)
|
||||
bar.addWidget(self.url_input, stretch=1)
|
||||
self.open_btn = QPushButton(_("browse.open"))
|
||||
self.open_btn.setToolTip(_("browse.open_tooltip"))
|
||||
self.open_btn.setMinimumHeight(38)
|
||||
self.open_btn.clicked.connect(self._open_from_input)
|
||||
bar.addWidget(self.open_btn)
|
||||
layout.addLayout(bar)
|
||||
|
||||
header = QHBoxLayout()
|
||||
self.title_label = QLabel("")
|
||||
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
|
||||
header.addWidget(self.title_label, stretch=1)
|
||||
self.subscribe_btn = QPushButton(_("browse.subscribe"))
|
||||
self.subscribe_btn.setToolTip(_("browse.subscribe_tooltip"))
|
||||
self.subscribe_btn.setVisible(False)
|
||||
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
|
||||
header.addWidget(self.subscribe_btn)
|
||||
self.playall_btn = QPushButton(_("browse.play_all"))
|
||||
self.playall_btn.setToolTip(_("browse.play_all_tooltip"))
|
||||
self.playall_btn.setVisible(False)
|
||||
self.playall_btn.clicked.connect(self._on_play_all)
|
||||
header.addWidget(self.playall_btn)
|
||||
self.download_btn = QPushButton(_("browse.download_playlist"))
|
||||
self.download_btn.setToolTip(_("browse.download_playlist_tooltip"))
|
||||
self.download_btn.setVisible(False)
|
||||
self.download_btn.clicked.connect(self._on_download_playlist)
|
||||
header.addWidget(self.download_btn)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.channel_tabs = QTabWidget()
|
||||
self._sections: List[_EntriesSection] = []
|
||||
for label_key, _suffix in CHANNEL_TABS:
|
||||
section = _EntriesSection(router, self)
|
||||
self._sections.append(section)
|
||||
self.channel_tabs.addTab(section, _(label_key))
|
||||
self.channel_tabs.currentChanged.connect(self._on_channel_tab_changed)
|
||||
self.channel_tabs.setVisible(False)
|
||||
layout.addWidget(self.channel_tabs, stretch=1)
|
||||
|
||||
self.playlist_section = _EntriesSection(router, self)
|
||||
self.playlist_section.setVisible(False)
|
||||
layout.addWidget(self.playlist_section, stretch=1)
|
||||
|
||||
self.hint_label = QLabel(_("browse.hint"))
|
||||
self.hint_label.setStyleSheet("color: #9aa0a6; padding: 40px;")
|
||||
layout.addWidget(self.hint_label, stretch=1)
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def open_url(self, url: str) -> None:
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return
|
||||
self.url_input.setText(url)
|
||||
self._current_url = url
|
||||
self._is_channel = bool(CHANNEL_URL_RE.search(url)) and not PLAYLIST_URL_RE.search(url)
|
||||
self.hint_label.setVisible(False)
|
||||
self._channel_meta = {}
|
||||
self.title_label.setText(url)
|
||||
|
||||
if self._is_channel:
|
||||
base = _normalize_channel_base(url)
|
||||
self._current_url = base
|
||||
self.playlist_section.setVisible(False)
|
||||
self.channel_tabs.setVisible(True)
|
||||
self.subscribe_btn.setVisible(True)
|
||||
self.playall_btn.setVisible(False)
|
||||
self.download_btn.setVisible(False)
|
||||
for section, (_k, suffix) in zip(self._sections, CHANNEL_TABS):
|
||||
section.set_url(f"{base}/{suffix}")
|
||||
self._sections[self.channel_tabs.currentIndex()].ensure_loaded()
|
||||
self._fetch_channel_meta(base)
|
||||
else:
|
||||
self.channel_tabs.setVisible(False)
|
||||
self.subscribe_btn.setVisible(False)
|
||||
self.playlist_section.setVisible(True)
|
||||
self.playall_btn.setVisible(True)
|
||||
self.download_btn.setVisible(True)
|
||||
self.playlist_section.set_url(url)
|
||||
self.playlist_section.ensure_loaded()
|
||||
|
||||
# --------------------------------------------------------------- internal
|
||||
|
||||
def _open_from_input(self) -> None:
|
||||
self.open_url(self.url_input.text())
|
||||
|
||||
def _on_channel_tab_changed(self, index: int) -> None:
|
||||
if 0 <= index < len(self._sections):
|
||||
self._sections[index].ensure_loaded()
|
||||
|
||||
def _fetch_channel_meta(self, base_url: str) -> None:
|
||||
"""Fetch channel title/id from the videos tab head (cheap, 1 entry)."""
|
||||
if self._meta_worker is not None:
|
||||
return
|
||||
self._meta_worker = YtdlpWorker(lambda c: c.fetch_flat_info(f"{base_url}/videos", items="1:1"), parent=self)
|
||||
self._meta_worker.result.connect(self._on_channel_meta)
|
||||
self._meta_worker.error.connect(lambda m: logger.debug(f"Channel meta fetch failed: {m}"))
|
||||
self._meta_worker.finished.connect(self._on_meta_finished)
|
||||
self._meta_worker.start()
|
||||
|
||||
def _on_channel_meta(self, info: Dict[str, Any]) -> None:
|
||||
self._channel_meta = {
|
||||
"channel_id": info.get("channel_id") or info.get("uploader_id"),
|
||||
"title": info.get("channel") or info.get("uploader") or info.get("title"),
|
||||
"url": self._current_url,
|
||||
"avatar_url": None,
|
||||
}
|
||||
if self._channel_meta.get("title"):
|
||||
self.title_label.setText(self._channel_meta["title"])
|
||||
# Now that the real channel id is known, the button can say whether
|
||||
# this channel is already followed.
|
||||
self.refresh_subscribe_state()
|
||||
|
||||
def _on_meta_finished(self) -> None:
|
||||
if self._meta_worker is not None:
|
||||
self._meta_worker.deleteLater()
|
||||
self._meta_worker = None
|
||||
|
||||
def _on_subscribe_clicked(self) -> None:
|
||||
meta = dict(self._channel_meta) if self._channel_meta.get("url") else {"url": self._current_url}
|
||||
if not meta.get("title"):
|
||||
meta["title"] = self.title_label.text()
|
||||
self.subscribeRequested.emit(meta)
|
||||
# The handler toggles, so reflect the new state here. Previously the
|
||||
# button always read "Subscribe" whichever way it had just gone, and
|
||||
# the only feedback was a status line on a different tab.
|
||||
self.refresh_subscribe_state()
|
||||
|
||||
def refresh_subscribe_state(self) -> None:
|
||||
"""Make the button say what pressing it will now do."""
|
||||
channel_id = self._channel_meta.get("channel_id") or self._channel_meta.get("url") or self._current_url
|
||||
subscribed = bool(channel_id) and LibraryManager.is_subscribed(str(channel_id))
|
||||
self.subscribe_btn.setText(_("browse.unsubscribe") if subscribed else _("browse.subscribe"))
|
||||
self.subscribe_btn.setToolTip(
|
||||
_("browse.unsubscribe_tooltip") if subscribed else _("browse.subscribe_tooltip")
|
||||
)
|
||||
|
||||
def _on_play_all(self) -> None:
|
||||
cards = list(self.playlist_section.grid._cards)
|
||||
for entry in [c.entry for c in cards]:
|
||||
e = dict(entry)
|
||||
if not e.get("url") and e.get("id"):
|
||||
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
|
||||
self._router.queueVideo.emit(e)
|
||||
# "Play all" queues what has been loaded, which is one page unless the
|
||||
# user pressed Load more. Say so rather than implying the whole list.
|
||||
self.playlist_section.status_label.setText(_("browse.queued_loaded", count=len(cards)))
|
||||
|
||||
def _on_download_playlist(self) -> None:
|
||||
if self._current_url:
|
||||
self._router.downloadVideo.emit(self._current_url)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Video cards and card grid
|
||||
=========================
|
||||
|
||||
VideoCard renders one yt-dlp flat entry (thumbnail, title, channel, duration)
|
||||
with Play / Queue / Download / Channel actions wired to the AppRouter.
|
||||
VideoCardGrid lays cards out in a responsive grid inside a scroll area with
|
||||
an optional "Load more" button for pagination.
|
||||
|
||||
Thumbnails are fetched off-thread (shared QThreadPool) and cached on disk in
|
||||
APP_THUMBNAILS_DIR keyed by video id, following the history dialog's pattern.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal, QSize
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..utils.ytsage_constants import APP_THUMBNAILS_DIR
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
CARD_WIDTH = 300
|
||||
THUMB_SIZE = QSize(284, 160)
|
||||
|
||||
_thumb_pool = QThreadPool()
|
||||
_thumb_pool.setMaxThreadCount(6)
|
||||
|
||||
|
||||
def _entry_video_id(entry: Dict[str, Any]) -> str:
|
||||
vid = entry.get("id") or entry.get("url") or entry.get("title") or "unknown"
|
||||
return hashlib.sha1(str(vid).encode("utf-8")).hexdigest()[:20]
|
||||
|
||||
|
||||
def _entry_thumbnail_url(entry: Dict[str, Any]) -> Optional[str]:
|
||||
if entry.get("thumbnail"):
|
||||
return entry["thumbnail"]
|
||||
thumbs = entry.get("thumbnails") or []
|
||||
if thumbs:
|
||||
# flat entries carry a list sorted small->large; prefer a medium one
|
||||
mid = thumbs[len(thumbs) // 2]
|
||||
return mid.get("url")
|
||||
if entry.get("id") and entry.get("ie_key", "Youtube") == "Youtube":
|
||||
return f"https://i.ytimg.com/vi/{entry['id']}/mqdefault.jpg"
|
||||
return None
|
||||
|
||||
|
||||
def format_duration(seconds: Optional[float]) -> str:
|
||||
if not seconds:
|
||||
return ""
|
||||
seconds = int(seconds)
|
||||
h, rem = divmod(seconds, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
||||
|
||||
|
||||
class _ThumbSignals(QObject):
|
||||
loaded = Signal(str, bytes) # cache_key, data
|
||||
|
||||
|
||||
class _ThumbFetchTask(QRunnable):
|
||||
"""Fetch a thumbnail (disk cache first) off the GUI thread."""
|
||||
|
||||
def __init__(self, cache_key: str, url: str, signals: _ThumbSignals) -> None:
|
||||
super().__init__()
|
||||
self._cache_key = cache_key
|
||||
self._url = url
|
||||
self._signals = signals
|
||||
|
||||
def run(self) -> None:
|
||||
cache_file = APP_THUMBNAILS_DIR / f"{self._cache_key}.jpg"
|
||||
try:
|
||||
if cache_file.exists():
|
||||
self._signals.loaded.emit(self._cache_key, cache_file.read_bytes())
|
||||
return
|
||||
response = requests.get(self._url, timeout=10)
|
||||
if response.status_code == 200 and response.content:
|
||||
try:
|
||||
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_bytes(response.content)
|
||||
except OSError as e:
|
||||
logger.debug(f"Could not cache thumbnail: {e}")
|
||||
self._signals.loaded.emit(self._cache_key, response.content)
|
||||
except Exception as e:
|
||||
logger.debug(f"Thumbnail fetch failed for {self._url}: {e}")
|
||||
|
||||
|
||||
class VideoCard(QFrame):
|
||||
"""One video entry with hover actions."""
|
||||
|
||||
def __init__(self, entry: Dict[str, Any], router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.entry = entry
|
||||
self._router = router
|
||||
self._cache_key = _entry_video_id(entry)
|
||||
|
||||
self.setFixedWidth(CARD_WIDTH)
|
||||
self.setObjectName("videoCard")
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QFrame#videoCard {
|
||||
background-color: #1b2021;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QFrame#videoCard:hover { background-color: #24292b; }
|
||||
"""
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self.thumb_label = QLabel()
|
||||
self.thumb_label.setFixedSize(THUMB_SIZE)
|
||||
self.thumb_label.setStyleSheet("background-color: #101314; border-radius: 4px;")
|
||||
self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.thumb_label)
|
||||
|
||||
duration_text = format_duration(entry.get("duration"))
|
||||
title_text = entry.get("title") or entry.get("url") or ""
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setWordWrap(True)
|
||||
title_label.setStyleSheet("font-weight: bold;")
|
||||
title_label.setMaximumHeight(44)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
meta_parts = [p for p in [entry.get("channel") or entry.get("uploader"), duration_text] if p]
|
||||
meta_label = QLabel(" • ".join(meta_parts))
|
||||
meta_label.setStyleSheet("color: #9aa0a6; font-size: 11px;")
|
||||
layout.addWidget(meta_label)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(6)
|
||||
|
||||
self.play_btn = QPushButton(_("cards.play"))
|
||||
self.play_btn.setToolTip(_("cards.play_tooltip"))
|
||||
self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry()))
|
||||
actions.addWidget(self.play_btn)
|
||||
|
||||
self.queue_btn = QPushButton(_("cards.queue"))
|
||||
self.queue_btn.setToolTip(_("cards.queue_tooltip"))
|
||||
self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry()))
|
||||
actions.addWidget(self.queue_btn)
|
||||
|
||||
self.download_btn = QPushButton(_("cards.download"))
|
||||
self.download_btn.setToolTip(_("cards.download_tooltip"))
|
||||
self.download_btn.clicked.connect(self._emit_download)
|
||||
actions.addWidget(self.download_btn)
|
||||
|
||||
layout.addLayout(actions)
|
||||
|
||||
self._thumb_signals = _ThumbSignals()
|
||||
self._thumb_signals.loaded.connect(self._on_thumb_loaded)
|
||||
thumb_url = _entry_thumbnail_url(entry)
|
||||
if thumb_url:
|
||||
_thumb_pool.start(_ThumbFetchTask(self._cache_key, thumb_url, self._thumb_signals))
|
||||
|
||||
def _routed_entry(self) -> Dict[str, Any]:
|
||||
e = dict(self.entry)
|
||||
if not e.get("url") and e.get("id"):
|
||||
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
|
||||
return e
|
||||
|
||||
def _emit_download(self) -> None:
|
||||
e = self._routed_entry()
|
||||
if e.get("url"):
|
||||
self._router.downloadVideo.emit(e["url"])
|
||||
|
||||
def _on_thumb_loaded(self, cache_key: str, data: bytes) -> None:
|
||||
if cache_key != self._cache_key:
|
||||
return
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(data):
|
||||
self.thumb_label.setPixmap(
|
||||
pixmap.scaled(
|
||||
THUMB_SIZE,
|
||||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
self._router.playVideo.emit(self._routed_entry())
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
|
||||
class VideoCardGrid(QScrollArea):
|
||||
"""Responsive grid of VideoCards with optional Load more pagination."""
|
||||
|
||||
loadMoreRequested = Signal()
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._cards: List[VideoCard] = []
|
||||
#: video id -> card, so merge_entries can tell new from existing
|
||||
self._by_key: Dict[str, "VideoCard"] = {}
|
||||
self._columns_in_use = 0
|
||||
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
self._container = QWidget()
|
||||
self._container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
outer = QVBoxLayout(self._container)
|
||||
outer.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
self._grid_widget = QWidget()
|
||||
self._grid = QGridLayout(self._grid_widget)
|
||||
self._grid.setSpacing(10)
|
||||
self._grid.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
||||
outer.addWidget(self._grid_widget)
|
||||
|
||||
self.load_more_btn = QPushButton(_("cards.load_more"))
|
||||
self.load_more_btn.setToolTip(_("cards.load_more_tooltip"))
|
||||
self.load_more_btn.clicked.connect(self.loadMoreRequested.emit)
|
||||
self.load_more_btn.setVisible(False)
|
||||
outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.empty_label = QLabel(_("cards.empty"))
|
||||
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_label.setStyleSheet("color: #9aa0a6; padding: 40px;")
|
||||
outer.addWidget(self.empty_label)
|
||||
|
||||
outer.addStretch()
|
||||
self.setWidget(self._container)
|
||||
|
||||
# ---------------------------------------------------------------- data
|
||||
|
||||
def set_entries(self, entries: List[Dict[str, Any]], show_load_more: bool = False) -> None:
|
||||
self.clear()
|
||||
self.append_entries(entries, show_load_more=show_load_more)
|
||||
|
||||
def append_entries(self, entries: List[Dict[str, Any]], show_load_more: bool = False) -> None:
|
||||
for entry in entries:
|
||||
card = VideoCard(entry, self._router, self._grid_widget)
|
||||
self._cards.append(card)
|
||||
self._by_key[self._key(entry)] = card
|
||||
self._relayout()
|
||||
self.load_more_btn.setVisible(show_load_more)
|
||||
self.empty_label.setVisible(not self._cards)
|
||||
|
||||
@staticmethod
|
||||
def _key(entry: Dict[str, Any]) -> str:
|
||||
return str(entry.get("id") or entry.get("url") or id(entry))
|
||||
|
||||
def merge_entries(self, entries: List[Dict[str, Any]], prune: bool = False) -> None:
|
||||
"""
|
||||
Fold entries in, keeping cards that are already here.
|
||||
|
||||
set_entries() destroys and rebuilds every card, which during a feed
|
||||
refresh happened once per channel: the grid flickered, the scroll
|
||||
position was lost each time, and every thumbnail was re-read from
|
||||
disk. Existing cards are left alone here, so only genuinely new
|
||||
videos cost anything.
|
||||
"""
|
||||
incoming = {self._key(e): e for e in entries}
|
||||
scroll = self.verticalScrollBar().value()
|
||||
self.setUpdatesEnabled(False)
|
||||
try:
|
||||
for key, entry in incoming.items():
|
||||
if key in self._by_key:
|
||||
continue
|
||||
card = VideoCard(entry, self._router, self._grid_widget)
|
||||
self._cards.append(card)
|
||||
self._by_key[key] = card
|
||||
|
||||
if prune:
|
||||
for key in [k for k in self._by_key if k not in incoming]:
|
||||
card = self._by_key.pop(key)
|
||||
if card in self._cards:
|
||||
self._cards.remove(card)
|
||||
self._grid.removeWidget(card)
|
||||
card.deleteLater()
|
||||
|
||||
self._relayout()
|
||||
self.empty_label.setVisible(not self._cards)
|
||||
finally:
|
||||
self.setUpdatesEnabled(True)
|
||||
self.verticalScrollBar().setValue(scroll)
|
||||
|
||||
def clear(self) -> None:
|
||||
for card in self._cards:
|
||||
self._grid.removeWidget(card)
|
||||
card.deleteLater()
|
||||
self._cards = []
|
||||
self._by_key.clear()
|
||||
self.empty_label.setVisible(True)
|
||||
self.load_more_btn.setVisible(False)
|
||||
|
||||
def card_count(self) -> int:
|
||||
return len(self._cards)
|
||||
|
||||
# -------------------------------------------------------------- layout
|
||||
|
||||
def _columns(self) -> int:
|
||||
available = max(1, self.viewport().width() - 20)
|
||||
return max(1, available // (CARD_WIDTH + 10))
|
||||
|
||||
def _relayout(self) -> None:
|
||||
cols = self._columns()
|
||||
# Drain first. addWidget on a card the layout already owns adds a
|
||||
# second item for it, so the layout grew an extra entry per card on
|
||||
# every append. takeAt detaches without deleting the widget.
|
||||
while self._grid.count():
|
||||
self._grid.takeAt(0)
|
||||
for i, card in enumerate(self._cards):
|
||||
self._grid.addWidget(card, i // cols, i % cols)
|
||||
self._columns_in_use = cols
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
# Compared against what was actually laid out, not columnCount():
|
||||
# QGridLayout never shrinks its column count, so that comparison
|
||||
# stayed true forever and relaid out on every resize event.
|
||||
if self._cards and self._columns() != self._columns_in_use:
|
||||
self._relayout()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Dialog modules for YTSage GUI.
|
||||
|
||||
This package contains all dialog classes organized by functionality:
|
||||
|
||||
- ytsage_dialogs_base: Base utility dialogs
|
||||
- ytsage_dialogs_settings: Settings configuration dialogs
|
||||
- ytsage_dialogs_update: Update-related dialogs and threads
|
||||
- ytsage_dialogs_ffmpeg: FFmpeg installation dialogs
|
||||
- ytsage_dialogs_selection: Subtitle and playlist selection dialogs
|
||||
- ytsage_dialogs_custom: Custom functionality dialogs
|
||||
"""
|
||||
|
||||
# Re-export all dialog classes for backward compatibility
|
||||
from .ytsage_dialogs_base import AboutDialog, LogWindow
|
||||
from .ytsage_dialogs_custom import CustomOptionsDialog, TimeRangeDialog
|
||||
from .ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
|
||||
from .ytsage_dialogs_history import HistoryDialog
|
||||
from .ytsage_dialogs_selection import (
|
||||
PlaylistSelectionDialog,
|
||||
SponsorBlockCategoryDialog,
|
||||
SubtitleSelectionDialog,
|
||||
)
|
||||
from .ytsage_dialogs_settings import AutoUpdateSettingsDialog, DownloadSettingsDialog
|
||||
from .ytsage_dialogs_update import AutoUpdateThread, UpdateThread, VersionCheckThread, YTDLPUpdateDialog
|
||||
from .ytsage_dialogs_updater import UpdaterTabWidget
|
||||
|
||||
__all__ = [
|
||||
# Base dialogs
|
||||
"LogWindow",
|
||||
"AboutDialog",
|
||||
|
||||
# Settings dialogs
|
||||
"DownloadSettingsDialog",
|
||||
"AutoUpdateSettingsDialog",
|
||||
|
||||
# Update dialogs and threads
|
||||
"VersionCheckThread",
|
||||
"UpdateThread",
|
||||
"YTDLPUpdateDialog",
|
||||
"AutoUpdateThread",
|
||||
|
||||
# FFmpeg dialogs
|
||||
"FFmpegInstallThread",
|
||||
"FFmpegCheckDialog",
|
||||
|
||||
# Selection dialogs
|
||||
"SubtitleSelectionDialog",
|
||||
"PlaylistSelectionDialog",
|
||||
"SponsorBlockCategoryDialog",
|
||||
|
||||
# Custom functionality dialogs
|
||||
"CustomOptionsDialog",
|
||||
"TimeRangeDialog",
|
||||
|
||||
# Updater widget
|
||||
"UpdaterTabWidget",
|
||||
|
||||
# History dialog
|
||||
"HistoryDialog",
|
||||
]
|
||||
@@ -0,0 +1,643 @@
|
||||
"""
|
||||
Base dialogs for YTSage application.
|
||||
Contains basic utility dialogs like LogWindow and AboutDialog.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Signal, QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ... import __version__ as APP_VERSION
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_logger import logger
|
||||
from ...utils.ytsage_constants import APP_LOG_DIR
|
||||
|
||||
from ...core.ytsage_ffmpeg import get_ffmpeg_path
|
||||
from ...core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, get_deno_version, refresh_version_cache
|
||||
from ...core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path, check_ytdlp_deno_integration
|
||||
from ...core.ytsage_deno import check_deno_installed, get_deno_path
|
||||
|
||||
|
||||
class SystemInfoThread(QThread):
|
||||
"""Background thread to gather system information."""
|
||||
info_ready = Signal(dict)
|
||||
|
||||
def run(self):
|
||||
info = {}
|
||||
|
||||
# yt-dlp Status
|
||||
ytdlp_found = check_ytdlp_installed()
|
||||
info['ytdlp_found'] = ytdlp_found
|
||||
info['ytdlp_version'] = get_ytdlp_version()
|
||||
info['ytdlp_path'] = get_yt_dlp_path() if ytdlp_found else None
|
||||
|
||||
# yt-dlp cache status
|
||||
ytdlp_cache = _version_cache.get("ytdlp", {})
|
||||
info['ytdlp_last_check'] = ytdlp_cache.get("last_check", 0)
|
||||
|
||||
# FFmpeg Status
|
||||
ffmpeg_found = check_ffmpeg()
|
||||
info['ffmpeg_found'] = ffmpeg_found
|
||||
info['ffmpeg_version'] = get_ffmpeg_version() if ffmpeg_found else _('about.not_available')
|
||||
info['ffmpeg_path'] = get_ffmpeg_path() if ffmpeg_found else None
|
||||
|
||||
# FFmpeg cache status
|
||||
ffmpeg_cache = _version_cache.get("ffmpeg", {})
|
||||
info['ffmpeg_last_check'] = ffmpeg_cache.get("last_check", 0)
|
||||
|
||||
# Deno Status
|
||||
deno_found = check_deno_installed()
|
||||
info['deno_found'] = deno_found
|
||||
info['deno_version'] = get_deno_version() if deno_found else _('about.not_available')
|
||||
info['deno_path'] = get_deno_path() if deno_found else None
|
||||
|
||||
# Deno cache status
|
||||
deno_cache = _version_cache.get("deno", {})
|
||||
info['deno_last_check'] = deno_cache.get("last_check", 0)
|
||||
|
||||
# Check integration with yt-dlp if both are present
|
||||
info['integration_status'] = False
|
||||
if deno_found and ytdlp_found:
|
||||
info['integration_status'] = check_ytdlp_deno_integration()
|
||||
|
||||
self.info_ready.emit(info)
|
||||
|
||||
|
||||
class LogWindow(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("dialogs.ytdlp_log_title"))
|
||||
self.setMinimumSize(700, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.log_text = QTextEdit()
|
||||
self.log_text.setReadOnly(True)
|
||||
self.log_text.setStyleSheet(
|
||||
"""
|
||||
QTextEdit {
|
||||
background-color: #2b2b2b;
|
||||
color: #ffffff;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
layout.addWidget(self.log_text)
|
||||
|
||||
def append_log(self, message) -> None:
|
||||
self.log_text.append(message)
|
||||
# Auto-scroll to bottom
|
||||
scrollbar = self.log_text.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
|
||||
class AboutDialog(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._parent = parent # Store parent to access version etc.
|
||||
self.setWindowTitle(_("about.title"))
|
||||
self.setMinimumSize(460, 420) # Slightly increased to accommodate paths
|
||||
self.resize(460, 440) # Slightly increased initial size
|
||||
self.setMaximumSize(500, 480) # Reasonable maximum size
|
||||
|
||||
# Set window flags to make dialog independent of parent movement
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15) # Reduced spacing
|
||||
layout.setContentsMargins(20, 20, 20, 20) # Reduced margins
|
||||
layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetMinAndMaxSize)
|
||||
|
||||
# App Information Section
|
||||
app_info_widget = self._create_app_info_section()
|
||||
layout.addWidget(app_info_widget)
|
||||
|
||||
# Separator - subtle and compact
|
||||
separator = QWidget()
|
||||
separator.setFixedHeight(1)
|
||||
separator.setStyleSheet("background-color: #2a2a2a; margin: 8px 20px;")
|
||||
layout.addWidget(separator)
|
||||
|
||||
# System Information Section
|
||||
system_info_widget = self._create_system_info_section()
|
||||
layout.addWidget(system_info_widget)
|
||||
|
||||
# Close Button - compact positioning
|
||||
layout.addSpacing(10) # Reduced space before button
|
||||
button_layout = QHBoxLayout()
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
|
||||
button_box.accepted.connect(self.accept)
|
||||
|
||||
# Center the button
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(button_box)
|
||||
button_layout.addStretch()
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Apply overall styling - improved consistency
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 10px 30px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QGroupBox {
|
||||
font-weight: bold;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
background-color: #15181b;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 15px;
|
||||
padding: 0 10px 0 10px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
def _create_app_info_section(self) -> QWidget:
|
||||
"""Create the application information section - compact version"""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setSpacing(6) # Reduced spacing
|
||||
|
||||
# Title and Version - more compact
|
||||
title_label = QLabel(
|
||||
"<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>SageTube</span>"
|
||||
)
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
version_label = QLabel(
|
||||
f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>"
|
||||
f"{_('about.version', version=getattr(self._parent, 'version', APP_VERSION))}</span>"
|
||||
)
|
||||
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(version_label)
|
||||
|
||||
# Description - more compact
|
||||
description_label = QLabel(_("about.description"))
|
||||
description_label.setWordWrap(True)
|
||||
description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
description_label.setStyleSheet("color: #ffffff; font-size: 11px; margin: 6px 0;")
|
||||
layout.addWidget(description_label)
|
||||
|
||||
# Author and Links - compact single line
|
||||
info_layout = QHBoxLayout()
|
||||
info_layout.setSpacing(15)
|
||||
|
||||
author_link = '<a href="https://git.houmeres.sk/Houmeres" style="color: #c90000; text-decoration: none;">Houmeres</a>'
|
||||
author_label = QLabel(
|
||||
f"{_('about.author', author=author_link)}"
|
||||
)
|
||||
author_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(author_label)
|
||||
|
||||
repo_link = '<a href="https://git.houmeres.sk/Houmeres/SageTube" style="color: #c90000; text-decoration: none;">SageTube</a>'
|
||||
repo_label = QLabel(
|
||||
f"{_('about.github', repo=repo_link)}"
|
||||
)
|
||||
repo_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(repo_label)
|
||||
|
||||
upstream_link = (
|
||||
'<a href="https://github.com/oop7/YTSage" style="color: #c90000; text-decoration: none;">'
|
||||
"Based on YTSage by oop7</a>"
|
||||
)
|
||||
upstream_label = QLabel(upstream_link)
|
||||
upstream_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(upstream_label)
|
||||
|
||||
# Center the info layout
|
||||
info_container = QHBoxLayout()
|
||||
info_container.addStretch()
|
||||
info_container.addLayout(info_layout)
|
||||
info_container.addStretch()
|
||||
|
||||
layout.addLayout(info_container)
|
||||
|
||||
return widget
|
||||
|
||||
def _create_system_info_section(self) -> QWidget:
|
||||
"""Create the system information section with compact design"""
|
||||
# Create main container with compact styling
|
||||
container = QWidget()
|
||||
container.setStyleSheet(
|
||||
"""
|
||||
QWidget {
|
||||
border: 1px solid #333333;
|
||||
border-radius: 8px;
|
||||
background-color: #15181b;
|
||||
margin-top: 5px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
main_layout = QVBoxLayout(container)
|
||||
main_layout.setSpacing(8) # Compact spacing
|
||||
main_layout.setContentsMargins(15, 10, 15, 10)
|
||||
|
||||
# Create header with title and refresh button on same line
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 0, 5)
|
||||
|
||||
# System Information title
|
||||
title_label = QLabel(_("about.system_info"))
|
||||
title_label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
header_layout.addWidget(title_label)
|
||||
|
||||
# Add stretch to push refresh button to the right
|
||||
header_layout.addStretch()
|
||||
|
||||
# Create logs button (minimal)
|
||||
self.logs_btn = QPushButton(_("about.open_logs")) # Expected to be small text or icon
|
||||
self.logs_btn.setToolTip(_("about.logs_tooltip"))
|
||||
self.logs_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.logs_btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
padding: 1px 6px;
|
||||
background-color: transparent;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #888888;
|
||||
font-size: 10px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
color: #ffffff;
|
||||
border-color: #555;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.logs_btn.clicked.connect(self.open_logs_folder)
|
||||
header_layout.addWidget(self.logs_btn)
|
||||
|
||||
# Create refresh button
|
||||
self.refresh_btn = QPushButton(_("about.refresh"))
|
||||
self.refresh_btn.setFixedSize(16, 16)
|
||||
self.refresh_btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
padding: 0px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #cccccc;
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
margin: 0px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
color: #c90000;
|
||||
background-color: rgba(201, 0, 0, 0.1);
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.refresh_btn.clicked.connect(self.refresh_version_info)
|
||||
header_layout.addWidget(self.refresh_btn)
|
||||
|
||||
main_layout.addLayout(header_layout)
|
||||
|
||||
# Compact status grid layout
|
||||
self.status_container = QVBoxLayout()
|
||||
self.status_container.setSpacing(6) # Tight spacing
|
||||
self.status_container.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
main_layout.addLayout(self.status_container)
|
||||
|
||||
# Show loading message initially
|
||||
self._show_loading_message()
|
||||
|
||||
# Populate system information asynchronously
|
||||
QTimer.singleShot(100, self.update_system_info)
|
||||
|
||||
return container
|
||||
|
||||
def _show_loading_message(self) -> None:
|
||||
"""Show a compact loading message while system information is being gathered."""
|
||||
# Strip the emoji from the localized string to avoid rendering issues
|
||||
loading_text = _("about.loading").replace("🔄", "").strip()
|
||||
loading_label = QLabel(loading_text)
|
||||
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
loading_label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
color: #888888;
|
||||
font-size: 11px;
|
||||
padding: 10px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
self.status_container.addWidget(loading_label)
|
||||
|
||||
def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status="") -> QWidget:
|
||||
"""Create a compact status item widget"""
|
||||
item_widget = QWidget()
|
||||
# Adjust height based on whether we have path info
|
||||
item_height = 50 if path_text else 35
|
||||
item_widget.setMaximumHeight(item_height)
|
||||
item_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
# Main layout
|
||||
item_layout = QVBoxLayout(item_widget)
|
||||
item_layout.setContentsMargins(8, 4, 8, 4)
|
||||
item_layout.setSpacing(2)
|
||||
|
||||
# First row: Icon, name, status, version
|
||||
first_row = QHBoxLayout()
|
||||
first_row.setSpacing(8)
|
||||
|
||||
# Icon and name - compact
|
||||
name_label = QLabel(f"{icon} <b>{name}</b>")
|
||||
name_label.setStyleSheet("font-size: 12px; color: #ffffff; font-weight: bold;")
|
||||
name_label.setMinimumWidth(80)
|
||||
first_row.addWidget(name_label)
|
||||
|
||||
# Status - compact
|
||||
status_label = QLabel(status_text)
|
||||
status_label.setStyleSheet("font-size: 11px; font-weight: bold;")
|
||||
status_label.setMinimumWidth(70)
|
||||
first_row.addWidget(status_label)
|
||||
|
||||
# Version info - improved readability
|
||||
version_info = version_text
|
||||
if cache_status:
|
||||
version_info += cache_status
|
||||
|
||||
version_label = QLabel(version_info)
|
||||
version_label.setStyleSheet("font-size: 11px; color: #cccccc;") # Increased from 10px
|
||||
version_label.setWordWrap(False)
|
||||
first_row.addWidget(version_label)
|
||||
|
||||
# Add stretch to push everything left
|
||||
first_row.addStretch()
|
||||
|
||||
item_layout.addLayout(first_row)
|
||||
|
||||
# Second row: Path (if provided)
|
||||
if path_text:
|
||||
path_label = QLabel(f"📁 {path_text}")
|
||||
path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color
|
||||
path_label.setWordWrap(False)
|
||||
# Truncate very long paths
|
||||
if len(str(path_text)) > 60:
|
||||
truncated_path = "..." + str(path_text)[-57:]
|
||||
path_label.setText(f"📁 {truncated_path}")
|
||||
item_layout.addWidget(path_label)
|
||||
|
||||
# Subtle background with minimal border
|
||||
item_widget.setStyleSheet(
|
||||
"""
|
||||
QWidget {
|
||||
background-color: rgba(45, 45, 45, 0.3);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
}
|
||||
QWidget:hover {
|
||||
background-color: rgba(60, 60, 60, 0.4);
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
return item_widget
|
||||
|
||||
def update_system_info(self) -> None:
|
||||
"""Start background thread to gather system info."""
|
||||
self.info_thread = SystemInfoThread()
|
||||
self.info_thread.info_ready.connect(self._populate_system_info)
|
||||
self.info_thread.start()
|
||||
|
||||
def _populate_system_info(self, info: dict) -> None:
|
||||
"""Populate UI with gathered info."""
|
||||
# Clear existing items
|
||||
for i in reversed(range(self.status_container.count())):
|
||||
child = self.status_container.itemAt(i).widget()
|
||||
if child:
|
||||
child.deleteLater()
|
||||
|
||||
# yt-dlp Status - compact version with path
|
||||
ytdlp_found = info['ytdlp_found']
|
||||
ytdlp_status_text = (
|
||||
f"<span style='color: #4CAF50;'>{_('about.detected')}</span>" if ytdlp_found else f"<span style='color: #F44336;'>{_('about.missing')}</span>"
|
||||
)
|
||||
ytdlp_version = info['ytdlp_version']
|
||||
|
||||
# Get yt-dlp path
|
||||
ytdlp_path = info['ytdlp_path']
|
||||
ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None
|
||||
|
||||
# Simplified cache status
|
||||
last_check = info['ytdlp_last_check']
|
||||
cache_status = ""
|
||||
if last_check > 0:
|
||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
||||
|
||||
ytdlp_item = self._create_status_item(
|
||||
"🎥",
|
||||
"yt-dlp",
|
||||
ytdlp_status_text,
|
||||
ytdlp_version + cache_status,
|
||||
ytdlp_path_text,
|
||||
)
|
||||
self.status_container.addWidget(ytdlp_item)
|
||||
|
||||
# FFmpeg Status - compact version with path
|
||||
ffmpeg_found = info['ffmpeg_found']
|
||||
ffmpeg_status_text = (
|
||||
f"<span style='color: #4CAF50;'>{_('about.detected')}</span>"
|
||||
if ffmpeg_found
|
||||
else f"<span style='color: #F44336;'>{_('about.missing')}</span>"
|
||||
)
|
||||
ffmpeg_version = info['ffmpeg_version']
|
||||
|
||||
# Get FFmpeg path
|
||||
ffmpeg_path_text = info['ffmpeg_path']
|
||||
|
||||
# Simplified cache status for FFmpeg
|
||||
last_check = info['ffmpeg_last_check']
|
||||
cache_status = ""
|
||||
if last_check > 0 and ffmpeg_found:
|
||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
||||
|
||||
ffmpeg_item = self._create_status_item(
|
||||
"🎬",
|
||||
"FFmpeg",
|
||||
ffmpeg_status_text,
|
||||
ffmpeg_version + cache_status,
|
||||
ffmpeg_path_text,
|
||||
)
|
||||
self.status_container.addWidget(ffmpeg_item)
|
||||
|
||||
# Deno Status - compact version with path (only show path if in app bin directory)
|
||||
deno_found = info['deno_found']
|
||||
deno_status_text = (
|
||||
f"<span style='color: #4CAF50;'>{_('about.detected')}</span>" if deno_found else f"<span style='color: #F44336;'>{_('about.missing')}</span>"
|
||||
)
|
||||
deno_version = info['deno_version']
|
||||
|
||||
# Get Deno path - only show if in app bin directory
|
||||
deno_path_text = None
|
||||
if deno_found:
|
||||
deno_path = info['deno_path']
|
||||
# Only show path if it's not the fallback "deno" and the file exists
|
||||
if deno_path and deno_path != "deno":
|
||||
from pathlib import Path
|
||||
from ...utils.ytsage_constants import DENO_APP_BIN_PATH
|
||||
# Check if the path is our managed binary
|
||||
if Path(deno_path).resolve() == DENO_APP_BIN_PATH.resolve():
|
||||
deno_path_text = deno_path
|
||||
|
||||
# Simplified cache status for Deno
|
||||
last_check = info['deno_last_check']
|
||||
cache_status = ""
|
||||
if last_check > 0 and deno_found:
|
||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>"
|
||||
|
||||
# Check integration with yt-dlp if both are present
|
||||
integration_status = ""
|
||||
if info.get('integration_status', False):
|
||||
integration_status = f" <span style='color: #4CAF50; font-size: 10px; font-weight: bold;'> + yt-dlp</span>"
|
||||
|
||||
deno_item = self._create_status_item(
|
||||
"🦕",
|
||||
"Deno",
|
||||
deno_status_text,
|
||||
deno_version + cache_status + integration_status,
|
||||
deno_path_text,
|
||||
)
|
||||
self.status_container.addWidget(deno_item)
|
||||
|
||||
|
||||
def open_logs_folder(self):
|
||||
"""Open the application logs folder in the system file explorer."""
|
||||
try:
|
||||
if not APP_LOG_DIR.exists():
|
||||
logger.warning(f"Log directory does not exist: {APP_LOG_DIR}")
|
||||
APP_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_url = QUrl.fromLocalFile(str(APP_LOG_DIR))
|
||||
QDesktopServices.openUrl(log_url)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open log folder: {e}")
|
||||
# Minimal error feedback since this is about dialog
|
||||
self.logs_btn.setToolTip(f"Error: {str(e)}")
|
||||
|
||||
def refresh_version_info(self) -> None:
|
||||
"""Refresh version information manually."""
|
||||
self.refresh_btn.setText(_('about.refreshing'))
|
||||
self.refresh_btn.setEnabled(False)
|
||||
|
||||
# Perform refresh in a separate thread to avoid blocking UI
|
||||
class RefreshThread(QThread):
|
||||
finished = Signal(bool)
|
||||
|
||||
def run(self):
|
||||
success = refresh_version_cache(force=True)
|
||||
self.finished.emit(success)
|
||||
|
||||
self.refresh_thread = RefreshThread()
|
||||
self.refresh_thread.finished.connect(self.on_refresh_finished)
|
||||
self.refresh_thread.start()
|
||||
|
||||
def on_refresh_finished(self, success) -> None:
|
||||
"""Handle refresh completion."""
|
||||
self.refresh_btn.setText(_('about.refresh'))
|
||||
self.refresh_btn.setEnabled(True)
|
||||
|
||||
if success:
|
||||
self.update_system_info()
|
||||
else:
|
||||
# Show error message with proper styling
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setWindowTitle(_('about.refresh_failed'))
|
||||
msg_box.setText(_('about.refresh_failed_message'))
|
||||
msg_box.setWindowIcon(self.windowIcon())
|
||||
msg_box.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
msg_box.exec()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
FFmpeg installation dialogs for YTSage application.
|
||||
Contains dialogs and threads for checking and installing FFmpeg.
|
||||
"""
|
||||
|
||||
import webbrowser
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from ...core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
|
||||
from ...utils.ytsage_constants import ICON_PATH
|
||||
from ...utils.ytsage_localization import _
|
||||
|
||||
|
||||
class FFmpegInstallThread(QThread):
|
||||
finished = Signal(bool)
|
||||
progress = Signal(str)
|
||||
|
||||
def run(self) -> None:
|
||||
# Use a callback to capture progress instead of stdout redirection
|
||||
def progress_callback(msg: str):
|
||||
self.progress.emit(msg)
|
||||
|
||||
# Install FFmpeg with progress callback
|
||||
success = auto_install_ffmpeg(progress_callback=progress_callback)
|
||||
self.finished.emit(success)
|
||||
|
||||
|
||||
class FFmpegCheckDialog(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("ffmpeg.installation_title"))
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(280)
|
||||
self.resize(500, 300)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent and parent.windowIcon():
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
else:
|
||||
# Try to load the icon directly if parent not available
|
||||
# icon_path logic moved to src\utils\ytsage_constants.py
|
||||
|
||||
if ICON_PATH.exists():
|
||||
self.setWindowIcon(QIcon(str(ICON_PATH)))
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Header with title and improved spacing
|
||||
header_text = QLabel(_("ffmpeg.installation_title"))
|
||||
header_text.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;")
|
||||
header_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(header_text)
|
||||
|
||||
# Message
|
||||
self.message_label = QLabel(_("ffmpeg.installation_message"))
|
||||
self.message_label.setWordWrap(True)
|
||||
self.message_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 10px 0; line-height: 1.4;")
|
||||
self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.message_label)
|
||||
|
||||
# Progress label with improved styling
|
||||
self.progress_label = QLabel("")
|
||||
self.progress_label.setWordWrap(True)
|
||||
self.progress_label.setMinimumHeight(80)
|
||||
self.progress_label.setMaximumHeight(120)
|
||||
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
||||
self.progress_label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
background-color: #1d1e22;
|
||||
color: #cccccc;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.progress_label.hide()
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
# Add stretch to push buttons to bottom
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons container - simple approach that should work
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(12)
|
||||
|
||||
# Install button
|
||||
self.install_btn = QPushButton(_("ffmpeg.install_button"))
|
||||
self.install_btn.clicked.connect(self.start_installation)
|
||||
button_layout.addWidget(self.install_btn)
|
||||
|
||||
# Manual install button
|
||||
self.manual_btn = QPushButton(_("ffmpeg.manual_guide"))
|
||||
self.manual_btn.clicked.connect(lambda: webbrowser.open("https://github.com/oop7/ffmpeg-install-guide"))
|
||||
button_layout.addWidget(self.manual_btn)
|
||||
|
||||
# Close button
|
||||
self.close_btn = QPushButton(_("buttons.close"))
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
button_layout.addWidget(self.close_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Style the dialog to match app theme
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
color: #999999;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Initialize installation thread
|
||||
self.install_thread = None
|
||||
self.progress_messages = [] # Store progress messages
|
||||
|
||||
def start_installation(self) -> None:
|
||||
self.install_btn.setEnabled(False)
|
||||
self.manual_btn.setEnabled(False)
|
||||
self.close_btn.setEnabled(False)
|
||||
|
||||
# Check if FFmpeg is already installed
|
||||
if check_ffmpeg_installed():
|
||||
self.message_label.setText(_("ffmpeg.already_installed"))
|
||||
self.progress_label.setText(_("ffmpeg.installation_complete"))
|
||||
self.progress_label.show()
|
||||
self.install_btn.hide()
|
||||
self.manual_btn.hide()
|
||||
self.close_btn.setEnabled(True)
|
||||
return
|
||||
|
||||
self.message_label.setText(_("ffmpeg.installing"))
|
||||
self.progress_messages = [] # Clear previous messages
|
||||
self.progress_label.show()
|
||||
|
||||
self.install_thread = FFmpegInstallThread()
|
||||
self.install_thread.finished.connect(self.installation_finished)
|
||||
self.install_thread.progress.connect(self.update_progress)
|
||||
self.install_thread.start()
|
||||
|
||||
def update_progress(self, message) -> None:
|
||||
# Keep only the last 5 messages to avoid overflow
|
||||
self.progress_messages.append(message)
|
||||
if len(self.progress_messages) > 5:
|
||||
self.progress_messages.pop(0)
|
||||
|
||||
# Display the messages
|
||||
self.progress_label.setText("\n".join(self.progress_messages))
|
||||
|
||||
def installation_finished(self, success) -> None:
|
||||
if success:
|
||||
self.message_label.setText(_("ffmpeg.install_success"))
|
||||
self.progress_label.setText(_("ffmpeg.installation_complete_close"))
|
||||
self.install_btn.hide()
|
||||
self.manual_btn.hide()
|
||||
else:
|
||||
self.message_label.setText(_("ffmpeg.installation_failed"))
|
||||
self.progress_label.setText(_("ffmpeg.try_manual"))
|
||||
self.install_btn.setEnabled(True)
|
||||
self.manual_btn.setEnabled(True)
|
||||
|
||||
self.close_btn.setEnabled(True)
|
||||
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
History Dialog for YTSage application.
|
||||
Displays download history with thumbnails using a virtualized list for performance.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, List, Any, Dict
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import (
|
||||
Qt, QSize, Signal, QThread, QTimer, QAbstractListModel,
|
||||
QModelIndex, QRect, QPoint, QEvent
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QPixmap, QIcon, QPainter, QColor, QFont, QBrush, QPen,
|
||||
QMouseEvent, QDesktopServices, QAction, QCursor, QPainterPath
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QListView,
|
||||
QWidget,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QStyledItemDelegate,
|
||||
QStyle,
|
||||
QApplication
|
||||
)
|
||||
|
||||
from ...utils.ytsage_history_manager import HistoryManager
|
||||
from ...utils.ytsage_constants import APP_THUMBNAILS_DIR, SUBPROCESS_CREATIONFLAGS
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class HistoryLoaderThread(QThread):
|
||||
"""Thread to load history and pre-fetch thumbnails."""
|
||||
|
||||
entries_loaded = Signal(list)
|
||||
thumbnail_loaded = Signal(str, bytes) # entry_id, image_bytes
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# Load entries from DB
|
||||
entries = HistoryManager.get_all_entries()
|
||||
self.entries_loaded.emit(entries)
|
||||
|
||||
# Background thumbnail loader
|
||||
for entry in entries:
|
||||
if self.isInterruptionRequested():
|
||||
break
|
||||
|
||||
thumbnail_url = entry.get("thumbnail_url")
|
||||
entry_id = entry.get("id", "")
|
||||
|
||||
if not thumbnail_url or not entry_id:
|
||||
continue
|
||||
|
||||
thumbnail_filename = f"{entry_id}.jpg"
|
||||
thumbnail_path = APP_THUMBNAILS_DIR / thumbnail_filename
|
||||
|
||||
if not thumbnail_path.exists():
|
||||
try:
|
||||
response = requests.get(thumbnail_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Optimize image before saving
|
||||
img_io = BytesIO(response.content)
|
||||
image = Image.open(img_io)
|
||||
|
||||
# Save to disk
|
||||
image.save(thumbnail_path, "JPEG", quality=90, optimize=True)
|
||||
|
||||
# Emit bytes for memory cache
|
||||
self.thumbnail_loaded.emit(entry_id, response.content)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error caching thumbnail: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading history: {e}")
|
||||
self.entries_loaded.emit([])
|
||||
|
||||
|
||||
class HistoryModel(QAbstractListModel):
|
||||
"""List Model for History Entries."""
|
||||
|
||||
EntryRole = Qt.ItemDataRole.UserRole + 1
|
||||
IdRole = Qt.ItemDataRole.UserRole + 2
|
||||
ThumbnailRole = Qt.ItemDataRole.UserRole + 3
|
||||
|
||||
def __init__(self, entries=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._entries = entries or []
|
||||
self.thumbnail_cache = {} # Map entry_id -> QPixmap
|
||||
|
||||
def rowCount(self, parent=QModelIndex()):
|
||||
return len(self._entries)
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid() or not (0 <= index.row() < len(self._entries)):
|
||||
return None
|
||||
|
||||
entry = self._entries[index.row()]
|
||||
entry_id = entry.get("id")
|
||||
|
||||
if role == self.EntryRole:
|
||||
return entry
|
||||
|
||||
elif role == self.IdRole:
|
||||
return entry_id
|
||||
|
||||
elif role == self.ThumbnailRole:
|
||||
return self.thumbnail_cache.get(entry_id)
|
||||
|
||||
elif role == Qt.ItemDataRole.DisplayRole:
|
||||
return entry.get("title", "")
|
||||
|
||||
return None
|
||||
|
||||
def update_entries(self, entries):
|
||||
self.beginResetModel()
|
||||
self._entries = entries
|
||||
self.endResetModel()
|
||||
|
||||
def remove_item(self, row):
|
||||
if 0 <= row < len(self._entries):
|
||||
self.beginRemoveRows(QModelIndex(), row, row)
|
||||
del self._entries[row]
|
||||
self.endRemoveRows()
|
||||
|
||||
def update_thumbnail(self, entry_id, pixmap):
|
||||
"""Update cache and notify view."""
|
||||
self.thumbnail_cache[entry_id] = pixmap
|
||||
# Find index for this ID
|
||||
for i, entry in enumerate(self._entries):
|
||||
if entry.get("id") == entry_id:
|
||||
idx = self.index(i)
|
||||
self.dataChanged.emit(idx, idx, [self.ThumbnailRole])
|
||||
break
|
||||
|
||||
|
||||
class HistoryDelegate(QStyledItemDelegate):
|
||||
"""Delegate to render history cards similar to the widgets."""
|
||||
|
||||
menu_clicked = Signal(QModelIndex, QPoint) # Signal for menu click
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.padding = 10
|
||||
self.thumb_width = 240
|
||||
self.thumb_height = 135
|
||||
# Increased card height to accommodate spacing
|
||||
self.card_height = 175
|
||||
# Define margins for spacing between cards
|
||||
self.h_margin = 10
|
||||
self.v_margin = 8
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
return QSize(option.rect.width(), self.card_height)
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
entry = index.data(HistoryModel.EntryRole)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
rect = option.rect
|
||||
# Apply margins for spacing
|
||||
card_rect = rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
|
||||
|
||||
is_hover = option.state & QStyle.StateFlag.State_MouseOver
|
||||
bg_color = QColor("#252830") if is_hover else QColor("#1d1e22")
|
||||
border_color = QColor("#3a3d46") if is_hover else QColor("#2a2d36")
|
||||
|
||||
# Draw Card
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(card_rect, 8, 8)
|
||||
|
||||
painter.fillPath(path, QBrush(bg_color))
|
||||
painter.setPen(QPen(border_color, 1))
|
||||
painter.drawPath(path)
|
||||
|
||||
# Draw Thumbnail
|
||||
thumb_rect = QRect(
|
||||
card_rect.left() + 10,
|
||||
card_rect.top() + 10,
|
||||
self.thumb_width,
|
||||
self.thumb_height
|
||||
)
|
||||
|
||||
pixmap = index.data(HistoryModel.ThumbnailRole)
|
||||
if pixmap and not pixmap.isNull():
|
||||
scaled = pixmap.scaled(
|
||||
thumb_rect.size(),
|
||||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
# Clip to rect
|
||||
painter.setClipRect(thumb_rect)
|
||||
painter.drawPixmap(thumb_rect.topLeft(), scaled)
|
||||
painter.setClipping(False)
|
||||
else:
|
||||
painter.fillRect(thumb_rect, QColor("#15181b"))
|
||||
painter.setPen(QPen(QColor("#666666")))
|
||||
icon_char = "🎵" if entry.get("is_audio_only") else "📹"
|
||||
painter.setFont(QFont("Segoe UI Emoji", 24))
|
||||
painter.drawText(thumb_rect, Qt.AlignmentFlag.AlignCenter, icon_char)
|
||||
|
||||
# Draw Border around thumb
|
||||
painter.setPen(QPen(QColor("#3d3d3d"), 2))
|
||||
painter.drawRect(thumb_rect)
|
||||
|
||||
# Text Area
|
||||
text_x = thumb_rect.right() + 12
|
||||
text_width = card_rect.right() - text_x - 85 # Leave even more space for the larger 60px button
|
||||
|
||||
# Title
|
||||
title_rect = QRect(text_x, thumb_rect.top(), text_width, 50)
|
||||
painter.setPen(QColor("#ffffff"))
|
||||
font_title = QFont()
|
||||
font_title.setBold(True)
|
||||
font_title.setPixelSize(14)
|
||||
painter.setFont(font_title)
|
||||
|
||||
# Use simple alignment flags
|
||||
painter.drawText(title_rect, Qt.AlignmentFlag.AlignLeft | Qt.TextFlag.TextWordWrap, entry.get("title", ""))
|
||||
|
||||
current_y = title_rect.bottom() + 5
|
||||
|
||||
# Channel
|
||||
channel = entry.get("channel")
|
||||
if channel:
|
||||
painter.setPen(QColor("#cccccc"))
|
||||
font_meta = QFont()
|
||||
font_meta.setPixelSize(12)
|
||||
painter.setFont(font_meta)
|
||||
painter.drawText(text_x, current_y, f"{_('video_info.channel')}: {channel}")
|
||||
current_y += 18
|
||||
|
||||
# Date
|
||||
date_str = entry.get("download_date", "")[:16].replace('T', ' ')
|
||||
if date_str:
|
||||
painter.setPen(QColor("#aaaaaa"))
|
||||
painter.setFont(QFont("Arial", 11))
|
||||
painter.drawText(text_x, current_y, f"{_('history.downloaded_on', date=date_str)}")
|
||||
current_y += 25
|
||||
|
||||
# Badge
|
||||
is_audio = entry.get("is_audio_only", False)
|
||||
badge_text = _("history.audio_download") if is_audio else _("history.video_download")
|
||||
badge_color = QColor("#0066cc") if is_audio else QColor("#c90000")
|
||||
|
||||
badge_rect = QRect(text_x, current_y, 80, 20)
|
||||
painter.setBrush(QBrush(badge_color))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(badge_rect, 3, 3)
|
||||
|
||||
painter.setPen(QColor("white"))
|
||||
font_badge = QFont()
|
||||
font_badge.setBold(True)
|
||||
font_badge.setPixelSize(10)
|
||||
painter.setFont(font_badge)
|
||||
painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge_text)
|
||||
|
||||
# File Size
|
||||
file_size = entry.get("file_size") or 0
|
||||
if file_size > 0:
|
||||
size_str = self.format_file_size(file_size)
|
||||
painter.setPen(QColor("#aaaaaa"))
|
||||
painter.setFont(QFont("Arial", 11))
|
||||
painter.drawText(badge_rect.right() + 10, current_y + 14, size_str)
|
||||
|
||||
# Menu Button
|
||||
menu_rect = self.get_menu_rect(card_rect)
|
||||
|
||||
# Check hover on menu button specifically
|
||||
mouse_pos = QCursor.pos()
|
||||
if option.widget:
|
||||
mouse_pos = option.widget.mapFromGlobal(mouse_pos)
|
||||
|
||||
if menu_rect.contains(mouse_pos):
|
||||
painter.setPen(QColor("#c90000"))
|
||||
else:
|
||||
painter.setPen(QColor("#ffffff"))
|
||||
|
||||
painter.setFont(QFont("Arial", 18, QFont.Weight.Bold))
|
||||
painter.drawText(menu_rect, Qt.AlignmentFlag.AlignCenter, "⋮")
|
||||
|
||||
painter.restore()
|
||||
|
||||
def get_menu_rect(self, card_rect):
|
||||
# Widen the clickable area significantly (60x50) and shift slightly left
|
||||
# to fix "left side not working" issues.
|
||||
w = 60
|
||||
h = 50
|
||||
margin_right = 5
|
||||
margin_top = 5
|
||||
return QRect(
|
||||
card_rect.right() - w - margin_right,
|
||||
card_rect.top() + margin_top,
|
||||
w,
|
||||
h
|
||||
)
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
"""Handle mouse clicks."""
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
# Use same margins as paint to ensure hit consistency
|
||||
card_rect = option.rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
|
||||
menu_rect = self.get_menu_rect(card_rect)
|
||||
|
||||
if menu_rect.contains(event.pos()):
|
||||
self.menu_clicked.emit(index, event.globalPos())
|
||||
return True
|
||||
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
def format_file_size(self, size_bytes: int) -> str:
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} TB"
|
||||
|
||||
|
||||
class HistoryDialog(QDialog):
|
||||
"""Dialog to display and manage download history."""
|
||||
|
||||
redownload_requested = Signal(dict)
|
||||
|
||||
def __init__(self, parent: Optional["YTSageApp"] = None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent
|
||||
|
||||
self.setup_ui()
|
||||
self.show_loading_state()
|
||||
|
||||
QTimer.singleShot(100, self.start_loading_history)
|
||||
|
||||
def setup_ui(self):
|
||||
self.setWindowTitle(_("history.title"))
|
||||
self.resize(850, 600)
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
||||
self.setStyleSheet("""
|
||||
QDialog { background-color: #15181b; }
|
||||
QLabel { color: #ffffff; }
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# --- Header ---
|
||||
header = QHBoxLayout()
|
||||
title = QLabel(_("history.title"))
|
||||
title.setStyleSheet("font-size: 18px; font-weight: bold;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
|
||||
self.clear_btn = QPushButton(_("history.clear_all"))
|
||||
self.clear_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #c90000; color: white; padding: 6px 12px;
|
||||
border: none; border-radius: 4px; font-weight: bold;
|
||||
}
|
||||
QPushButton:hover { background-color: #a50000; }
|
||||
QPushButton:disabled { background-color: #555555; color: #aaaaaa; }
|
||||
""")
|
||||
self.clear_btn.clicked.connect(self.clear_all_history)
|
||||
header.addWidget(self.clear_btn)
|
||||
layout.addLayout(header)
|
||||
|
||||
# --- Search ---
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText(_("history.search_placeholder"))
|
||||
self.search_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
padding: 8px; border: 2px solid #2a2d36; border-radius: 4px;
|
||||
background-color: #1b2021; color: white;
|
||||
}
|
||||
""")
|
||||
self.search_input.textChanged.connect(self.filter_history)
|
||||
layout.addWidget(self.search_input)
|
||||
|
||||
# --- List View ---
|
||||
self.list_view = QListView()
|
||||
self.list_view.setStyleSheet("""
|
||||
QListView {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
QListView::item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
""")
|
||||
self.list_view.setVerticalScrollMode(QListView.ScrollMode.ScrollPerPixel)
|
||||
self.list_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.list_view.setUniformItemSizes(True)
|
||||
self.list_view.setSelectionMode(QListView.SelectionMode.NoSelection)
|
||||
self.list_view.setMouseTracking(True)
|
||||
self.list_view.setResizeMode(QListView.ResizeMode.Adjust)
|
||||
|
||||
self.model = HistoryModel([], self)
|
||||
self.list_view.setModel(self.model)
|
||||
|
||||
self.delegate = HistoryDelegate(self.list_view)
|
||||
self.delegate.menu_clicked.connect(self.show_context_menu)
|
||||
self.list_view.setItemDelegate(self.delegate)
|
||||
|
||||
self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.list_view.customContextMenuRequested.connect(self.on_context_menu_requested)
|
||||
|
||||
layout.addWidget(self.list_view)
|
||||
|
||||
# --- Status ---
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
def show_loading_state(self):
|
||||
self.status_label.setText(_("history.loading"))
|
||||
self.clear_btn.setEnabled(False)
|
||||
|
||||
def start_loading_history(self):
|
||||
self.loader_thread = HistoryLoaderThread()
|
||||
self.loader_thread.entries_loaded.connect(self.on_entries_loaded)
|
||||
self.loader_thread.thumbnail_loaded.connect(self.on_thumbnail_loaded)
|
||||
self.loader_thread.start()
|
||||
|
||||
def on_entries_loaded(self, entries):
|
||||
self.model.update_entries(entries)
|
||||
|
||||
count = len(entries)
|
||||
if count == 0:
|
||||
self.status_label.setText(_("history.no_history"))
|
||||
self.clear_btn.setEnabled(False)
|
||||
else:
|
||||
self.status_label.setText(_("history.entries_count", count=count))
|
||||
self.clear_btn.setEnabled(True)
|
||||
|
||||
self.load_cached_thumbnails(entries)
|
||||
|
||||
def load_cached_thumbnails(self, entries):
|
||||
for entry in entries:
|
||||
eid = entry.get("id")
|
||||
if not eid: continue
|
||||
|
||||
p = APP_THUMBNAILS_DIR / f"{eid}.jpg"
|
||||
if p.exists():
|
||||
pix = QPixmap(str(p))
|
||||
if not pix.isNull():
|
||||
self.model.thumbnail_cache[eid] = pix
|
||||
|
||||
def on_thumbnail_loaded(self, entry_id, data_bytes):
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(data_bytes)
|
||||
if not pixmap.isNull():
|
||||
self.model.update_thumbnail(entry_id, pixmap)
|
||||
|
||||
def on_context_menu_requested(self, pos):
|
||||
index = self.list_view.indexAt(pos)
|
||||
if index.isValid():
|
||||
global_pos = self.list_view.mapToGlobal(pos)
|
||||
self.show_context_menu(index, global_pos)
|
||||
|
||||
def show_context_menu(self, index, global_pos):
|
||||
entry = index.data(HistoryModel.EntryRole)
|
||||
if not entry: return
|
||||
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("""
|
||||
QMenu {
|
||||
background-color: #2a2d36; border: 1px solid #3a3d46; color: white;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 8px 20px;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #c90000;
|
||||
}
|
||||
""")
|
||||
|
||||
act_open = menu.addAction("📁 " + _("history.open_location"))
|
||||
act_redownload = menu.addAction("⬇️ " + _("history.redownload"))
|
||||
menu.addSeparator()
|
||||
act_remove = menu.addAction("🗑️ " + _("history.remove"))
|
||||
|
||||
action = menu.exec(global_pos)
|
||||
|
||||
if action == act_open:
|
||||
self.open_file_location(entry)
|
||||
elif action == act_redownload:
|
||||
self.redownload_entry(entry)
|
||||
elif action == act_remove:
|
||||
self.remove_entry(index)
|
||||
|
||||
def open_file_location(self, entry):
|
||||
path_str = entry.get("file_path", "")
|
||||
if not path_str: return
|
||||
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
QMessageBox.warning(self, _("main_ui.error_title"), _("history.file_not_found_message", path=path))
|
||||
return
|
||||
|
||||
try:
|
||||
if os.name == "nt":
|
||||
subprocess.run(['explorer', '/select,', str(path)], creationflags=SUBPROCESS_CREATIONFLAGS)
|
||||
elif subprocess.sys.platform == "darwin":
|
||||
subprocess.run(['open', '-R', str(path)])
|
||||
else:
|
||||
folder_path = path.parent
|
||||
subprocess.run(['xdg-open', str(folder_path)])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file: {e}")
|
||||
|
||||
def redownload_entry(self, entry):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.redownload_confirm_title"),
|
||||
_("history.redownload_confirm_message", title=entry.get("title")),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.redownload_requested.emit(entry)
|
||||
self.accept()
|
||||
|
||||
def remove_entry(self, index):
|
||||
entry = index.data(HistoryModel.EntryRole)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.remove_confirm_title"),
|
||||
_("history.remove_confirm_message", title=entry.get("title")),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
if HistoryManager.remove_entry(entry.get("id")):
|
||||
self.model.remove_item(index.row())
|
||||
self.status_label.setText(
|
||||
_("history.entries_count", count=self.model.rowCount())
|
||||
)
|
||||
|
||||
def clear_all_history(self):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.clear_confirm_title"),
|
||||
_("history.clear_confirm_message"),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
HistoryManager.clear_history()
|
||||
self.model.update_entries([])
|
||||
self.clear_btn.setEnabled(False)
|
||||
self.status_label.setText(_("history.no_history"))
|
||||
|
||||
def filter_history(self, query):
|
||||
results = HistoryManager.search_entries(query)
|
||||
self.model.update_entries(results)
|
||||
self.load_cached_thumbnails(results)
|
||||
@@ -0,0 +1,730 @@
|
||||
"""
|
||||
Selection dialogs for YTSage application.
|
||||
Contains dialogs for selecting subtitles, playlist videos, and SponsorBlock categories.
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...utils.ytsage_localization import _
|
||||
|
||||
|
||||
class SubtitleSelectionDialog(QDialog):
|
||||
def __init__(self, available_manual, available_auto, previously_selected, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("dialogs.select_subtitles"))
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
self.available_manual = available_manual
|
||||
self.available_auto = available_auto
|
||||
self.previously_selected = set(previously_selected) # Use a set for quick lookups
|
||||
self.selected_subtitles = list(previously_selected) # Initialize with previous selection
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# Filter input
|
||||
self.filter_input = QLineEdit()
|
||||
self.filter_input.setPlaceholderText(_("dialogs.filter_languages_placeholder"))
|
||||
self.filter_input.textChanged.connect(self.filter_list)
|
||||
self.filter_input.setStyleSheet(
|
||||
"""
|
||||
QLineEdit {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #ff0000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
layout.addWidget(self.filter_input)
|
||||
|
||||
# Scroll Area for the list
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
|
||||
layout.addWidget(scroll_area)
|
||||
|
||||
# Container widget for list items (needed for scroll area)
|
||||
self.list_container = QWidget()
|
||||
self.list_layout = QVBoxLayout(self.list_container)
|
||||
self.list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.list_layout.setSpacing(2) # Compact spacing
|
||||
self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
|
||||
scroll_area.setWidget(self.list_container)
|
||||
|
||||
# Populate the list initially
|
||||
self.populate_list()
|
||||
|
||||
# OK and Cancel buttons
|
||||
button_box = QDialogButtonBox()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
# Style the buttons
|
||||
for button in button_box.buttons():
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px 15px; /* Adjust padding */
|
||||
min-height: 30px; /* Ensure consistent height */
|
||||
color: white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #555555;
|
||||
}
|
||||
"""
|
||||
)
|
||||
# Style the OK button specifically if needed
|
||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||
button.setStyleSheet(
|
||||
button.styleSheet()
|
||||
+ "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }"
|
||||
)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def populate_list(self, filter_text="") -> None:
|
||||
# Clear existing checkboxes from layout
|
||||
while self.list_layout.count():
|
||||
item = self.list_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
|
||||
filter_text = filter_text.lower()
|
||||
combined_subs = {}
|
||||
|
||||
# Add manual subs
|
||||
for lang_code, sub_info in self.available_manual.items():
|
||||
if not filter_text or filter_text in lang_code.lower():
|
||||
combined_subs[lang_code] = f"{lang_code} - Manual"
|
||||
|
||||
# Add auto subs (only if no manual exists and matches filter)
|
||||
for lang_code, sub_info in self.available_auto.items():
|
||||
if lang_code not in combined_subs: # Don't overwrite manual
|
||||
if not filter_text or filter_text in lang_code.lower():
|
||||
combined_subs[lang_code] = f"{lang_code} - Auto-generated"
|
||||
|
||||
if not combined_subs:
|
||||
matching_text = _("dialogs.matching") if filter_text else ""
|
||||
no_subs_label = QLabel(_("dialogs.no_subtitles_available") + (f" {matching_text} '{filter_text}'" if filter_text else ""))
|
||||
no_subs_label.setStyleSheet("color: #aaaaaa; padding: 10px;")
|
||||
self.list_layout.addWidget(no_subs_label)
|
||||
return
|
||||
|
||||
# Sort by language code
|
||||
sorted_lang_codes = sorted(combined_subs.keys())
|
||||
|
||||
for lang_code in sorted_lang_codes:
|
||||
item_text = combined_subs[lang_code]
|
||||
checkbox = QCheckBox(item_text)
|
||||
checkbox.setProperty("subtitle_id", item_text) # Store the identifier
|
||||
checkbox.setChecked(item_text in self.previously_selected) # Check if previously selected
|
||||
checkbox.stateChanged.connect(self.update_selection)
|
||||
checkbox.setStyleSheet(
|
||||
"""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px; /* Square checkboxes */
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.list_layout.addWidget(checkbox)
|
||||
|
||||
self.list_layout.addStretch() # Pushes items up if list is short
|
||||
|
||||
def filter_list(self) -> None:
|
||||
self.populate_list(self.filter_input.text())
|
||||
|
||||
def update_selection(self, state) -> None:
|
||||
sender = self.sender()
|
||||
subtitle_id = sender.property("subtitle_id")
|
||||
if state == Qt.CheckState.Checked.value:
|
||||
if subtitle_id not in self.previously_selected:
|
||||
self.previously_selected.add(subtitle_id)
|
||||
else:
|
||||
if subtitle_id in self.previously_selected:
|
||||
self.previously_selected.remove(subtitle_id)
|
||||
|
||||
def get_selected_subtitles(self) -> list:
|
||||
# Return the final set as a list
|
||||
return list(self.previously_selected)
|
||||
|
||||
def accept(self) -> None:
|
||||
# Update the final list before closing
|
||||
self.selected_subtitles = self.get_selected_subtitles()
|
||||
super().accept()
|
||||
|
||||
|
||||
class PlaylistSelectionDialog(QDialog):
|
||||
def __init__(self, playlist_entries, previously_selected_string, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("playlist.select_videos_title"))
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(400) # Allow more vertical space
|
||||
|
||||
self.playlist_entries = playlist_entries
|
||||
self.checkboxes = []
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# Filter Input
|
||||
self.filter_input = QLineEdit()
|
||||
self.filter_input.setPlaceholderText(_("dialogs.filter_playlist_placeholder"))
|
||||
self.filter_input.textChanged.connect(self.filter_list)
|
||||
self.filter_input.setStyleSheet(
|
||||
"""
|
||||
QLineEdit {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #ff0000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
main_layout.addWidget(self.filter_input)
|
||||
|
||||
# Top buttons (Select/Deselect All)
|
||||
button_layout = QHBoxLayout()
|
||||
select_all_btn = QPushButton(_("buttons.select_all"))
|
||||
deselect_all_btn = QPushButton(_("buttons.deselect_all"))
|
||||
select_all_btn.clicked.connect(self._select_all)
|
||||
deselect_all_btn.clicked.connect(self._deselect_all)
|
||||
# Style the buttons to match the subtitle dialog
|
||||
select_all_btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px 15px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #555555;
|
||||
}
|
||||
"""
|
||||
)
|
||||
deselect_all_btn.setStyleSheet(select_all_btn.styleSheet())
|
||||
button_layout.addWidget(select_all_btn)
|
||||
button_layout.addWidget(deselect_all_btn)
|
||||
button_layout.addStretch()
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
# Scrollable area for checkboxes
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
|
||||
scroll_widget = QWidget()
|
||||
self.list_layout = QVBoxLayout(scroll_widget) # Layout for checkboxes
|
||||
self.list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.list_layout.setSpacing(2) # Compact spacing
|
||||
self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
|
||||
scroll_area.setWidget(scroll_widget)
|
||||
main_layout.addWidget(scroll_area)
|
||||
|
||||
# Populate checkboxes
|
||||
self._populate_list(previously_selected_string)
|
||||
|
||||
# Dialog buttons (OK/Cancel)
|
||||
button_box = QDialogButtonBox()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
# Style the buttons to match subtitle dialog
|
||||
for button in button_box.buttons():
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px 15px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #555555;
|
||||
}
|
||||
"""
|
||||
)
|
||||
# Style the OK button specifically if needed
|
||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||
button.setStyleSheet(
|
||||
button.styleSheet()
|
||||
+ "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }"
|
||||
)
|
||||
|
||||
main_layout.addWidget(button_box)
|
||||
|
||||
# Apply styling to match subtitle dialog
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog { background-color: #15181b; }
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
QWidget { background-color: #15181b; }
|
||||
"""
|
||||
)
|
||||
|
||||
def _parse_selection_string(self, selection_string) -> set:
|
||||
"""Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices."""
|
||||
selected_indices = set()
|
||||
if not selection_string:
|
||||
# If no previous selection, assume all are selected initially
|
||||
return set(range(1, len(self.playlist_entries) + 1))
|
||||
|
||||
parts = selection_string.split(",")
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if "-" in part:
|
||||
try:
|
||||
start, end = map(int, part.split("-"))
|
||||
if start <= end:
|
||||
selected_indices.update(range(start, end + 1))
|
||||
except ValueError:
|
||||
pass # Ignore invalid ranges
|
||||
else:
|
||||
try:
|
||||
selected_indices.add(int(part))
|
||||
except ValueError:
|
||||
pass # Ignore invalid numbers
|
||||
return selected_indices
|
||||
|
||||
def filter_list(self, text: str) -> None:
|
||||
"""Filter the list of checkboxes based on title."""
|
||||
text = text.lower()
|
||||
for checkbox in self.checkboxes:
|
||||
title = (checkbox.property("full_title") or "").lower()
|
||||
checkbox.setVisible(text in title)
|
||||
|
||||
def _populate_list(self, previously_selected_string) -> None:
|
||||
"""Populates the scroll area with checkboxes for each video."""
|
||||
selected_indices = self._parse_selection_string(previously_selected_string)
|
||||
|
||||
# Clear existing checkboxes if any (e.g., if repopulating)
|
||||
while self.list_layout.count():
|
||||
child = self.list_layout.takeAt(0)
|
||||
if child.widget():
|
||||
child.widget().deleteLater()
|
||||
self.checkboxes.clear()
|
||||
|
||||
for index, entry in enumerate(self.playlist_entries):
|
||||
if not entry:
|
||||
continue # Skip None entries if yt-dlp returns them
|
||||
|
||||
video_index = index + 1 # yt-dlp uses 1-based indexing
|
||||
title = entry.get("title", f"Video {video_index}")
|
||||
|
||||
# Format duration
|
||||
duration = entry.get("duration")
|
||||
duration_str = ""
|
||||
if duration:
|
||||
try:
|
||||
m, s = divmod(int(duration), 60)
|
||||
h, m = divmod(m, 60)
|
||||
if h > 0:
|
||||
duration_str = f" [{h}:{m:02d}:{s:02d}]"
|
||||
else:
|
||||
duration_str = f" [{m:02d}:{s:02d}]"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Shorten title if too long but keep enough space for duration
|
||||
max_len = 65
|
||||
display_title = (title[:max_len] + "...") if len(title) > max_len + 3 else title
|
||||
|
||||
checkbox = QCheckBox(f"{video_index}. {display_title}{duration_str}")
|
||||
checkbox.setChecked(video_index in selected_indices)
|
||||
checkbox.setProperty("video_index", video_index) # Store index
|
||||
checkbox.setProperty("full_title", title) # Store full title for filtering
|
||||
checkbox.setStyleSheet(
|
||||
"""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.list_layout.addWidget(checkbox)
|
||||
self.checkboxes.append(checkbox)
|
||||
self.list_layout.addStretch() # Push checkboxes to the top
|
||||
|
||||
def _select_all(self) -> None:
|
||||
for checkbox in self.checkboxes:
|
||||
checkbox.setChecked(True)
|
||||
|
||||
def _deselect_all(self) -> None:
|
||||
for checkbox in self.checkboxes:
|
||||
checkbox.setChecked(False)
|
||||
|
||||
def _condense_indices(self, indices: list[int]) -> str:
|
||||
"""Condenses a list of 1-based indices into a yt-dlp selection string."""
|
||||
if not indices:
|
||||
return ""
|
||||
|
||||
# Remove duplicates and sort in one step
|
||||
indices = sorted(set(indices))
|
||||
|
||||
ranges = []
|
||||
start = end = indices[0]
|
||||
|
||||
for num in indices[1:]:
|
||||
if num == end + 1:
|
||||
end = num
|
||||
else:
|
||||
ranges.append(f"{start}-{end}" if start != end else str(start))
|
||||
start = end = num
|
||||
|
||||
# Append the last range
|
||||
ranges.append(f"{start}-{end}" if start != end else str(start))
|
||||
|
||||
return ",".join(ranges)
|
||||
|
||||
def get_selected_items_string(self) -> str | None:
|
||||
"""Returns the selection string based on checked boxes."""
|
||||
selected_indices = [cb.property("video_index") for cb in self.checkboxes if cb.isChecked()]
|
||||
|
||||
# Check if all items are selected
|
||||
if len(selected_indices) == len(self.playlist_entries):
|
||||
return None # yt-dlp default is all items, so return None or empty string
|
||||
|
||||
return self._condense_indices(selected_indices)
|
||||
|
||||
|
||||
class SponsorBlockCategoryDialog(QDialog):
|
||||
"""Dialog for selecting SponsorBlock categories to remove from videos."""
|
||||
|
||||
# Default SponsorBlock categories with descriptions
|
||||
SPONSORBLOCK_CATEGORIES = {
|
||||
"sponsor": {
|
||||
"name_key": "sponsorblock.sponsor",
|
||||
"description_key": "sponsorblock.sponsor_desc",
|
||||
"default": True,
|
||||
},
|
||||
"selfpromo": {
|
||||
"name_key": "sponsorblock.selfpromo",
|
||||
"description_key": "sponsorblock.selfpromo_desc",
|
||||
"default": True,
|
||||
},
|
||||
"interaction": {
|
||||
"name_key": "sponsorblock.interaction",
|
||||
"description_key": "sponsorblock.interaction_desc",
|
||||
"default": True,
|
||||
},
|
||||
"intro": {
|
||||
"name_key": "sponsorblock.intro",
|
||||
"description_key": "sponsorblock.intro_desc",
|
||||
"default": False,
|
||||
},
|
||||
"outro": {
|
||||
"name_key": "sponsorblock.outro",
|
||||
"description_key": "sponsorblock.outro_desc",
|
||||
"default": False,
|
||||
},
|
||||
"preview": {
|
||||
"name_key": "sponsorblock.preview",
|
||||
"description_key": "sponsorblock.preview_desc",
|
||||
"default": False,
|
||||
},
|
||||
"music_offtopic": {
|
||||
"name_key": "sponsorblock.music_offtopic",
|
||||
"description_key": "sponsorblock.music_offtopic_desc",
|
||||
"default": False,
|
||||
},
|
||||
"filler": {
|
||||
"name_key": "sponsorblock.filler",
|
||||
"description_key": "sponsorblock.filler_desc",
|
||||
"default": False,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, previously_selected=None, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("dialogs.sponsorblock_categories"))
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(400)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent:
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
|
||||
self.previously_selected = set(previously_selected) if previously_selected else set()
|
||||
self.checkboxes = {}
|
||||
|
||||
self.init_ui()
|
||||
self.apply_styling()
|
||||
|
||||
def init_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Title and description
|
||||
title_label = QLabel(_("dialogs.sponsorblock_categories"))
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
desc_label = QLabel(_("dialogs.sponsorblock_description"))
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Scroll area for categories
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setStyleSheet("QScrollArea { border: none; }")
|
||||
|
||||
scroll_widget = QWidget()
|
||||
scroll_layout = QVBoxLayout(scroll_widget)
|
||||
scroll_layout.setContentsMargins(10, 0, 10, 0)
|
||||
scroll_layout.setSpacing(8)
|
||||
|
||||
# Add category checkboxes
|
||||
for category_id, category_info in self.SPONSORBLOCK_CATEGORIES.items():
|
||||
# Create a container widget for each category
|
||||
category_widget = QWidget()
|
||||
category_layout = QVBoxLayout(category_widget)
|
||||
category_layout.setContentsMargins(0, 0, 0, 0)
|
||||
category_layout.setSpacing(2)
|
||||
|
||||
# Create checkbox with localized name
|
||||
checkbox = QCheckBox(_(category_info["name_key"]))
|
||||
checkbox.setProperty("category_id", category_id)
|
||||
|
||||
# Determine if this category should be checked
|
||||
if self.previously_selected:
|
||||
# Use previously selected categories
|
||||
is_checked = category_id in self.previously_selected
|
||||
else:
|
||||
# Use default values for first time
|
||||
is_checked = category_info["default"]
|
||||
|
||||
checkbox.setChecked(is_checked)
|
||||
|
||||
checkbox.setStyleSheet(
|
||||
"""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 4px;
|
||||
spacing: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Create description label with localized text
|
||||
desc_label = QLabel(_(category_info["description_key"]))
|
||||
desc_label.setStyleSheet("color: #aaaaaa; font-size: 11px; margin-left: 28px; margin-bottom: 8px;")
|
||||
desc_label.setWordWrap(True)
|
||||
|
||||
category_layout.addWidget(checkbox)
|
||||
category_layout.addWidget(desc_label)
|
||||
|
||||
self.checkboxes[category_id] = checkbox
|
||||
scroll_layout.addWidget(category_widget)
|
||||
|
||||
scroll_layout.addStretch()
|
||||
scroll_area.setWidget(scroll_widget)
|
||||
layout.addWidget(scroll_area)
|
||||
|
||||
# Quick selection buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
select_defaults_btn = QPushButton(_("buttons.select_defaults"))
|
||||
select_defaults_btn.clicked.connect(self.select_defaults)
|
||||
select_defaults_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
select_all_btn = QPushButton(_("buttons.select_all"))
|
||||
select_all_btn.clicked.connect(self.select_all)
|
||||
select_all_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
deselect_all_btn = QPushButton(_("buttons.deselect_all"))
|
||||
deselect_all_btn.clicked.connect(self.deselect_all)
|
||||
deselect_all_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
button_layout.addWidget(select_defaults_btn)
|
||||
button_layout.addWidget(select_all_btn)
|
||||
button_layout.addWidget(deselect_all_btn)
|
||||
button_layout.addStretch()
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Dialog buttons
|
||||
button_box = QDialogButtonBox()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
# Style the dialog buttons
|
||||
for button in button_box.buttons():
|
||||
button.setStyleSheet(self._get_button_style())
|
||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||
button.setStyleSheet(
|
||||
button.styleSheet()
|
||||
+ "QPushButton { background-color: #ff0000; border-color: #cc0000; } "
|
||||
+ "QPushButton:hover { background-color: #cc0000; }"
|
||||
)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def _get_button_style(self) -> str:
|
||||
"""Returns the standard button style for this dialog."""
|
||||
return """
|
||||
QPushButton {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px 15px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #555555;
|
||||
}
|
||||
"""
|
||||
|
||||
def apply_styling(self) -> None:
|
||||
"""Apply the dialog styling to match the rest of the application."""
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QWidget {
|
||||
background-color: #15181b;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
def select_defaults(self) -> None:
|
||||
"""Select only the default categories."""
|
||||
for category_id, checkbox in self.checkboxes.items():
|
||||
default_value = self.SPONSORBLOCK_CATEGORIES[category_id]["default"]
|
||||
checkbox.setChecked(default_value)
|
||||
|
||||
def select_all(self) -> None:
|
||||
"""Select all categories."""
|
||||
for checkbox in self.checkboxes.values():
|
||||
checkbox.setChecked(True)
|
||||
|
||||
def deselect_all(self) -> None:
|
||||
"""Deselect all categories."""
|
||||
for checkbox in self.checkboxes.values():
|
||||
checkbox.setChecked(False)
|
||||
|
||||
def get_selected_categories(self) -> list:
|
||||
"""Returns a list of selected category IDs."""
|
||||
selected = []
|
||||
for category_id, checkbox in self.checkboxes.items():
|
||||
if checkbox.isChecked():
|
||||
selected.append(category_id)
|
||||
return selected
|
||||
|
||||
def get_selected_categories_string(self) -> str:
|
||||
"""Returns a comma-separated string of selected categories for yt-dlp."""
|
||||
selected = self.get_selected_categories()
|
||||
return ",".join(selected) if selected else ""
|
||||
@@ -0,0 +1,989 @@
|
||||
"""
|
||||
Settings-related dialogs for YTSage application.
|
||||
Contains dialogs for configuring download settings.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from packaging import version as version_parser
|
||||
from PySide6.QtCore import Qt, QTimer, QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..ytsage_smooth_tab_widget import SmoothTabWidget
|
||||
from ...utils.ytsage_logger import logger
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_config_manager import ConfigManager
|
||||
from ...utils.ytsage_constants import APP_LOG_DIR
|
||||
|
||||
|
||||
class DownloadSettingsDialog(QDialog):
|
||||
def __init__(self, current_path, current_limit, current_unit_index, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("settings.title"))
|
||||
self.setMinimumWidth(550)
|
||||
self.setMinimumHeight(400)
|
||||
self.current_path = current_path
|
||||
self.current_limit = current_limit if current_limit is not None else ""
|
||||
self.current_unit_index = current_unit_index
|
||||
|
||||
# Apply main app styling
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QFrame#tabContent {
|
||||
border: 1px solid #3d3d3d;
|
||||
background-color: #15181b;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #c90000;
|
||||
}
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #2a2d36;
|
||||
}
|
||||
QWidget {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QGroupBox {
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
margin-top: 1.5ex;
|
||||
color: #ffffff;
|
||||
padding: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
padding: 0 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
selection-background-color: #c90000;
|
||||
selection-color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
color: #999999;
|
||||
}
|
||||
QCheckBox {
|
||||
spacing: 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QRadioButton {
|
||||
spacing: 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QRadioButton::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QComboBox {
|
||||
padding: 8px;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
min-width: 150px;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 20px;
|
||||
}
|
||||
QComboBox::down-arrow {
|
||||
border: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
border: 1px solid #3d3d3d;
|
||||
selection-background-color: #c90000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Create tab widget
|
||||
self.tab_widget = SmoothTabWidget()
|
||||
layout.addWidget(self.tab_widget)
|
||||
|
||||
# === General Tab ===
|
||||
general_tab = QWidget()
|
||||
general_layout = QVBoxLayout(general_tab)
|
||||
|
||||
# --- Download Path Section ---
|
||||
path_group_box = QGroupBox(_("settings.download_path"))
|
||||
path_layout = QVBoxLayout()
|
||||
|
||||
self.path_display = QLabel(str(self.current_path))
|
||||
self.path_display.setWordWrap(True)
|
||||
self.path_display.setStyleSheet(
|
||||
"QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }"
|
||||
)
|
||||
path_layout.addWidget(self.path_display)
|
||||
|
||||
|
||||
browse_button = QPushButton(_("settings.browse"))
|
||||
browse_button.clicked.connect(self.browse_new_path)
|
||||
path_layout.addWidget(browse_button)
|
||||
|
||||
path_group_box.setLayout(path_layout)
|
||||
general_layout.addWidget(path_group_box)
|
||||
|
||||
# --- Speed Limit Section ---
|
||||
speed_group_box = QGroupBox(_("settings.speed_limit"))
|
||||
speed_layout = QHBoxLayout()
|
||||
|
||||
self.speed_limit_input = QLineEdit(str(self.current_limit))
|
||||
self.speed_limit_input.setPlaceholderText(_("settings.speed_limit_placeholder"))
|
||||
speed_layout.addWidget(self.speed_limit_input)
|
||||
|
||||
self.speed_limit_unit = QComboBox()
|
||||
self.speed_limit_unit.addItems(["KB/s", "MB/s"])
|
||||
self.speed_limit_unit.setCurrentIndex(self.current_unit_index)
|
||||
speed_layout.addWidget(self.speed_limit_unit)
|
||||
|
||||
speed_group_box.setLayout(speed_layout)
|
||||
general_layout.addWidget(speed_group_box)
|
||||
|
||||
# --- Connections Section ---
|
||||
connections_group_box = QGroupBox(_("settings.concurrent_fragments", default="Concurrent Connections"))
|
||||
connections_layout = QVBoxLayout()
|
||||
|
||||
self.connections_enabled = ConfigManager.get("concurrent_fragments") or 1
|
||||
|
||||
connections_spin_layout = QHBoxLayout()
|
||||
self.connections_input = QComboBox()
|
||||
self.connections_input.addItems([str(i) for i in range(1, 21)])
|
||||
self.connections_input.setCurrentText(str(self.connections_enabled))
|
||||
connections_spin_layout.addWidget(self.connections_input)
|
||||
connections_spin_layout.addStretch()
|
||||
connections_layout.addLayout(connections_spin_layout)
|
||||
|
||||
connections_help_label = QLabel(_("settings.concurrent_fragments_help", default="Number of connections per download. Higher values bypass throttling but may cause temporary blocks if set too high. Default: 1."))
|
||||
connections_help_label.setWordWrap(True)
|
||||
connections_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||
connections_layout.addWidget(connections_help_label)
|
||||
|
||||
connections_group_box.setLayout(connections_layout)
|
||||
general_layout.addWidget(connections_group_box)
|
||||
|
||||
# --- Generic Mode Section ---
|
||||
generic_mode_group_box = QGroupBox(_("settings.generic_mode"))
|
||||
generic_mode_layout = QVBoxLayout()
|
||||
|
||||
generic_val = ConfigManager.get("generic_mode")
|
||||
self.generic_mode_enabled = generic_val if generic_val is not None else True
|
||||
|
||||
self.generic_mode_checkbox = QCheckBox(_("settings.enable_generic_mode"))
|
||||
self.generic_mode_checkbox.setChecked(self.generic_mode_enabled)
|
||||
generic_mode_layout.addWidget(self.generic_mode_checkbox)
|
||||
|
||||
generic_mode_help_label = QLabel(_("settings.generic_mode_help"))
|
||||
generic_mode_help_label.setWordWrap(True)
|
||||
generic_mode_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||
generic_mode_layout.addWidget(generic_mode_help_label)
|
||||
|
||||
generic_mode_group_box.setLayout(generic_mode_layout)
|
||||
general_layout.addWidget(generic_mode_group_box)
|
||||
general_layout.addStretch()
|
||||
|
||||
# === Format Tab ===
|
||||
format_tab = QWidget()
|
||||
format_layout = QVBoxLayout(format_tab)
|
||||
|
||||
# --- Output Format Settings Section ---
|
||||
output_format_group_box = QGroupBox(_("settings.output_format_settings"))
|
||||
output_format_layout = QVBoxLayout()
|
||||
|
||||
# Load current format settings from ConfigManager
|
||||
self.force_format_enabled = ConfigManager.get("force_output_format") or False
|
||||
self.preferred_format_value = ConfigManager.get("preferred_output_format") or "mp4"
|
||||
|
||||
# Enable/Disable force output format checkbox
|
||||
self.force_format_checkbox = QCheckBox(_("settings.force_output_format"))
|
||||
self.force_format_checkbox.setChecked(self.force_format_enabled)
|
||||
output_format_layout.addWidget(self.force_format_checkbox)
|
||||
|
||||
# Format selection layout
|
||||
format_select_layout = QHBoxLayout()
|
||||
format_label = QLabel(_("settings.preferred_format"))
|
||||
format_label.setStyleSheet("color: #ffffff; margin-top: 5px;")
|
||||
format_select_layout.addWidget(format_label)
|
||||
|
||||
self.format_combo = QComboBox()
|
||||
self.format_combo.addItems([
|
||||
_("settings.format_mp4"),
|
||||
_("settings.format_webm"),
|
||||
_("settings.format_mkv")
|
||||
])
|
||||
# Set current selection based on saved format
|
||||
format_index_map = {"mp4": 0, "webm": 1, "mkv": 2}
|
||||
self.format_combo.setCurrentIndex(format_index_map.get(self.preferred_format_value, 0))
|
||||
format_select_layout.addWidget(self.format_combo)
|
||||
format_select_layout.addStretch()
|
||||
output_format_layout.addLayout(format_select_layout)
|
||||
|
||||
# Help text
|
||||
help_label = QLabel(_("settings.force_format_help"))
|
||||
help_label.setWordWrap(True)
|
||||
help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||
output_format_layout.addWidget(help_label)
|
||||
|
||||
output_format_group_box.setLayout(output_format_layout)
|
||||
format_layout.addWidget(output_format_group_box)
|
||||
|
||||
# --- Audio Format Settings Section (for audio-only downloads) ---
|
||||
audio_format_group_box = QGroupBox(_("settings.audio_format_settings"))
|
||||
audio_format_layout = QVBoxLayout()
|
||||
|
||||
# Load current audio format settings from ConfigManager
|
||||
self.force_audio_format_enabled = ConfigManager.get("force_audio_format") or False
|
||||
self.preferred_audio_format_value = ConfigManager.get("preferred_audio_format") or "best"
|
||||
self.audio_normalization_enabled = ConfigManager.get("audio_normalization") or False
|
||||
|
||||
# Enable/Disable force audio format checkbox
|
||||
self.force_audio_format_checkbox = QCheckBox(_("settings.force_audio_format"))
|
||||
self.force_audio_format_checkbox.setChecked(self.force_audio_format_enabled)
|
||||
audio_format_layout.addWidget(self.force_audio_format_checkbox)
|
||||
|
||||
# Enable/Disable audio normalization checkbox
|
||||
self.audio_normalization_checkbox = QCheckBox(_("settings.audio_normalization", default="Audio Normalization"))
|
||||
self.audio_normalization_checkbox.setChecked(self.audio_normalization_enabled)
|
||||
audio_format_layout.addWidget(self.audio_normalization_checkbox)
|
||||
|
||||
# Audio normalization help text
|
||||
audio_norm_help_label = QLabel(_("settings.audio_normalization_help", default="When enabled, audio will be normalized using EBU R128 standard."))
|
||||
audio_norm_help_label.setWordWrap(True)
|
||||
audio_norm_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||
audio_format_layout.addWidget(audio_norm_help_label)
|
||||
|
||||
# Audio format selection layout
|
||||
audio_format_select_layout = QHBoxLayout()
|
||||
audio_format_label = QLabel(_("settings.preferred_audio_format"))
|
||||
audio_format_label.setStyleSheet("color: #ffffff; margin-top: 5px;")
|
||||
audio_format_select_layout.addWidget(audio_format_label)
|
||||
|
||||
self.audio_format_combo = QComboBox()
|
||||
self.audio_format_combo.addItems([
|
||||
_("settings.audio_format_best"),
|
||||
_("settings.audio_format_aac"),
|
||||
_("settings.audio_format_mp3"),
|
||||
_("settings.audio_format_flac"),
|
||||
_("settings.audio_format_wav"),
|
||||
_("settings.audio_format_opus"),
|
||||
_("settings.audio_format_m4a"),
|
||||
_("settings.audio_format_vorbis")
|
||||
])
|
||||
# Set current selection based on saved format
|
||||
audio_format_index_map = {"best": 0, "aac": 1, "mp3": 2, "flac": 3, "wav": 4, "opus": 5, "m4a": 6, "vorbis": 7}
|
||||
self.audio_format_combo.setCurrentIndex(audio_format_index_map.get(self.preferred_audio_format_value, 0))
|
||||
audio_format_select_layout.addWidget(self.audio_format_combo)
|
||||
audio_format_select_layout.addStretch()
|
||||
audio_format_layout.addLayout(audio_format_select_layout)
|
||||
|
||||
# Help text for audio format
|
||||
audio_help_label = QLabel(_("settings.force_audio_format_help"))
|
||||
audio_help_label.setWordWrap(True)
|
||||
audio_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||
audio_format_layout.addWidget(audio_help_label)
|
||||
|
||||
# Connect signals
|
||||
self.audio_normalization_checkbox.stateChanged.connect(self._on_audio_normalization_toggled)
|
||||
self.force_audio_format_checkbox.stateChanged.connect(self._on_force_audio_format_toggled)
|
||||
|
||||
audio_format_group_box.setLayout(audio_format_layout)
|
||||
format_layout.addWidget(audio_format_group_box)
|
||||
|
||||
# --- Default Quality and Subtitles Section ---
|
||||
defaults_group_box = QGroupBox(_("settings.defaults_settings", default="Default Selection Settings"))
|
||||
defaults_layout = QVBoxLayout()
|
||||
|
||||
# Default Video Quality
|
||||
vid_qual_layout = QHBoxLayout()
|
||||
vid_qual_label = QLabel(_("settings.default_video_quality", default="Default Video Resolution (Height):"))
|
||||
vid_qual_label.setStyleSheet("color: #ffffff; margin-top: 5px;")
|
||||
self.default_vid_qual_input = QLineEdit(str(ConfigManager.get("default_video_quality") or ""))
|
||||
self.default_vid_qual_input.setPlaceholderText("e.g. 1080 or 720")
|
||||
vid_qual_layout.addWidget(vid_qual_label)
|
||||
vid_qual_layout.addWidget(self.default_vid_qual_input)
|
||||
defaults_layout.addLayout(vid_qual_layout)
|
||||
|
||||
# Default Subtitles
|
||||
sub_layout = QHBoxLayout()
|
||||
sub_label = QLabel(_("settings.default_subtitle_language", default="Default Subtitle Language(s):"))
|
||||
sub_label.setStyleSheet("color: #ffffff; margin-top: 5px;")
|
||||
self.default_sub_input = QLineEdit(ConfigManager.get("default_subtitle_language") or "")
|
||||
self.default_sub_input.setPlaceholderText("e.g. en, es")
|
||||
sub_layout.addWidget(sub_label)
|
||||
sub_layout.addWidget(self.default_sub_input)
|
||||
defaults_layout.addLayout(sub_layout)
|
||||
|
||||
defaults_help = QLabel(_("settings.defaults_help", default="Set your preferred video height and subtitle languages (comma-separated). They will be auto-selected if available."))
|
||||
defaults_help.setWordWrap(True)
|
||||
defaults_help.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||
defaults_layout.addWidget(defaults_help)
|
||||
|
||||
defaults_group_box.setLayout(defaults_layout)
|
||||
format_layout.addWidget(defaults_group_box)
|
||||
|
||||
format_layout.addStretch()
|
||||
|
||||
# === File Tab ===
|
||||
file_tab = QWidget()
|
||||
file_layout = QVBoxLayout(file_tab)
|
||||
|
||||
# --- Filename Format Section ---
|
||||
filename_format_group_box = QGroupBox(_("settings.filename_format"))
|
||||
filename_layout = QVBoxLayout()
|
||||
|
||||
# Load current filename format from ConfigManager
|
||||
self.filename_format_value = ConfigManager.get("filename_format") or "%(title)s_%(resolution)s_[%(id)s].%(ext)s"
|
||||
|
||||
# Input and Reset Button Layout
|
||||
filename_input_layout = QHBoxLayout()
|
||||
|
||||
self.filename_format_input = QLineEdit(self.filename_format_value)
|
||||
self.filename_format_input.setPlaceholderText("%(title)s_%(resolution)s_[%(id)s].%(ext)s")
|
||||
filename_input_layout.addWidget(self.filename_format_input)
|
||||
|
||||
self.reset_format_button = QPushButton(_("buttons.reset"))
|
||||
self.reset_format_button.setFixedWidth(70)
|
||||
self.reset_format_button.clicked.connect(lambda: self.filename_format_input.setText("%(title)s_%(resolution)s_[%(id)s].%(ext)s"))
|
||||
filename_input_layout.addWidget(self.reset_format_button)
|
||||
|
||||
filename_layout.addLayout(filename_input_layout)
|
||||
|
||||
filename_help_label = QLabel(_("settings.filename_format_help"))
|
||||
filename_help_label.setWordWrap(True)
|
||||
filename_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||
filename_layout.addWidget(filename_help_label)
|
||||
|
||||
filename_format_group_box.setLayout(filename_layout)
|
||||
file_layout.addWidget(filename_format_group_box)
|
||||
file_layout.addStretch()
|
||||
|
||||
# Add tabs to tab widget
|
||||
self.tab_widget.addTab(general_tab, _("settings.tab_general", default="General"))
|
||||
self.tab_widget.addTab(format_tab, _("settings.tab_format", default="Format"))
|
||||
self.tab_widget.addTab(file_tab, _("settings.tab_file", default="File"))
|
||||
|
||||
# Dialog buttons (OK/Cancel)
|
||||
button_box = QDialogButtonBox()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
# Style the buttons to look identical to Custom Options dialog
|
||||
for btn in [ok_button, cancel_button]:
|
||||
btn.setMinimumHeight(35)
|
||||
btn.setMinimumWidth(80)
|
||||
btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
padding: 8px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def _on_audio_normalization_toggled(self, state: int) -> None:
|
||||
"""Handle logic when audio normalization is toggled."""
|
||||
if state == Qt.CheckState.Checked.value:
|
||||
# Normalization requires re-encoding, so we must force an audio format
|
||||
self.force_audio_format_checkbox.setChecked(True)
|
||||
|
||||
# If 'Best (No conversion)' is selected, change it to MP3 to ensure re-encoding
|
||||
if self.audio_format_combo.currentIndex() == 0:
|
||||
self.audio_format_combo.setCurrentIndex(2) # Index 2 is typically MP3
|
||||
|
||||
def _on_force_audio_format_toggled(self, state: int) -> None:
|
||||
"""Handle logic when force audio format is toggled."""
|
||||
if state == Qt.CheckState.Unchecked.value:
|
||||
# If re-encoding is disabled, normalization cannot happen
|
||||
self.audio_normalization_checkbox.setChecked(False)
|
||||
|
||||
def browse_new_path(self) -> None:
|
||||
new_path = QFileDialog.getExistingDirectory(self, _("dialogs.select_folder"), str(self.current_path))
|
||||
if new_path:
|
||||
self.current_path = new_path
|
||||
self.path_display.setText(self.current_path)
|
||||
|
||||
def get_selected_path(self) -> str:
|
||||
"""Returns the confirmed path after the dialog is accepted."""
|
||||
return self.current_path
|
||||
|
||||
def get_selected_speed_limit(self) -> str | None:
|
||||
"""Returns the entered speed limit value (as string or None)."""
|
||||
limit_str = self.speed_limit_input.text().strip()
|
||||
if not limit_str:
|
||||
return None
|
||||
try:
|
||||
float(limit_str) # Check if convertible to float
|
||||
return limit_str
|
||||
except ValueError:
|
||||
logger.info("Invalid speed limit input in dialog")
|
||||
return None
|
||||
|
||||
def get_selected_unit_index(self) -> int:
|
||||
"""Returns the index of the selected speed limit unit."""
|
||||
return self.speed_limit_unit.currentIndex()
|
||||
|
||||
def get_force_format_enabled(self) -> bool:
|
||||
"""Returns whether force output format is enabled."""
|
||||
return self.force_format_checkbox.isChecked()
|
||||
|
||||
def get_generic_mode_enabled(self) -> bool:
|
||||
"""Returns whether generic mode is enabled."""
|
||||
return self.generic_mode_checkbox.isChecked()
|
||||
|
||||
def get_concurrent_fragments(self) -> int:
|
||||
"""Returns the number of concurrent fragments."""
|
||||
try:
|
||||
return int(self.connections_input.currentText())
|
||||
except ValueError:
|
||||
return 1
|
||||
|
||||
def get_preferred_format(self) -> str:
|
||||
"""Returns the selected preferred format (lowercase)."""
|
||||
format_map = {0: "mp4", 1: "webm", 2: "mkv"}
|
||||
return format_map.get(self.format_combo.currentIndex(), "mp4")
|
||||
|
||||
def get_force_audio_format_enabled(self) -> bool:
|
||||
"""Returns whether force audio format is enabled."""
|
||||
return self.force_audio_format_checkbox.isChecked()
|
||||
|
||||
def get_preferred_audio_format(self) -> str:
|
||||
"""Returns the selected preferred audio format (lowercase)."""
|
||||
audio_format_map = {0: "best", 1: "aac", 2: "mp3", 3: "flac", 4: "wav", 5: "opus", 6: "m4a", 7: "vorbis"}
|
||||
return audio_format_map.get(self.audio_format_combo.currentIndex(), "best")
|
||||
|
||||
def get_audio_normalization_enabled(self) -> bool:
|
||||
"""Returns whether audio normalization is enabled."""
|
||||
return self.audio_normalization_checkbox.isChecked()
|
||||
|
||||
def get_filename_format(self) -> str:
|
||||
"""Returns the filename format string."""
|
||||
return self.filename_format_input.text().strip()
|
||||
|
||||
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
|
||||
"""Create a styled QMessageBox that matches the app theme."""
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(icon)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
msg_box.setWindowIcon(self.windowIcon())
|
||||
msg_box.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QMessageBox QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
return msg_box
|
||||
|
||||
def accept(self) -> None:
|
||||
"""Override accept to save format settings."""
|
||||
try:
|
||||
ConfigManager.set("generic_mode", self.get_generic_mode_enabled())
|
||||
ConfigManager.set("concurrent_fragments", self.get_concurrent_fragments())
|
||||
|
||||
# Save output format settings
|
||||
force_format = self.get_force_format_enabled()
|
||||
preferred_format = self.get_preferred_format()
|
||||
ConfigManager.set("force_output_format", force_format)
|
||||
ConfigManager.set("preferred_output_format", preferred_format)
|
||||
|
||||
# Save audio format settings
|
||||
force_audio_format = self.get_force_audio_format_enabled()
|
||||
preferred_audio_format = self.get_preferred_audio_format()
|
||||
audio_normalization = self.get_audio_normalization_enabled()
|
||||
ConfigManager.set("force_audio_format", force_audio_format)
|
||||
ConfigManager.set("preferred_audio_format", preferred_audio_format)
|
||||
ConfigManager.set("audio_normalization", audio_normalization)
|
||||
|
||||
# Save defaults
|
||||
default_vid = self.default_vid_qual_input.text().strip()
|
||||
ConfigManager.set("default_video_quality", default_vid if default_vid else None)
|
||||
default_sub = self.default_sub_input.text().strip()
|
||||
ConfigManager.set("default_subtitle_language", default_sub if default_sub else None)
|
||||
|
||||
# Save filename format
|
||||
filename_format = self.get_filename_format()
|
||||
if filename_format:
|
||||
ConfigManager.set("filename_format", filename_format)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
_("settings.settings_saved_title"),
|
||||
_("settings.settings_saved_message"),
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, _("settings.error_title"), _("settings.error_saving_settings", error=str(e)))
|
||||
|
||||
# Call the parent accept method to close the dialog
|
||||
super().accept()
|
||||
|
||||
|
||||
class AutoUpdateSettingsDialog(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("settings.auto_update_title"))
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent:
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
|
||||
self.init_ui()
|
||||
self.load_current_settings()
|
||||
self.apply_styling()
|
||||
|
||||
def init_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Title
|
||||
title_label = QLabel(f"<h2>{_("settings.auto_update_header")}</h2>")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel(_("settings.auto_update_description"))
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Enable/Disable auto-update
|
||||
self.enable_checkbox = QCheckBox(_("settings.enable_auto_updates"))
|
||||
self.enable_checkbox.setChecked(True) # Default enabled
|
||||
self.enable_checkbox.toggled.connect(self.on_enable_toggled)
|
||||
layout.addWidget(self.enable_checkbox)
|
||||
|
||||
# Frequency options
|
||||
frequency_group = QGroupBox(_("settings.update_frequency_group"))
|
||||
frequency_layout = QVBoxLayout()
|
||||
|
||||
self.frequency_group = QButtonGroup(self)
|
||||
|
||||
self.startup_radio = QRadioButton(_("settings.check_startup"))
|
||||
self.daily_radio = QRadioButton(_("settings.check_daily"))
|
||||
self.weekly_radio = QRadioButton(_("settings.check_weekly"))
|
||||
|
||||
self.daily_radio.setChecked(True) # Default to daily
|
||||
|
||||
self.frequency_group.addButton(self.startup_radio, 0)
|
||||
self.frequency_group.addButton(self.daily_radio, 1)
|
||||
self.frequency_group.addButton(self.weekly_radio, 2)
|
||||
|
||||
frequency_layout.addWidget(self.startup_radio)
|
||||
frequency_layout.addWidget(self.daily_radio)
|
||||
frequency_layout.addWidget(self.weekly_radio)
|
||||
frequency_group.setLayout(frequency_layout)
|
||||
|
||||
layout.addWidget(frequency_group)
|
||||
|
||||
# Current status
|
||||
status_group = QGroupBox(_("settings.current_status"))
|
||||
status_layout = QVBoxLayout()
|
||||
|
||||
self.current_version_label = QLabel(_("settings.current_version_label"))
|
||||
self.last_check_label = QLabel(_("settings.last_check_label"))
|
||||
self.next_check_label = QLabel(_("settings.next_check_label"))
|
||||
|
||||
status_layout.addWidget(self.current_version_label)
|
||||
status_layout.addWidget(self.last_check_label)
|
||||
status_layout.addWidget(self.next_check_label)
|
||||
status_group.setLayout(status_layout)
|
||||
|
||||
layout.addWidget(status_group)
|
||||
|
||||
# Manual check button
|
||||
self.manual_check_btn = QPushButton(_("settings.manual_check_button"))
|
||||
self.manual_check_btn.clicked.connect(self.manual_check)
|
||||
layout.addWidget(self.manual_check_btn)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.save_btn = QPushButton(_("settings.save_settings"))
|
||||
self.save_btn.clicked.connect(self.save_settings)
|
||||
|
||||
self.cancel_btn = QPushButton(_("buttons.cancel"))
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addWidget(self.save_btn)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def apply_styling(self) -> None:
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QGroupBox {
|
||||
color: #ffffff;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
font-weight: bold;
|
||||
background-color: #15181b;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 5px 0 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QCheckBox, QRadioButton {
|
||||
color: #ffffff;
|
||||
spacing: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked, QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #15181b;
|
||||
}
|
||||
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
margin: 5px;
|
||||
min-width: 100px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
color: #999999;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
def load_current_settings(self) -> None:
|
||||
"""Load current auto-update settings from config."""
|
||||
try:
|
||||
settings = get_auto_update_settings()
|
||||
|
||||
# Set checkbox
|
||||
self.enable_checkbox.setChecked(settings["enabled"])
|
||||
|
||||
# Set frequency
|
||||
frequency = settings["frequency"]
|
||||
if frequency == "startup":
|
||||
self.startup_radio.setChecked(True)
|
||||
elif frequency == "weekly":
|
||||
self.weekly_radio.setChecked(True)
|
||||
else: # daily
|
||||
self.daily_radio.setChecked(True)
|
||||
|
||||
# Update status labels
|
||||
current_version = get_ytdlp_version()
|
||||
self.current_version_label.setText(_("auto_update.current_version", version=current_version))
|
||||
|
||||
last_check = settings["last_check"]
|
||||
if last_check > 0:
|
||||
last_check_time = datetime.fromtimestamp(last_check).strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.last_check_label.setText(_("auto_update.last_check", time=last_check_time))
|
||||
else:
|
||||
self.last_check_label.setText(_("auto_update.last_check_never"))
|
||||
|
||||
# Calculate next check time
|
||||
self.update_next_check_label()
|
||||
|
||||
# Update UI state
|
||||
self.on_enable_toggled(settings["enabled"])
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error loading auto-update settings: {e}")
|
||||
|
||||
def update_next_check_label(self) -> None:
|
||||
"""Update the next check label based on current settings."""
|
||||
try:
|
||||
if not self.enable_checkbox.isChecked():
|
||||
self.next_check_label.setText(_("auto_update.next_check_disabled"))
|
||||
return
|
||||
|
||||
settings = get_auto_update_settings()
|
||||
last_check = settings["last_check"]
|
||||
frequency = self.get_selected_frequency()
|
||||
|
||||
if last_check == 0:
|
||||
self.next_check_label.setText(_("auto_update.next_check_startup"))
|
||||
return
|
||||
|
||||
next_check_time = last_check
|
||||
if frequency == "startup":
|
||||
next_check_time += 3600 # 1 hour
|
||||
elif frequency == "daily":
|
||||
next_check_time += 86400 # 24 hours
|
||||
elif frequency == "weekly":
|
||||
next_check_time += 604800 # 7 days
|
||||
|
||||
current_time = time.time()
|
||||
if next_check_time <= current_time:
|
||||
self.next_check_label.setText(_("auto_update.next_check_overdue"))
|
||||
else:
|
||||
next_check_datetime = datetime.fromtimestamp(next_check_time)
|
||||
self.next_check_label.setText(_("auto_update.next_check", time=next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
except Exception as e:
|
||||
self.next_check_label.setText(_("auto_update.next_check_error"))
|
||||
logger.exception(f"Error calculating next check time: {e}")
|
||||
|
||||
def on_enable_toggled(self, enabled) -> None:
|
||||
"""Handle enable/disable checkbox toggle."""
|
||||
# Enable/disable frequency options
|
||||
for i in range(self.frequency_group.buttons().__len__()):
|
||||
self.frequency_group.button(i).setEnabled(enabled)
|
||||
|
||||
self.update_next_check_label()
|
||||
|
||||
def get_selected_frequency(self) -> str:
|
||||
"""Get the selected frequency setting."""
|
||||
if self.startup_radio.isChecked():
|
||||
return "startup"
|
||||
elif self.weekly_radio.isChecked():
|
||||
return "weekly"
|
||||
else:
|
||||
return "daily"
|
||||
|
||||
def manual_check(self) -> None:
|
||||
"""Perform a manual update check."""
|
||||
self.manual_check_btn.setEnabled(False)
|
||||
self.manual_check_btn.setText(_("auto_update.checking"))
|
||||
|
||||
# Force an immediate update check
|
||||
def check_in_thread() -> None:
|
||||
try:
|
||||
result = check_and_update_ytdlp_auto()
|
||||
|
||||
# Update UI in main thread
|
||||
QTimer.singleShot(0, lambda: self.manual_check_finished(result))
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during manual check: {e}")
|
||||
QTimer.singleShot(0, lambda: self.manual_check_finished(False))
|
||||
|
||||
# Run in separate thread to avoid blocking UI
|
||||
threading.Thread(target=check_in_thread, daemon=True).start()
|
||||
|
||||
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
|
||||
"""Create a styled QMessageBox that matches the app theme."""
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(icon)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
msg_box.setWindowIcon(self.windowIcon())
|
||||
msg_box.setStyleSheet(
|
||||
"""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QMessageBox QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
return msg_box
|
||||
|
||||
def manual_check_finished(self, success) -> None:
|
||||
"""Handle completion of manual update check."""
|
||||
self.manual_check_btn.setEnabled(True)
|
||||
self.manual_check_btn.setText(_("auto_update.check_now"))
|
||||
|
||||
if success:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Icon.Information,
|
||||
"Update Check",
|
||||
"✅ Update check completed successfully!\nCheck the console for details.",
|
||||
)
|
||||
msg_box.exec()
|
||||
else:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Icon.Warning,
|
||||
"Update Check",
|
||||
"❌ Update check failed.\nCheck the console for error details.",
|
||||
)
|
||||
msg_box.exec()
|
||||
|
||||
# Refresh the current settings display
|
||||
self.load_current_settings()
|
||||
|
||||
def save_settings(self) -> None:
|
||||
"""Save the auto-update settings."""
|
||||
try:
|
||||
enabled = self.enable_checkbox.isChecked()
|
||||
frequency = self.get_selected_frequency()
|
||||
|
||||
if update_auto_update_settings(enabled, frequency):
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Icon.Information,
|
||||
_("settings.settings_saved_title"),
|
||||
_("settings.settings_saved_successfully"),
|
||||
)
|
||||
msg_box.exec()
|
||||
self.accept()
|
||||
else:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Icon.Warning,
|
||||
_("settings.error_title"),
|
||||
_("settings.failed_save_settings"),
|
||||
)
|
||||
msg_box.exec()
|
||||
except Exception as e:
|
||||
logger.exception(f"Error saving auto-update settings: {e}")
|
||||
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings.error_saving", error=str(e)))
|
||||
msg_box.exec()
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Update-related dialogs and threads for YTSage application.
|
||||
Contains dialogs and background threads for checking and performing yt-dlp binary updates.
|
||||
Note: This module only handles binary updates. Python package updates have been removed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from packaging import version
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
||||
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout
|
||||
|
||||
from ...core.ytsage_utils import get_ytdlp_version
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ...utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL
|
||||
from ...utils.ytsage_config_manager import ConfigManager
|
||||
from ...utils.ytsage_localization import LocalizationManager
|
||||
|
||||
# Shorthand for localization
|
||||
_ = LocalizationManager.get_text
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_logger import logger
|
||||
|
||||
|
||||
class VersionCheckThread(QThread):
|
||||
finished = Signal(str, str, str) # current_version, latest_version, error_message
|
||||
|
||||
def run(self) -> None:
|
||||
current_version = ""
|
||||
latest_version = ""
|
||||
error_message = ""
|
||||
|
||||
try:
|
||||
# Get the yt-dlp executable path
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
|
||||
# Get current version with timeout
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[yt_dlp_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30, # 30 second timeout
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
current_version = result.stdout.strip()
|
||||
else:
|
||||
error_message = "yt-dlp binary not accessible."
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
error_message = "yt-dlp version check timed out."
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
except Exception as e:
|
||||
error_message = f"yt-dlp not found or accessible: {e}"
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
|
||||
# Get latest version from PyPI (yt-dlp releases are also published to PyPI)
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
response.raise_for_status()
|
||||
latest_version = response.json()["info"]["version"]
|
||||
|
||||
# Clean up version strings
|
||||
current_version = current_version.replace("_", ".")
|
||||
latest_version = latest_version.replace("_", ".")
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_message = f"Network error checking PyPI: {e}"
|
||||
except Exception as e:
|
||||
error_message = f"Error checking version: {e}"
|
||||
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
|
||||
|
||||
class UpdateThread(QThread):
|
||||
update_status = Signal(str) # For status messages
|
||||
update_progress = Signal(int) # For progress percentage (0-100)
|
||||
update_finished = Signal(bool, str) # success (bool), message/error (str)
|
||||
|
||||
def run(self) -> None:
|
||||
error_message = ""
|
||||
success = False
|
||||
try:
|
||||
self.update_status.emit(_('update.checking_current'))
|
||||
self.update_progress.emit(10)
|
||||
|
||||
# Get the yt-dlp path
|
||||
try:
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
self.update_status.emit(_('update.found_at', path=yt_dlp_path))
|
||||
except Exception as e:
|
||||
self.update_status.emit(_('update.error_getting_path', error=e))
|
||||
self.update_finished.emit(False, _('update.error_getting_path', error=e))
|
||||
return
|
||||
|
||||
self.update_progress.emit(20)
|
||||
|
||||
# Update the binary (no more pip-based updates)
|
||||
self.update_status.emit(_('update.updating_binary'))
|
||||
success = self._update_binary(yt_dlp_path)
|
||||
|
||||
if success:
|
||||
self.update_progress.emit(100)
|
||||
error_message = _('update.update_success')
|
||||
else:
|
||||
error_message = _('update.update_failed')
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_message = _('update.network_error', error=e)
|
||||
self.update_status.emit(error_message)
|
||||
success = False
|
||||
except Exception as e:
|
||||
error_message = _('update.general_error', error=e)
|
||||
self.update_status.emit(error_message)
|
||||
success = False
|
||||
|
||||
self.update_finished.emit(success, error_message)
|
||||
|
||||
def _update_binary(self, yt_dlp_path: Path) -> bool:
|
||||
"""Update yt-dlp binary using its built-in updater (same logic as AutoUpdateThread)."""
|
||||
try:
|
||||
logger.info("UpdateThread: Checking for yt-dlp updates...")
|
||||
|
||||
result = subprocess.run(
|
||||
[yt_dlp_path, "-U"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(yt_dlp_path, 0o755)
|
||||
|
||||
logger.info("UpdateThread: yt-dlp update completed successfully.")
|
||||
if result.stdout:
|
||||
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
|
||||
self.update_status.emit(_('update.binary_updated'))
|
||||
self.update_progress.emit(95)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}")
|
||||
self.update_status.emit(_('update.update_failed_stderr', error=result.stderr.strip()))
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("UpdateThread: yt-dlp update timed out.")
|
||||
self.update_status.emit(_('update.update_timeout'))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"UpdateThread: Unexpected error during update: {e}")
|
||||
self.update_status.emit(_('update.unexpected_error', error=e))
|
||||
return False
|
||||
|
||||
|
||||
class YTDLPUpdateDialog(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_('update.title'))
|
||||
self.setMinimumWidth(450)
|
||||
self.setMinimumHeight(200)
|
||||
self._closing = False # Flag to track if dialog is closing
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Status label
|
||||
self.status_label = QLabel(_('update.checking'))
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setMinimumHeight(60)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Progress bar
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.hide() # Hide initially
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
self.update_btn = QPushButton(_('buttons.update'))
|
||||
self.update_btn.clicked.connect(self.perform_update)
|
||||
self.update_btn.setEnabled(False)
|
||||
|
||||
self.close_btn = QPushButton(_('buttons.close'))
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
|
||||
button_layout.addWidget(self.update_btn)
|
||||
button_layout.addWidget(self.close_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Style
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 100px;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #1d1e22;
|
||||
height: 30px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stop: 0 #e60000, stop: 0.5 #ff3333, stop: 1 #c90000);
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Start version check in background
|
||||
self.check_version()
|
||||
|
||||
def check_version(self) -> None:
|
||||
self.status_label.setText(_('update.checking'))
|
||||
self.update_btn.setEnabled(False)
|
||||
self.version_check_thread = VersionCheckThread()
|
||||
self.version_check_thread.finished.connect(self.on_version_check_finished)
|
||||
self.version_check_thread.start()
|
||||
|
||||
def on_version_check_finished(self, current_version, latest_version, error_message) -> None:
|
||||
# Check if dialog is closing to avoid unnecessary updates
|
||||
if hasattr(self, "_closing") and self._closing:
|
||||
return
|
||||
|
||||
if error_message:
|
||||
self.status_label.setText(error_message)
|
||||
self.update_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
if not current_version or not latest_version:
|
||||
self.status_label.setText(_('update.could_not_determine'))
|
||||
self.update_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
try:
|
||||
# Compare versions
|
||||
current_ver = version.parse(current_version)
|
||||
latest_ver = version.parse(latest_version)
|
||||
|
||||
if current_ver < latest_ver:
|
||||
self.status_label.setText(
|
||||
_('update.update_available', current=current_version, latest=latest_version)
|
||||
)
|
||||
self.update_btn.setEnabled(True)
|
||||
else:
|
||||
self.status_label.setText(_('update.already_latest', version=current_version))
|
||||
self.update_btn.setEnabled(False)
|
||||
except version.InvalidVersion:
|
||||
# If version parsing fails, do a simple string comparison
|
||||
if current_version != latest_version:
|
||||
self.status_label.setText(
|
||||
_('update.update_available_failed', current=current_version, latest=latest_version)
|
||||
)
|
||||
self.update_btn.setEnabled(True)
|
||||
else:
|
||||
self.status_label.setText(_('update.up_to_date', version=current_version))
|
||||
self.update_btn.setEnabled(False)
|
||||
except Exception as e:
|
||||
self.status_label.setText(_('update.error_comparing', error=e))
|
||||
self.update_btn.setEnabled(False)
|
||||
|
||||
def perform_update(self) -> None:
|
||||
# Immediate visual feedback
|
||||
self.update_btn.setEnabled(False)
|
||||
self.close_btn.setEnabled(False)
|
||||
self.update_btn.setText(_('update.updating'))
|
||||
self.status_label.setText(_('update.initializing'))
|
||||
|
||||
# Show progress bar immediately
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.show()
|
||||
|
||||
# Start the update thread
|
||||
self._start_update_thread()
|
||||
|
||||
def _start_update_thread(self) -> None:
|
||||
"""Start the actual update thread."""
|
||||
# Create and start the update thread
|
||||
self.update_thread = UpdateThread()
|
||||
self.update_thread.update_status.connect(self.on_update_status)
|
||||
self.update_thread.update_progress.connect(self.on_update_progress)
|
||||
self.update_thread.update_finished.connect(self.on_update_finished)
|
||||
self.update_thread.start()
|
||||
|
||||
def on_update_status(self, message) -> None:
|
||||
"""Slot to receive status messages from UpdateThread."""
|
||||
if not (hasattr(self, "_closing") and self._closing):
|
||||
self.status_label.setText(message)
|
||||
|
||||
def on_update_progress(self, progress) -> None:
|
||||
"""Slot to receive progress updates from UpdateThread."""
|
||||
if not (hasattr(self, "_closing") and self._closing):
|
||||
self.progress_bar.setValue(progress)
|
||||
|
||||
def on_update_finished(self, success, message) -> None:
|
||||
"""Slot called when the UpdateThread finishes."""
|
||||
# Check if dialog is closing to avoid unnecessary updates
|
||||
if hasattr(self, "_closing") and self._closing:
|
||||
return
|
||||
|
||||
self.progress_bar.setValue(100)
|
||||
self.status_label.setText(message)
|
||||
self.close_btn.setEnabled(True)
|
||||
self.update_btn.setText(_('buttons.update')) # Reset button text
|
||||
|
||||
if success:
|
||||
# Show success briefly then auto-check version
|
||||
QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh
|
||||
else:
|
||||
# Re-enable update button on failure after a short delay
|
||||
QTimer.singleShot(
|
||||
3000,
|
||||
lambda: (self.update_btn.setEnabled(True) if not (hasattr(self, "_closing") and self._closing) else None),
|
||||
)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
"""Ensure threads are terminated if the dialog is closed prematurely."""
|
||||
# Set a flag to indicate dialog is closing
|
||||
self._closing = True
|
||||
|
||||
if hasattr(self, "version_check_thread") and self.version_check_thread.isRunning():
|
||||
self.version_check_thread.quit()
|
||||
if not self.version_check_thread.wait(3000): # Wait up to 3 seconds
|
||||
self.version_check_thread.terminate()
|
||||
|
||||
if hasattr(self, "update_thread") and self.update_thread.isRunning():
|
||||
self.update_thread.quit()
|
||||
if not self.update_thread.wait(5000): # Wait up to 5 seconds for update to finish
|
||||
self.update_thread.terminate()
|
||||
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
class AutoUpdateThread(QThread):
|
||||
"""Thread for performing automatic background updates without UI feedback."""
|
||||
|
||||
update_finished = Signal(bool, str) # success (bool), message (str)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Perform automatic yt-dlp update check and update if needed."""
|
||||
try:
|
||||
logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...")
|
||||
|
||||
# Get current version
|
||||
current_version = get_ytdlp_version()
|
||||
if "Error" in current_version:
|
||||
logger.warning("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update")
|
||||
self.update_finished.emit(False, "Could not determine current yt-dlp version")
|
||||
return
|
||||
|
||||
# Get latest version from PyPI
|
||||
try:
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
response.raise_for_status()
|
||||
latest_version = response.json()["info"]["version"]
|
||||
|
||||
# Clean up version strings
|
||||
current_version = current_version.replace("_", ".")
|
||||
latest_version = latest_version.replace("_", ".")
|
||||
|
||||
logger.info(f"AutoUpdateThread: Current yt-dlp version: {current_version}")
|
||||
logger.info(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}")
|
||||
|
||||
# Compare versions
|
||||
if version.parse(latest_version) > version.parse(current_version):
|
||||
logger.info(f"AutoUpdateThread: Auto-updating yt-dlp from {current_version} to {latest_version}...")
|
||||
|
||||
# Perform the update
|
||||
success = self._perform_update()
|
||||
|
||||
if success:
|
||||
logger.info("AutoUpdateThread: Auto-update completed successfully!")
|
||||
# Update the last check timestamp
|
||||
ConfigManager.set("last_update_check", time.time())
|
||||
self.update_finished.emit(
|
||||
True,
|
||||
f"Successfully updated yt-dlp from {current_version} to {latest_version}",
|
||||
)
|
||||
else:
|
||||
logger.warning("AutoUpdateThread: Auto-update failed")
|
||||
self.update_finished.emit(False, "Auto-update failed")
|
||||
else:
|
||||
logger.info("AutoUpdateThread: yt-dlp is already up to date")
|
||||
# Still update the timestamp even if no update was needed
|
||||
ConfigManager.set("last_update_check", time.time())
|
||||
self.update_finished.emit(
|
||||
True,
|
||||
f"yt-dlp is already up to date (version {current_version})",
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
|
||||
self.update_finished.emit(False, f"Network error: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True)
|
||||
self.update_finished.emit(False, f"Update check error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"AutoUpdateThread: Critical error in auto-update: {e}", exc_info=True)
|
||||
self.update_finished.emit(False, f"Critical error: {e}")
|
||||
|
||||
def _perform_update(self) -> bool:
|
||||
"""Perform the actual binary update."""
|
||||
try:
|
||||
# Get the yt-dlp path
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
|
||||
# Always update the binary (no more pip-based updates)
|
||||
logger.info("AutoUpdateThread: Updating yt-dlp binary...")
|
||||
return self._update_binary(yt_dlp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"AutoUpdateThread: Error in _perform_update: {e}")
|
||||
return False
|
||||
|
||||
def _update_binary(self, yt_dlp_path: Path) -> bool:
|
||||
"""Update yt-dlp binary using its built-in updater."""
|
||||
try:
|
||||
logger.info("AutoUpdateThread: Checking for yt-dlp updates...")
|
||||
|
||||
result = subprocess.run(
|
||||
[yt_dlp_path, "-U"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(yt_dlp_path, 0o755)
|
||||
|
||||
logger.info("AutoUpdateThread: yt-dlp update completed successfully.")
|
||||
if result.stdout:
|
||||
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"AutoUpdateThread: yt-dlp update failed. {result.stderr.strip()}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("AutoUpdateThread: yt-dlp update timed out.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"AutoUpdateThread: Unexpected error during update: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Feed tab - subscriptions and their video feed
|
||||
=============================================
|
||||
|
||||
Two modes (config feed.mode):
|
||||
- local: aggregate the most recent uploads of every locally-subscribed
|
||||
channel (no account needed). Channels refresh sequentially in one worker
|
||||
thread; the grid fills incrementally as each channel lands.
|
||||
- account: fetch youtube.com/feed/subscriptions with the user's cookies -
|
||||
the real logged-in feed. Enabled only while cookies are active.
|
||||
|
||||
Subscriptions are stored in LibraryManager (sagetube_library.db); the
|
||||
Browse page's Subscribe button routes here via the main window.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QThread, QTimer, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ytsage_gui_cards import VideoCardGrid
|
||||
from ..core.ytsage_client import YtdlpClient, YtdlpWorker
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
|
||||
#: Only refresh a channel automatically if it is at least this stale.
|
||||
AUTO_REFRESH_DEFAULT_MINUTES = 30
|
||||
#: Channels touched per automatic refresh. A manual Refresh still does all of
|
||||
#: them; this only bounds what opening the tab can set off.
|
||||
AUTO_REFRESH_BATCH = 8
|
||||
#: Let the tab transition finish before spawning subprocesses.
|
||||
AUTO_REFRESH_DELAY_MS = 1500
|
||||
#: The automatic path gives up sooner than a manual refresh: nobody is
|
||||
#: watching it, and a stuck channel must not block the rest.
|
||||
AUTO_REFRESH_TIMEOUT = 45
|
||||
|
||||
|
||||
class FeedRefreshWorker(QThread):
|
||||
"""Sequentially refresh each subscription's recent uploads."""
|
||||
|
||||
channelDone = Signal(str) # channel_id
|
||||
channelFailed = Signal(str, str) # channel_id, error
|
||||
allDone = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscriptions: List[Dict[str, Any]],
|
||||
per_channel: int,
|
||||
parent=None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._subs = subscriptions
|
||||
self._per_channel = per_channel
|
||||
self._timeout = timeout
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
def run(self) -> None:
|
||||
client = YtdlpClient()
|
||||
for sub in self._subs:
|
||||
if self._cancelled:
|
||||
break
|
||||
try:
|
||||
kwargs = {}
|
||||
if self._timeout is not None:
|
||||
kwargs["timeout"] = self._timeout
|
||||
entries = client.fetch_flat_entries(
|
||||
f"{sub['url'].rstrip('/')}/videos",
|
||||
start=1,
|
||||
end=self._per_channel,
|
||||
use_cache=False,
|
||||
**kwargs,
|
||||
)
|
||||
LibraryManager.upsert_feed_items(sub["channel_id"], entries)
|
||||
LibraryManager.mark_refreshed(sub["channel_id"])
|
||||
self.channelDone.emit(sub["channel_id"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Feed refresh failed for {sub.get('title')}: {e}")
|
||||
self.channelFailed.emit(sub["channel_id"], str(e))
|
||||
self.allDone.emit()
|
||||
|
||||
|
||||
class FeedPage(QWidget):
|
||||
#: Emitted after subscribe/unsubscribe so other pages can re-read state.
|
||||
subscriptionsChanged = Signal()
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._refresh_worker: Optional[FeedRefreshWorker] = None
|
||||
self._account_worker: Optional[YtdlpWorker] = None
|
||||
#: monotonic timestamp of the last automatic refresh this session
|
||||
self._last_auto_refresh: float = 0.0
|
||||
self._done_count = 0
|
||||
self._failed_count = 0
|
||||
self._total_count = 0
|
||||
self._last_error = ""
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.mode_combo = QComboBox()
|
||||
self.mode_combo.setToolTip(_("feed.mode_tooltip"))
|
||||
self.mode_combo.addItem(_("feed.mode_local"), "local")
|
||||
self.mode_combo.addItem(_("feed.mode_account"), "account")
|
||||
self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1)
|
||||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||||
bar.addWidget(self.mode_combo)
|
||||
|
||||
self.refresh_btn = QPushButton(_("feed.refresh"))
|
||||
self.refresh_btn.setToolTip(_("feed.refresh_tooltip"))
|
||||
self.refresh_btn.setProperty("sageIcon", "refresh")
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
bar.addWidget(self.refresh_btn)
|
||||
|
||||
# Account mode is disabled without cookies. Rather than leaving that
|
||||
# as a dead end, offer the way out right beside it.
|
||||
self.signin_btn = QPushButton(_("feed.sign_in"))
|
||||
self.signin_btn.setToolTip(_("feed.sign_in_tooltip"))
|
||||
self.signin_btn.setProperty("sageIcon", "user")
|
||||
self.signin_btn.clicked.connect(self._router.cookieSetupRequested)
|
||||
bar.addWidget(self.signin_btn)
|
||||
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #9aa0a6;")
|
||||
bar.addWidget(self.status_label, stretch=1)
|
||||
layout.addLayout(bar)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
layout.addWidget(splitter, stretch=1)
|
||||
|
||||
# Kept as an attribute: account mode does not use local subscriptions,
|
||||
# so the panel is hidden there rather than sitting empty beside a full
|
||||
# feed and implying something is missing.
|
||||
self.subs_panel = side = QWidget()
|
||||
side_layout = QVBoxLayout(side)
|
||||
side_layout.setContentsMargins(0, 0, 4, 0)
|
||||
subs_label = QLabel(_("feed.subscriptions"))
|
||||
subs_label.setStyleSheet("font-weight: bold;")
|
||||
side_layout.addWidget(subs_label)
|
||||
self.subs_list = QListWidget()
|
||||
self.subs_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.subs_list.customContextMenuRequested.connect(self._on_subs_context_menu)
|
||||
self.subs_list.itemDoubleClicked.connect(self._on_sub_activated)
|
||||
side_layout.addWidget(self.subs_list)
|
||||
splitter.addWidget(side)
|
||||
|
||||
grid_host = QWidget()
|
||||
grid_layout = QVBoxLayout(grid_host)
|
||||
grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.grid = VideoCardGrid(router, grid_host)
|
||||
grid_layout.addWidget(self.grid, stretch=1)
|
||||
|
||||
# Feed is the first tab, so an empty feed is the first thing a new
|
||||
# install shows. Say what to do about it.
|
||||
self.empty_hint = QLabel("")
|
||||
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_hint.setWordWrap(True)
|
||||
self.empty_hint.setStyleSheet("color: #9aa0a6; padding: 24px; font-size: 13px;")
|
||||
self.empty_hint.hide()
|
||||
grid_layout.addWidget(self.empty_hint, stretch=1)
|
||||
# The grid carries its own bare "Nothing here yet"; two empty states
|
||||
# stacked on one page is one too many.
|
||||
self.grid.empty_label.hide()
|
||||
|
||||
splitter.addWidget(grid_host)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 4)
|
||||
splitter.setSizes([220, 900])
|
||||
|
||||
self.reload_subscriptions()
|
||||
self._load_cached_feed()
|
||||
self._update_mode_availability()
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def subscribe_channel(self, meta: Dict[str, Any]) -> None:
|
||||
"""Wired to BrowsePage.subscribeRequested via the main window."""
|
||||
channel_id = meta.get("channel_id") or meta.get("url")
|
||||
if not channel_id or not meta.get("url"):
|
||||
logger.warning(f"Cannot subscribe, missing channel info: {meta}")
|
||||
return
|
||||
if LibraryManager.is_subscribed(str(channel_id)):
|
||||
LibraryManager.unsubscribe(str(channel_id))
|
||||
self.status_label.setText(_("feed.unsubscribed", title=meta.get("title") or channel_id))
|
||||
else:
|
||||
LibraryManager.subscribe(str(channel_id), meta.get("title") or str(channel_id), meta["url"], meta.get("avatar_url"))
|
||||
self.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id))
|
||||
self.reload_subscriptions()
|
||||
self._update_empty_state()
|
||||
self.subscriptionsChanged.emit()
|
||||
|
||||
def reload_subscriptions(self) -> None:
|
||||
self.subs_list.clear()
|
||||
for sub in LibraryManager.subscriptions():
|
||||
item = QListWidgetItem(sub["title"])
|
||||
item.setData(Qt.ItemDataRole.UserRole, sub)
|
||||
self.subs_list.addItem(item)
|
||||
|
||||
def refresh(self) -> None:
|
||||
mode = self.mode_combo.currentData()
|
||||
ConfigManager.set("feed.mode", mode)
|
||||
if mode == "account":
|
||||
self._refresh_account()
|
||||
else:
|
||||
self._refresh_local()
|
||||
|
||||
# --------------------------------------------------- refresh on opening
|
||||
|
||||
def on_tab_activated(self) -> None:
|
||||
"""
|
||||
Called by the main window when the Feed tab becomes visible.
|
||||
|
||||
The cached grid is already on screen -- it is loaded in the
|
||||
constructor and kept up to date -- so this only decides whether to go
|
||||
and fetch. A refresh is one yt-dlp subprocess per subscribed channel,
|
||||
so it is braked four ways: only channels that are actually stale, at
|
||||
most one automatic run per interval per session, a batch cap, and a
|
||||
short delay so it does not race the tab transition or first-run setup.
|
||||
"""
|
||||
if self._refresh_worker is not None or self._account_worker is not None:
|
||||
return
|
||||
if (self.mode_combo.currentData() or "local") != "local":
|
||||
return
|
||||
|
||||
minutes = ConfigManager.get("feed.auto_refresh_on_open_minutes")
|
||||
minutes = AUTO_REFRESH_DEFAULT_MINUTES if minutes is None else int(minutes)
|
||||
if minutes <= 0:
|
||||
return # opted out
|
||||
|
||||
now = time.monotonic()
|
||||
if self._last_auto_refresh and (now - self._last_auto_refresh) < minutes * 60:
|
||||
return
|
||||
|
||||
cutoff = time.time() - minutes * 60
|
||||
stale = [s for s in LibraryManager.subscriptions() if not s.get("last_refreshed") or s["last_refreshed"] < cutoff]
|
||||
if not stale:
|
||||
return
|
||||
|
||||
self._last_auto_refresh = now
|
||||
batch = stale[:AUTO_REFRESH_BATCH]
|
||||
if len(stale) > len(batch):
|
||||
logger.info(f"Feed auto-refresh: {len(batch)} of {len(stale)} stale channels this time.")
|
||||
QTimer.singleShot(AUTO_REFRESH_DELAY_MS, lambda: self._refresh_local(batch, auto=True))
|
||||
|
||||
# --------------------------------------------------------------- local
|
||||
|
||||
def _refresh_local(self, subs: Optional[List[Dict[str, Any]]] = None, auto: bool = False) -> None:
|
||||
if self._refresh_worker is not None:
|
||||
return
|
||||
if subs is None:
|
||||
subs = LibraryManager.subscriptions()
|
||||
if not subs:
|
||||
if not auto:
|
||||
self.status_label.setText(_("feed.no_subscriptions"))
|
||||
return
|
||||
per_channel = int(ConfigManager.get("feed.per_channel_items") or 15)
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.status_label.setText(_("feed.refreshing", done=0, total=len(subs)))
|
||||
self._done_count = 0
|
||||
self._failed_count = 0
|
||||
self._last_error = ""
|
||||
self._total_count = len(subs)
|
||||
self._refresh_worker = FeedRefreshWorker(
|
||||
subs,
|
||||
per_channel,
|
||||
parent=self,
|
||||
timeout=AUTO_REFRESH_TIMEOUT if auto else None,
|
||||
)
|
||||
self._refresh_worker.channelDone.connect(self._on_channel_done)
|
||||
self._refresh_worker.channelFailed.connect(self._on_channel_failed)
|
||||
self._refresh_worker.allDone.connect(self._on_refresh_done)
|
||||
self._refresh_worker.start()
|
||||
|
||||
def _on_channel_done(self, channel_id: str) -> None:
|
||||
self._done_count += 1
|
||||
self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count))
|
||||
# Merge only this channel's rows. Re-reading the whole feed here
|
||||
# rebuilt every card after every channel: visible flicker, the scroll
|
||||
# position lost each time, and thumbnails re-read from disk.
|
||||
self._merge_channel(channel_id)
|
||||
|
||||
def _on_channel_failed(self, channel_id: str, error: str) -> None:
|
||||
self._done_count += 1
|
||||
self._failed_count += 1
|
||||
# Do not write the error into the status line here: the next
|
||||
# channelDone overwrites it immediately, so failures were invisible.
|
||||
# It is reported once at the end instead.
|
||||
self._last_error = error
|
||||
|
||||
def _on_refresh_done(self) -> None:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
if self._failed_count:
|
||||
self.status_label.setText(
|
||||
_("feed.refreshed_with_failures", count=self.grid.card_count(), failed=self._failed_count)
|
||||
)
|
||||
logger.warning(f"Feed refresh: {self._failed_count} channel(s) failed; last error: {self._last_error[:200]}")
|
||||
else:
|
||||
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
|
||||
self._update_empty_state()
|
||||
if self._refresh_worker is not None:
|
||||
self._refresh_worker.deleteLater()
|
||||
self._refresh_worker = None
|
||||
|
||||
@staticmethod
|
||||
def _to_entries(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": it["video_id"],
|
||||
"url": it["url"],
|
||||
"title": it["title"],
|
||||
"channel": it.get("channel"),
|
||||
"duration": it["duration"],
|
||||
"thumbnail": it["thumbnail_url"],
|
||||
}
|
||||
for it in items
|
||||
]
|
||||
|
||||
def _load_cached_feed(self) -> None:
|
||||
self.grid.set_entries(self._to_entries(LibraryManager.feed_items()))
|
||||
self._update_empty_state()
|
||||
|
||||
def _merge_channel(self, channel_id: str) -> None:
|
||||
"""Fold one channel's fresh rows into the grid without rebuilding it."""
|
||||
try:
|
||||
items = LibraryManager.feed_items(channel_id=channel_id)
|
||||
except TypeError:
|
||||
# Older signature without the filter: fall back to a full reload.
|
||||
self._load_cached_feed()
|
||||
return
|
||||
self.grid.merge_entries(self._to_entries(items))
|
||||
self._update_empty_state()
|
||||
|
||||
def _update_empty_state(self) -> None:
|
||||
"""
|
||||
Feed is the first tab now, so an empty one is the first thing a new
|
||||
user sees. Say what to do about it rather than showing a blank grid.
|
||||
"""
|
||||
account_mode = (self.mode_combo.currentData() or "local") == "account"
|
||||
self.subs_panel.setVisible(not account_mode)
|
||||
|
||||
self.grid.empty_label.hide()
|
||||
if self.grid.card_count() > 0:
|
||||
self.empty_hint.hide()
|
||||
self.grid.show()
|
||||
return
|
||||
|
||||
# The message depends on the mode: account mode does not use local
|
||||
# subscriptions at all, so telling someone to go and subscribe to
|
||||
# something would be wrong advice.
|
||||
if account_mode:
|
||||
message = _("feed.empty_account")
|
||||
elif not LibraryManager.subscriptions():
|
||||
message = _("feed.empty_no_subscriptions")
|
||||
else:
|
||||
message = _("feed.empty_not_refreshed")
|
||||
self.empty_hint.setText(message)
|
||||
|
||||
# Hide the empty grid so the hint sits in the middle of the page
|
||||
# rather than pinned under a large blank area.
|
||||
self.grid.hide()
|
||||
self.empty_hint.show()
|
||||
|
||||
# -------------------------------------------------------------- account
|
||||
|
||||
def _refresh_account(self) -> None:
|
||||
if self._account_worker is not None:
|
||||
return
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.status_label.setText(_("feed.fetching_account"))
|
||||
self._account_worker = YtdlpWorker(lambda c: c.fetch_account_feed(n=60), parent=self)
|
||||
self._account_worker.result.connect(self._on_account_feed)
|
||||
self._account_worker.error.connect(self._on_account_error)
|
||||
self._account_worker.finished.connect(self._on_account_finished)
|
||||
self._account_worker.start()
|
||||
|
||||
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
|
||||
self.grid.set_entries(entries)
|
||||
self.status_label.setText(_("feed.refreshed", count=len(entries)))
|
||||
# Without this the grid stays hidden behind the empty-state hint that
|
||||
# was shown at startup, so a successful refresh reported "60 videos in
|
||||
# feed" above a "No subscriptions yet" message and no videos.
|
||||
self._update_empty_state()
|
||||
|
||||
def _on_account_error(self, message: str) -> None:
|
||||
logger.error(f"Account feed failed: {message}")
|
||||
self.status_label.setText(_("feed.account_failed", error=message[:200]))
|
||||
self._update_empty_state()
|
||||
|
||||
def _on_account_finished(self) -> None:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
if self._account_worker is not None:
|
||||
self._account_worker.deleteLater()
|
||||
self._account_worker = None
|
||||
|
||||
# ------------------------------------------------------------- internal
|
||||
|
||||
def _update_mode_availability(self) -> None:
|
||||
cookies_on = bool(ConfigManager.get("cookie_active"))
|
||||
account_index = self.mode_combo.findData("account")
|
||||
model_item = self.mode_combo.model().item(account_index)
|
||||
if model_item is not None:
|
||||
model_item.setEnabled(cookies_on)
|
||||
# Say *why* it is unavailable. It used to be greyed out with no
|
||||
# explanation and no way to do anything about it.
|
||||
model_item.setToolTip("" if cookies_on else _("feed.account_requires_cookies"))
|
||||
if not cookies_on and self.mode_combo.currentData() == "account":
|
||||
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
|
||||
# The way out of that dead end: offered only while it is one.
|
||||
self.signin_btn.setVisible(not cookies_on)
|
||||
|
||||
def on_cookies_changed(self) -> None:
|
||||
"""Cookies were applied elsewhere; account mode may now be usable."""
|
||||
self._update_mode_availability()
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
self._update_mode_availability()
|
||||
super().showEvent(event)
|
||||
|
||||
def hideEvent(self, event) -> None:
|
||||
# Leaving the tab cancels an automatic refresh. cancel() existed and
|
||||
# was never called, so a background sweep kept running -- and closing
|
||||
# the window destroyed a live QThread.
|
||||
self._cancel_refresh()
|
||||
super().hideEvent(event)
|
||||
|
||||
def _cancel_refresh(self) -> None:
|
||||
worker = self._refresh_worker
|
||||
if worker is None:
|
||||
return
|
||||
worker.cancel()
|
||||
if not worker.wait(3000):
|
||||
logger.warning("Feed refresh did not stop in time.")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Called on application close."""
|
||||
self._cancel_refresh()
|
||||
|
||||
def _on_mode_changed(self) -> None:
|
||||
mode = self.mode_combo.currentData()
|
||||
ConfigManager.set("feed.mode", mode)
|
||||
# Switching modes left the previous mode's items on screen, so the
|
||||
# local feed appeared to be the account feed. Show what the new mode
|
||||
# actually has.
|
||||
if mode == "local":
|
||||
self._load_cached_feed()
|
||||
else:
|
||||
self.grid.set_entries([])
|
||||
self.status_label.setText(_("feed.account_needs_refresh"))
|
||||
self._update_empty_state()
|
||||
|
||||
def _on_sub_activated(self, item: QListWidgetItem) -> None:
|
||||
sub = item.data(Qt.ItemDataRole.UserRole)
|
||||
if sub and sub.get("url"):
|
||||
self._router.openChannel.emit(sub["url"])
|
||||
|
||||
def _on_subs_context_menu(self, pos) -> None:
|
||||
item = self.subs_list.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
sub = item.data(Qt.ItemDataRole.UserRole)
|
||||
menu = QMenu(self)
|
||||
open_action = menu.addAction(_("feed.open_channel"))
|
||||
unsub_action = menu.addAction(_("browse.unsubscribe"))
|
||||
action = menu.exec(self.subs_list.mapToGlobal(pos))
|
||||
if action is open_action and sub.get("url"):
|
||||
self._router.openChannel.emit(sub["url"])
|
||||
elif action is unsub_action:
|
||||
LibraryManager.unsubscribe(sub["channel_id"])
|
||||
self.reload_subscriptions()
|
||||
self._load_cached_feed()
|
||||
@@ -0,0 +1,613 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFontMetrics
|
||||
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
from ..utils.ytsage_localization import _
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class FormatSignals(QObject):
|
||||
format_update = Signal(list)
|
||||
|
||||
|
||||
class FormatTableMixin:
|
||||
def _calculate_column_width(self, label: str, min_width: int, padding: int) -> int:
|
||||
"""Calculate responsive column width based on header text length."""
|
||||
self = cast("YTSageApp", self)
|
||||
font_metrics = QFontMetrics(self.format_table.horizontalHeader().font())
|
||||
text_width = font_metrics.horizontalAdvance(label)
|
||||
return max(text_width + padding, min_width)
|
||||
|
||||
def _get_audio_codec_display(self, format_info: dict) -> str:
|
||||
"""
|
||||
Generate a display string for audio codec information.
|
||||
Highlights EAC3/AC3 and other surround sound formats with channel info.
|
||||
"""
|
||||
acodec = format_info.get("acodec")
|
||||
if not acodec or acodec == "none":
|
||||
return "N/A"
|
||||
|
||||
channels = format_info.get("audio_channels")
|
||||
abr = format_info.get("abr")
|
||||
|
||||
# Build codec string
|
||||
codec_str = str(acodec)
|
||||
|
||||
# Add channel info for surround sound formats
|
||||
if channels:
|
||||
codec_str += f" ({channels}ch)"
|
||||
|
||||
# Add bitrate if available
|
||||
if abr and isinstance(abr, (int, float)):
|
||||
if abr >= 1000:
|
||||
codec_str += f" {abr/1000:.1f}Mbps"
|
||||
else:
|
||||
codec_str += f" {int(abr)}kbps"
|
||||
|
||||
return codec_str
|
||||
|
||||
def _apply_column_widths(self, header_labels: list[str], is_playlist_mode: bool = False) -> None:
|
||||
"""Apply responsive column widths to format table."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
if is_playlist_mode:
|
||||
# Playlist mode: 6 columns
|
||||
configs = [
|
||||
{"min_width": 70, "padding": 40}, # Select
|
||||
{"min_width": 100, "padding": 30}, # Quality
|
||||
{"min_width": 100, "padding": 30}, # Resolution
|
||||
{"min_width": 60, "padding": 30}, # FPS
|
||||
{"min_width": 60, "padding": 30}, # HDR
|
||||
{"min_width": 100, "padding": 30}, # Audio
|
||||
]
|
||||
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
calculated_width = self._calculate_column_width(header_labels[0], configs[0]["min_width"], configs[0]["padding"])
|
||||
self.format_table.setColumnWidth(0, calculated_width)
|
||||
|
||||
# Remaining columns stretch
|
||||
for i in range(1, 6):
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch)
|
||||
else:
|
||||
# Normal mode: 9 columns
|
||||
configs = [
|
||||
{"min_width": 70, "padding": 40, "stretch": False}, # Select - fixed
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Quality - stretch
|
||||
{"min_width": 85, "padding": 30, "stretch": False}, # Extension - fixed
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Resolution - stretch
|
||||
{"min_width": 90, "padding": 30, "stretch": True}, # File Size - stretch
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Codec - stretch
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Audio - stretch
|
||||
{"min_width": 60, "padding": 30, "stretch": False}, # FPS - fixed
|
||||
{"min_width": 60, "padding": 30, "stretch": False}, # HDR - fixed
|
||||
]
|
||||
|
||||
# Apply column widths with mixed fixed and stretch modes
|
||||
for col_index, (label, config) in enumerate(zip(header_labels, configs)):
|
||||
calculated_width = self._calculate_column_width(label, config["min_width"], config["padding"])
|
||||
|
||||
if config["stretch"]:
|
||||
# Stretchable columns for flexible content
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(col_index, QHeaderView.ResizeMode.Stretch)
|
||||
else:
|
||||
# Fixed columns for consistent sizing
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(col_index, QHeaderView.ResizeMode.Fixed)
|
||||
self.format_table.setColumnWidth(col_index, calculated_width)
|
||||
|
||||
def setup_format_table(self) -> QTableWidget:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
self.format_signals = FormatSignals()
|
||||
# Format table with improved styling
|
||||
self.format_table = QTableWidget()
|
||||
self.format_table.setColumnCount(9)
|
||||
|
||||
# Get translated header labels
|
||||
header_labels = [
|
||||
_("formats.select"),
|
||||
_("formats.quality"),
|
||||
_("formats.extension"),
|
||||
_("formats.resolution"),
|
||||
_("formats.file_size"),
|
||||
_("formats.codec"),
|
||||
_("formats.audio"),
|
||||
_("formats.fps"),
|
||||
_("formats.hdr"),
|
||||
]
|
||||
self.format_table.setHorizontalHeaderLabels(header_labels)
|
||||
|
||||
# Enable alternating row colors
|
||||
self.format_table.setAlternatingRowColors(True)
|
||||
|
||||
# Apply responsive column widths
|
||||
self._apply_column_widths(header_labels, is_playlist_mode=False)
|
||||
|
||||
# Set vertical header (row numbers) visible to false
|
||||
self.format_table.verticalHeader().setVisible(False)
|
||||
|
||||
# Set selection mode to no selection (since we're using checkboxes)
|
||||
self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
|
||||
# Disable editing to prevent the selection box on double-click
|
||||
self.format_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
|
||||
self.format_table.setStyleSheet(
|
||||
"""
|
||||
QTableWidget {
|
||||
background-color: #1b2021;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
gridline-color: #1b2021;
|
||||
}
|
||||
QTableWidget::item {
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid #1b2021;
|
||||
}
|
||||
QTableWidget::item:selected {
|
||||
background-color: transparent;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background-color: #15181b;
|
||||
padding: 5px;
|
||||
border: 1px solid #1b2021;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
/* Style alternating rows with more contrast */
|
||||
QTableWidget::item:alternate {
|
||||
background-color: #212529;
|
||||
}
|
||||
QTableWidget::item {
|
||||
background-color: #16191b;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #15181b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
}
|
||||
QWidget {
|
||||
background-color: transparent;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Store format checkboxes and formats
|
||||
self.format_checkboxes = []
|
||||
self.all_formats = []
|
||||
self._row_format_type = [] # Track format type per row: 'video' or 'audio'
|
||||
self._table_built = False # Track if table has been built with current formats
|
||||
|
||||
# Set table size policies
|
||||
self.format_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
# Set minimum and maximum heights
|
||||
self.format_table.setMinimumHeight(200)
|
||||
|
||||
# Connect the signal
|
||||
self.format_signals.format_update.connect(self._update_format_table)
|
||||
|
||||
return self.format_table
|
||||
|
||||
def filter_formats(self) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if not hasattr(self, "all_formats") or not self.all_formats:
|
||||
return
|
||||
|
||||
# Check if we need to rebuild the table (first time or formats changed)
|
||||
if not self._table_built:
|
||||
self._build_full_format_table()
|
||||
return
|
||||
|
||||
# Use row visibility for fast filtering instead of rebuilding table
|
||||
show_video = hasattr(self, "video_button") and self.video_button.isChecked() # type: ignore[reportAttributeAccessIssue]
|
||||
show_audio = hasattr(self, "audio_button") and self.audio_button.isChecked() # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
for row, format_type in enumerate(self._row_format_type):
|
||||
if format_type == "video":
|
||||
self.format_table.setRowHidden(row, not show_video)
|
||||
else: # audio
|
||||
self.format_table.setRowHidden(row, not show_audio)
|
||||
|
||||
def _build_full_format_table(self) -> None:
|
||||
"""Build the complete format table once with all formats."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Clear current table
|
||||
self.format_table.setRowCount(0)
|
||||
self.format_checkboxes.clear()
|
||||
self._row_format_type.clear()
|
||||
|
||||
# Separate and filter formats
|
||||
video_formats = [f for f in self.all_formats if f.get("vcodec") != "none" and f.get("filesize") is not None]
|
||||
audio_formats = [
|
||||
f
|
||||
for f in self.all_formats
|
||||
if (f.get("vcodec") == "none" or "audio only" in str(f.get("format_note") or "").lower())
|
||||
and f.get("acodec") != "none"
|
||||
# Removed filesize requirement - many audio-only formats (including EAC3/AC3) may not have filesize in yt-dlp output
|
||||
# Include all audio formats to detect EAC3/AC3 and other surround sound codecs
|
||||
]
|
||||
|
||||
# Sort formats by quality
|
||||
def get_quality(f):
|
||||
if f.get("vcodec") != "none":
|
||||
resolution = f.get("resolution", "0x0")
|
||||
if resolution is None or not isinstance(resolution, str):
|
||||
return 0
|
||||
try:
|
||||
res = resolution.split("x")[-1]
|
||||
return int(res)
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
else:
|
||||
abr = f.get("abr") or 0
|
||||
return abr if isinstance(abr, (int, float)) else 0
|
||||
|
||||
video_formats.sort(key=get_quality, reverse=True)
|
||||
audio_formats.sort(key=get_quality, reverse=True)
|
||||
|
||||
# Combine: video first, then audio (maintains logical grouping)
|
||||
all_filtered = [(f, "video") for f in video_formats] + [(f, "audio") for f in audio_formats]
|
||||
|
||||
# Build table with format type tracking
|
||||
self.format_table.setVisible(False)
|
||||
self._populate_format_table(all_filtered)
|
||||
self._table_built = True
|
||||
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
default_video_quality = ConfigManager.get("default_video_quality")
|
||||
|
||||
# Auto-select the default or best format
|
||||
if self.format_checkboxes:
|
||||
found_default = False
|
||||
if default_video_quality:
|
||||
# Try to find a video format with the requested height (e.g. '1080')
|
||||
target_height = str(default_video_quality)
|
||||
# the formats are sorted by quality, so the first match is typically the best one for that height
|
||||
for idx, (f, fmt_type) in enumerate(all_filtered):
|
||||
if fmt_type == "video":
|
||||
res = str(f.get("resolution", ""))
|
||||
if res.endswith(f"x{target_height}") or target_height in res:
|
||||
self.format_checkboxes[idx].setChecked(True)
|
||||
self.handle_checkbox_click(self.format_checkboxes[idx])
|
||||
found_default = True
|
||||
break
|
||||
|
||||
if not found_default:
|
||||
# Fallback to the first available format (which is the best quality since it's sorted)
|
||||
self.format_checkboxes[0].setChecked(True)
|
||||
self.handle_checkbox_click(self.format_checkboxes[0])
|
||||
|
||||
# Apply initial visibility based on current button states
|
||||
self.filter_formats()
|
||||
|
||||
# Animate table appearance
|
||||
if hasattr(self, "animate_widget_fade_in"):
|
||||
self.animate_widget_fade_in(self.format_table)
|
||||
else:
|
||||
self.format_table.setVisible(True)
|
||||
|
||||
def _populate_format_table(self, formats_with_types: list) -> None:
|
||||
"""Populate the format table with formats and their types."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
is_playlist_mode = hasattr(self, "is_playlist") and self.is_playlist # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Configure columns based on mode
|
||||
if is_playlist_mode:
|
||||
self.format_table.setColumnCount(6)
|
||||
header_labels = [_("formats.select"), _("formats.quality"), _("formats.resolution"), _("formats.fps"), _("formats.hdr"), _("formats.audio")]
|
||||
self.format_table.setHorizontalHeaderLabels(header_labels)
|
||||
|
||||
# Configure column visibility and resizing for playlist mode
|
||||
self.format_table.setColumnHidden(6, True)
|
||||
self.format_table.setColumnHidden(7, True)
|
||||
self.format_table.setColumnHidden(8, True)
|
||||
|
||||
# Apply responsive column widths for playlist mode
|
||||
self._apply_column_widths(header_labels, is_playlist_mode=True)
|
||||
|
||||
else:
|
||||
self.format_table.setColumnCount(9)
|
||||
header_labels = [
|
||||
_("formats.select"),
|
||||
_("formats.quality"),
|
||||
_("formats.extension"),
|
||||
_("formats.resolution"),
|
||||
_("formats.file_size"),
|
||||
_("formats.codec"),
|
||||
_("formats.audio"),
|
||||
_("formats.fps"),
|
||||
_("formats.hdr"),
|
||||
]
|
||||
self.format_table.setHorizontalHeaderLabels(header_labels)
|
||||
self._apply_column_widths(header_labels, is_playlist_mode=False)
|
||||
|
||||
for f, format_type in formats_with_types:
|
||||
row = self.format_table.rowCount()
|
||||
self.format_table.insertRow(row)
|
||||
self._row_format_type.append(format_type)
|
||||
|
||||
# Create checkbox widget
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }")
|
||||
checkbox.format_id = f["format_id"]
|
||||
checkbox.is_audio_only = f.get("vcodec") == "none"
|
||||
# A missing acodec means unknown, not progressive - treating it as
|
||||
# has-audio skips the +bestaudio merge and yields silent videos
|
||||
acodec = f.get("acodec")
|
||||
checkbox.has_audio = acodec is not None and acodec != "none"
|
||||
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
|
||||
self.format_checkboxes.append(checkbox)
|
||||
|
||||
# Create a container widget for the checkbox
|
||||
checkbox_container = QWidget()
|
||||
checkbox_layout = QHBoxLayout(checkbox_container)
|
||||
checkbox_layout.addWidget(checkbox)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.format_table.setCellWidget(row, 0, checkbox_container)
|
||||
|
||||
# Quality label with color coding
|
||||
quality_label = self.get_quality_label(f)
|
||||
if is_playlist_mode and f.get("vcodec") != "none":
|
||||
quality_label = f"≤ {quality_label}"
|
||||
|
||||
quality_item = QTableWidgetItem(quality_label)
|
||||
# Set color based on quality (check multiple language terms)
|
||||
quality_lower = quality_label.lower()
|
||||
if any(term.lower() in quality_lower for term in ["Best", "Óptima", "Mejor", "Melhor", "Лучшее", "最佳", "Beste", "Meilleure", "सर्वोत्तम", "Terbaik", "En iyi", "Najlepsza", "Najlepszy", "Najlepsze", "Migliore", "Miglior", "الأفضل", "أفضل", "最高"]):
|
||||
quality_item.setForeground(QColor("#00ff00")) # Green for best quality
|
||||
elif any(term.lower() in quality_lower for term in ["High", "Alta", "Alto", "Áudio Alto", "Audio Alto", "Высокое", "高清", "高质量", "Hoch", "Haute", "Élevé", "Audio élevé", "उच्च", "उच्च ऑडियो", "Tinggi", "Audio tinggi", "Yüksek", "Yüksek ses", "Wysoka", "Wysoki", "Wysokie", "Audio alto", "عالية", "عالي", "صوت عالي", "高", "高音質"]):
|
||||
quality_item.setForeground(QColor("#00cc00")) # Light green for high quality
|
||||
elif any(term.lower() in quality_lower for term in ["Medium", "Media", "Medio", "Média", "Áudio Médio", "Audio Medio", "Среднее", "中等", "Mittel", "Moyenne", "Audio moyen", "मध्यम", "मध्यम ऑडियो", "Sedang", "Audio sedang", "Orta", "Orta ses", "Średnia", "Średni", "Średnie", "Audio medio", "متوسطة", "متوسط", "صوت متوسط", "中", "中音質"]):
|
||||
quality_item.setForeground(QColor("#ffaa00")) # Orange for medium quality
|
||||
elif any(term.lower() in quality_lower for term in ["Low", "Baja", "Bajo", "Baixa", "Áudio Baixo", "Audio Bajo", "Низкое", "低质量", "Niedrig", "Niedriges Audio", "Faible", "Audio faible", "Qualité faible", "निम्न", "निम्न ऑडियो", "निम्न गुणवत्ता", "Rendah", "Audio rendah", "Kualitas rendah", "Düşük", "Düşük ses", "Düşük kalite", "Niska", "Niski", "Niskie", "Bassa", "Audio basso", "Bassa qualità", "منخفضة", "منخفض", "صوت منخفض", "جودة منخفضة", "低", "低音質", "低品質"]):
|
||||
quality_item.setForeground(QColor("#ff5555")) # Red for low quality
|
||||
self.format_table.setItem(row, 1, quality_item)
|
||||
|
||||
# Resolution
|
||||
resolution = f.get("resolution") or "N/A"
|
||||
if not isinstance(resolution, str):
|
||||
resolution = str(resolution)
|
||||
|
||||
if is_playlist_mode and f.get("vcodec") != "none" and resolution != "N/A":
|
||||
resolution = f"≤ {resolution}"
|
||||
|
||||
if is_playlist_mode:
|
||||
# Column 2 for playlist mode: Resolution
|
||||
self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
|
||||
|
||||
# Column 3: FPS (Frame Rate)
|
||||
fps_value = f.get("fps")
|
||||
if fps_value is not None and fps_value >= 1:
|
||||
fps_text = f"{fps_value:.0f}fps"
|
||||
else:
|
||||
fps_text = "N/A"
|
||||
fps_item = QTableWidgetItem(fps_text)
|
||||
if fps_value and fps_value >= 60:
|
||||
fps_item.setForeground(QColor("#00ff00"))
|
||||
elif fps_value and fps_value >= 30:
|
||||
fps_item.setForeground(QColor("#ffaa00"))
|
||||
elif fps_value and fps_value >= 1:
|
||||
fps_item.setForeground(QColor("#ff5555"))
|
||||
else:
|
||||
fps_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 3, fps_item)
|
||||
|
||||
# Column 4: HDR
|
||||
if f.get("vcodec") == "none":
|
||||
hdr_text = "N/A"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
else:
|
||||
hdr_value = f.get("dynamic_range")
|
||||
if hdr_value and hdr_value != "SDR":
|
||||
hdr_text = hdr_value
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#00ffff"))
|
||||
else:
|
||||
hdr_text = "SDR"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 4, hdr_item)
|
||||
else:
|
||||
# Extension for normal mode (column 2)
|
||||
extension = str(f.get("ext") or "")
|
||||
self.format_table.setItem(row, 2, QTableWidgetItem(extension.upper()))
|
||||
|
||||
# Audio Status column
|
||||
needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none"
|
||||
acodec = f.get("acodec")
|
||||
|
||||
# Enhanced audio status with support for surround sound formats (EAC3/AC3)
|
||||
if needs_audio:
|
||||
audio_status = _("formats.will_merge_audio")
|
||||
audio_color = QColor("#ffa500")
|
||||
elif f.get("vcodec") != "none":
|
||||
# Video format with audio
|
||||
if acodec in ["ac3", "eac3"]:
|
||||
# Highlight surround sound codecs
|
||||
channels = f.get("audio_channels", "")
|
||||
if channels:
|
||||
audio_status = f"{_('formats.has_audio')} - {acodec.upper()} {channels}ch"
|
||||
else:
|
||||
audio_status = f"{_('formats.has_audio')} - {acodec.upper()}"
|
||||
audio_color = QColor("#00ccff") # Cyan for surround sound
|
||||
else:
|
||||
audio_status = _("formats.has_audio")
|
||||
audio_color = QColor("#00cc00")
|
||||
else:
|
||||
# Audio-only format
|
||||
if acodec in ["ac3", "eac3"]:
|
||||
# Highlight surround sound audio-only
|
||||
channels = f.get("audio_channels", "")
|
||||
if channels:
|
||||
audio_status = f"{acodec.upper()} {channels}ch"
|
||||
else:
|
||||
audio_status = acodec.upper()
|
||||
audio_color = QColor("#00ccff") # Cyan for surround sound
|
||||
else:
|
||||
audio_status = _("formats.audio_only")
|
||||
audio_color = QColor("#cccccc")
|
||||
|
||||
audio_item = QTableWidgetItem(audio_status)
|
||||
audio_item.setForeground(audio_color)
|
||||
audio_column_index = 5 if is_playlist_mode else 6
|
||||
self.format_table.setItem(row, audio_column_index, audio_item)
|
||||
|
||||
# Populate columns only shown in non-playlist mode
|
||||
if not is_playlist_mode:
|
||||
# Column 3: Resolution
|
||||
self.format_table.setItem(row, 3, QTableWidgetItem(resolution))
|
||||
|
||||
# Column 4: File Size
|
||||
filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
|
||||
self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
|
||||
|
||||
# Column 5: Codec
|
||||
if f.get("vcodec") == "none":
|
||||
# Audio-only format - display codec with channel info
|
||||
codec = str(f.get("acodec") or "N/A")
|
||||
# Add audio channel information if available (e.g., for 5.1 detection)
|
||||
channels = f.get("audio_channels")
|
||||
if channels:
|
||||
codec += f" ({channels}ch)"
|
||||
else:
|
||||
# Video format - display video codec and audio codec if present
|
||||
codec = str(f.get("vcodec") or "N/A")
|
||||
if f.get("acodec") != "none":
|
||||
acodec = str(f.get("acodec") or "N/A")
|
||||
# Add audio channel info for video with audio
|
||||
channels = f.get("audio_channels")
|
||||
if channels:
|
||||
acodec += f" ({channels}ch)"
|
||||
codec += f" / {acodec}"
|
||||
self.format_table.setItem(row, 5, QTableWidgetItem(codec))
|
||||
|
||||
# Column 7: FPS (Frame Rate)
|
||||
fps_value = f.get("fps")
|
||||
if fps_value is not None and fps_value >= 1:
|
||||
fps_text = f"{fps_value:.0f}fps"
|
||||
else:
|
||||
fps_text = "N/A"
|
||||
|
||||
fps_item = QTableWidgetItem(fps_text)
|
||||
if fps_value and fps_value >= 60:
|
||||
fps_item.setForeground(QColor("#00ff00"))
|
||||
elif fps_value and fps_value >= 30:
|
||||
fps_item.setForeground(QColor("#ffaa00"))
|
||||
elif fps_value and fps_value >= 1:
|
||||
fps_item.setForeground(QColor("#ff5555"))
|
||||
else:
|
||||
fps_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 7, fps_item)
|
||||
|
||||
# Column 8: HDR (Dynamic Range)
|
||||
if f.get("vcodec") == "none":
|
||||
hdr_text = "N/A"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
else:
|
||||
hdr_value = f.get("dynamic_range")
|
||||
if hdr_value and hdr_value != "SDR":
|
||||
hdr_text = hdr_value
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#00ffff"))
|
||||
else:
|
||||
hdr_text = "SDR"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 8, hdr_item)
|
||||
|
||||
def _update_format_table(self, formats) -> None:
|
||||
"""Signal handler that triggers a full table rebuild when formats change."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Mark table as needing rebuild and trigger it
|
||||
self._table_built = False
|
||||
self._build_full_format_table()
|
||||
|
||||
def handle_checkbox_click(self, clicked_checkbox) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
for checkbox in self.format_checkboxes:
|
||||
if checkbox != clicked_checkbox:
|
||||
checkbox.setChecked(False)
|
||||
|
||||
def get_selected_format(self):
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
for checkbox in self.format_checkboxes:
|
||||
if checkbox.isChecked():
|
||||
return {
|
||||
"format_id": checkbox.format_id,
|
||||
"is_audio_only": getattr(checkbox, "is_audio_only", False),
|
||||
"has_audio": getattr(checkbox, "has_audio", False),
|
||||
}
|
||||
return None
|
||||
|
||||
def update_format_table(self, formats) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
self.all_formats = formats
|
||||
self._table_built = False # Reset flag to trigger rebuild with new formats
|
||||
self.format_signals.format_update.emit(formats)
|
||||
|
||||
def get_quality_label(self, format_info) -> str:
|
||||
"""Determine quality label based on format information"""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if format_info.get("vcodec") == "none":
|
||||
# Audio quality
|
||||
abr = format_info.get("abr") or 0
|
||||
if not isinstance(abr, (int, float)):
|
||||
abr = 0
|
||||
if abr >= 256:
|
||||
return _("formats.best_audio")
|
||||
elif abr >= 192:
|
||||
return _("formats.high_audio")
|
||||
elif abr >= 128:
|
||||
return _("formats.medium_audio")
|
||||
else:
|
||||
return _("formats.low_audio")
|
||||
else:
|
||||
# Video quality
|
||||
height = 0
|
||||
resolution = format_info.get("resolution", "")
|
||||
if resolution:
|
||||
try:
|
||||
parts = resolution.split("x")
|
||||
if len(parts) == 2:
|
||||
# Use the smaller dimension to correctly classify vertical videos
|
||||
height = min(int(parts[0]), int(parts[1]))
|
||||
except:
|
||||
pass
|
||||
|
||||
if height >= 2160:
|
||||
return _("formats.best_4k")
|
||||
elif height >= 1440:
|
||||
return _("formats.best_2k")
|
||||
elif height >= 1080:
|
||||
return _("formats.high_1080p")
|
||||
elif height >= 720:
|
||||
return _("formats.high_720p")
|
||||
elif height >= 480:
|
||||
return _("formats.medium_480p")
|
||||
else:
|
||||
return _("formats.low_quality")
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
AppRouter - cross-tab navigation signals
|
||||
========================================
|
||||
|
||||
A tiny QObject signal hub connecting the watch-first pages (search, feed,
|
||||
browse) with the player and the downloader tab. Pages emit; the main window
|
||||
routes. Entry dicts are yt-dlp flat entries or the subset
|
||||
{id, url, title, channel, channel_url, duration, thumbnail}.
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
|
||||
class AppRouter(QObject):
|
||||
playVideo = Signal(dict) # play immediately in the Watch tab
|
||||
queueVideo = Signal(dict) # append to the play queue
|
||||
downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed
|
||||
openChannel = Signal(str) # open a channel URL in the Browse tab
|
||||
openPlaylist = Signal(str) # open a playlist URL in the Browse tab
|
||||
# Cookie settings were applied. YtdlpClient re-reads config per call so it
|
||||
# needs no telling, but the account button, the Feed's mode availability
|
||||
# and the live mpv handle all do -- otherwise signing in only takes effect
|
||||
# after a restart.
|
||||
cookiesChanged = Signal()
|
||||
# Somewhere in the UI asked for the cookie login dialog.
|
||||
cookieSetupRequested = Signal()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Search tab - in-app YouTube search via yt-dlp (ytsearchN:)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from .ytsage_gui_cards import VideoCardGrid
|
||||
from ..core.ytsage_client import YtdlpWorker
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
PAGE_SIZE = 24
|
||||
|
||||
|
||||
class SearchPage(QWidget):
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._worker: Optional[YtdlpWorker] = None
|
||||
self._query = ""
|
||||
self._offset = 0
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.query_input = QLineEdit()
|
||||
self.query_input.setPlaceholderText(_("search.placeholder"))
|
||||
self.query_input.returnPressed.connect(self.start_search)
|
||||
self.query_input.setMinimumHeight(38)
|
||||
bar.addWidget(self.query_input, stretch=1)
|
||||
|
||||
self.search_btn = QPushButton(_("search.button"))
|
||||
self.search_btn.setToolTip(_("search.button_tooltip"))
|
||||
self.search_btn.clicked.connect(self.start_search)
|
||||
self.search_btn.setMinimumHeight(38)
|
||||
bar.addWidget(self.search_btn)
|
||||
layout.addLayout(bar)
|
||||
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #9aa0a6; padding: 2px;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self.grid = VideoCardGrid(router, self)
|
||||
self.grid.loadMoreRequested.connect(self.load_more)
|
||||
layout.addWidget(self.grid, stretch=1)
|
||||
|
||||
# ---------------------------------------------------------------- search
|
||||
|
||||
def start_search(self) -> None:
|
||||
query = self.query_input.text().strip()
|
||||
if not query or self._worker is not None:
|
||||
return
|
||||
self._query = query
|
||||
self._offset = 0
|
||||
self.grid.clear()
|
||||
self._fetch(append=False)
|
||||
|
||||
def load_more(self) -> None:
|
||||
if self._worker is None and self._query:
|
||||
self._offset += PAGE_SIZE
|
||||
self._fetch(append=True)
|
||||
|
||||
def _fetch(self, append: bool) -> None:
|
||||
self.status_label.setText(_("search.searching"))
|
||||
self.search_btn.setEnabled(False)
|
||||
query, offset = self._query, self._offset
|
||||
self._worker = YtdlpWorker(lambda c: c.search(query, n=PAGE_SIZE, offset=offset), parent=self)
|
||||
self._worker.result.connect(lambda entries: self._on_results(entries, append))
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.finished.connect(self._on_finished)
|
||||
self._worker.start()
|
||||
|
||||
def _on_results(self, entries: List[Dict[str, Any]], append: bool) -> None:
|
||||
show_more = len(entries) >= PAGE_SIZE
|
||||
if append:
|
||||
self.grid.append_entries(entries, show_load_more=show_more)
|
||||
else:
|
||||
self.grid.set_entries(entries, show_load_more=show_more)
|
||||
self.status_label.setText(_("search.results_count", count=self.grid.card_count()))
|
||||
|
||||
def _on_error(self, message: str) -> None:
|
||||
logger.error(f"Search failed: {message}")
|
||||
self.status_label.setText(_("search.failed", error=message[:200]))
|
||||
|
||||
def _on_finished(self) -> None:
|
||||
self.search_btn.setEnabled(True)
|
||||
if self._worker is not None:
|
||||
self._worker.deleteLater()
|
||||
self._worker = None
|
||||
@@ -0,0 +1,473 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from .ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
|
||||
SponsorBlockCategoryDialog,
|
||||
SubtitleSelectionDialog,
|
||||
)
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class ThumbnailDownloadThread(QThread):
|
||||
"""Thread to download thumbnail image asynchronously."""
|
||||
finished = Signal(bytes)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, url):
|
||||
super().__init__()
|
||||
self.url = url
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get(self.url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
self.finished.emit(response.content)
|
||||
else:
|
||||
self.error.emit(f"HTTP Error: {response.status_code}")
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class VideoInfoMixin:
|
||||
def setup_video_info_section(self) -> QHBoxLayout:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Create a horizontal layout for thumbnail and video info
|
||||
media_info_layout = QHBoxLayout()
|
||||
media_info_layout.setSpacing(15)
|
||||
|
||||
# Left side container for thumbnail
|
||||
thumbnail_container = QWidget()
|
||||
thumbnail_container.setFixedWidth(320)
|
||||
thumbnail_layout = QVBoxLayout(thumbnail_container)
|
||||
thumbnail_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Thumbnail on the left
|
||||
self.thumbnail_label = QLabel()
|
||||
self.thumbnail_label.setFixedSize(320, 180)
|
||||
self.thumbnail_label.setStyleSheet("border: 2px solid #3d3d3d; border-radius: 4px;")
|
||||
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
thumbnail_layout.addWidget(self.thumbnail_label)
|
||||
thumbnail_layout.addStretch()
|
||||
|
||||
media_info_layout.addWidget(thumbnail_container)
|
||||
|
||||
# Video information on the right
|
||||
video_info_layout = QVBoxLayout()
|
||||
video_info_layout.setSpacing(2) # Reduce spacing between elements
|
||||
video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# Title and info labels
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setWordWrap(True)
|
||||
self.title_label.setStyleSheet("font-size: 12px; font-weight: bold;")
|
||||
|
||||
# Add basic info labels
|
||||
self.channel_label = QLabel()
|
||||
self.views_label = QLabel()
|
||||
self.date_label = QLabel()
|
||||
self.duration_label = QLabel()
|
||||
self.like_count_label = QLabel()
|
||||
|
||||
# Style the info labels
|
||||
for label in [
|
||||
self.channel_label,
|
||||
self.views_label,
|
||||
self.date_label,
|
||||
self.duration_label,
|
||||
self.like_count_label,
|
||||
]:
|
||||
label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
color: #999999;
|
||||
font-size: 11px;
|
||||
padding: 0px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Add labels to video info layout
|
||||
video_info_layout.addWidget(self.title_label)
|
||||
video_info_layout.addWidget(self.channel_label)
|
||||
video_info_layout.addWidget(self.views_label)
|
||||
video_info_layout.addWidget(self.like_count_label)
|
||||
video_info_layout.addWidget(self.date_label)
|
||||
video_info_layout.addWidget(self.duration_label)
|
||||
|
||||
# Add spacing before subtitle section
|
||||
video_info_layout.addSpacing(10)
|
||||
|
||||
# --- Subtitle Section ---
|
||||
subtitle_layout = QHBoxLayout()
|
||||
subtitle_layout.setSpacing(10)
|
||||
|
||||
# Subtitle selection button
|
||||
self.subtitle_select_btn = QPushButton(_("main_ui.select_subtitles")) # Renamed & changed text
|
||||
self.subtitle_select_btn.setFixedHeight(30)
|
||||
# self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed
|
||||
self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog)
|
||||
self.subtitle_select_btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #1d1e22;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px; /* Adjusted padding */
|
||||
}
|
||||
QPushButton:hover { background-color: #2a2d36; }
|
||||
/* Optional: Style differently if subtitles ARE selected */
|
||||
QPushButton[subtitlesSelected="true"] {
|
||||
border-color: #c90000; /* Indicate selection */
|
||||
}
|
||||
/* Style for disabled state */
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
border-color: #3d3d3d;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling
|
||||
subtitle_layout.addWidget(self.subtitle_select_btn)
|
||||
|
||||
# Label to show number of selected subtitles
|
||||
self.selected_subs_label = QLabel(_("selection.none_selected"))
|
||||
self.selected_subs_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
|
||||
subtitle_layout.addWidget(self.selected_subs_label)
|
||||
|
||||
# Add the subtitle layout to the main video info layout
|
||||
video_info_layout.addLayout(subtitle_layout)
|
||||
# --- End Subtitle Section ---
|
||||
|
||||
# Add small spacing between subtitle and sponsorblock sections
|
||||
video_info_layout.addSpacing(4)
|
||||
|
||||
# --- SponsorBlock Section ---
|
||||
sponsorblock_layout = QHBoxLayout()
|
||||
|
||||
self.sponsorblock_select_btn = QPushButton(_("main_ui.sponsorblock_categories"))
|
||||
self.sponsorblock_select_btn.setFixedHeight(30)
|
||||
self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog)
|
||||
self.sponsorblock_select_btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #1d1e22;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #2a2d36;
|
||||
}
|
||||
QPushButton[sponsorBlockSelected="true"] {
|
||||
border-color: #c90000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
border-color: #3d3d3d;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
||||
sponsorblock_layout.addWidget(self.sponsorblock_select_btn)
|
||||
|
||||
# Label to show selection count
|
||||
self.selected_sponsorblock_label = QLabel(_("selection.none_selected"))
|
||||
self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
|
||||
sponsorblock_layout.addWidget(self.selected_sponsorblock_label)
|
||||
|
||||
sponsorblock_layout.addStretch()
|
||||
|
||||
# Add the sponsorblock layout to the main video info layout
|
||||
video_info_layout.addLayout(sponsorblock_layout)
|
||||
# --- End SponsorBlock Section ---
|
||||
|
||||
# Initialize SponsorBlock categories as empty initially (will be set to defaults when user opens dialog)
|
||||
self.selected_sponsorblock_categories = []
|
||||
self._update_sponsorblock_display()
|
||||
|
||||
# Add stretch at the bottom
|
||||
video_info_layout.addStretch()
|
||||
|
||||
# Add video info layout to main layout
|
||||
media_info_layout.addLayout(video_info_layout, stretch=1)
|
||||
|
||||
return media_info_layout
|
||||
|
||||
def setup_playlist_info_section(self) -> QLabel:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
self.playlist_info_label = QLabel()
|
||||
self.playlist_info_label.setVisible(False)
|
||||
self.playlist_info_label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
padding: 5px 8px;
|
||||
margin: 0;
|
||||
background-color: #1d1e22;
|
||||
border: 1px solid #c90000;
|
||||
border-radius: 4px;
|
||||
min-height: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
return self.playlist_info_label
|
||||
|
||||
def update_video_info(self, info) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if hasattr(self, "is_playlist") and self.is_playlist:
|
||||
# Playlist Mode: Show playlist title and video count
|
||||
self.title_label.setText(self.playlist_info.get("title", _("playlist.unknown")))
|
||||
|
||||
num_videos = len(getattr(self, "playlist_entries", []))
|
||||
self.duration_label.setText(_("playlist.total_videos", count=num_videos))
|
||||
|
||||
# Hide video-specific info
|
||||
self.channel_label.setText("")
|
||||
self.views_label.setText("")
|
||||
self.date_label.setText("")
|
||||
self.like_count_label.setText("")
|
||||
self.channel_label.setVisible(False)
|
||||
self.views_label.setVisible(False)
|
||||
self.date_label.setVisible(False)
|
||||
self.like_count_label.setVisible(False)
|
||||
else:
|
||||
# Single Video Mode: Show standard video info
|
||||
# Ensure labels are visible first
|
||||
self.channel_label.setVisible(True)
|
||||
self.views_label.setVisible(True)
|
||||
self.date_label.setVisible(True)
|
||||
self.like_count_label.setVisible(True)
|
||||
|
||||
# Format view count with commas
|
||||
views = info.get("view_count")
|
||||
formatted_views = f"{views:,}" if views is not None else "N/A"
|
||||
|
||||
# Format like count with commas
|
||||
likes = info.get("like_count")
|
||||
formatted_likes = f"{likes:,}" if likes is not None else "N/A"
|
||||
|
||||
# Format upload date
|
||||
upload_date = info.get("upload_date", "")
|
||||
if upload_date:
|
||||
date_obj = datetime.strptime(upload_date, "%Y%m%d")
|
||||
formatted_date = date_obj.strftime("%B %d, %Y")
|
||||
else:
|
||||
formatted_date = _("video_info.unknown_date")
|
||||
|
||||
# Format duration
|
||||
duration = info.get("duration", 0)
|
||||
minutes = duration // 60
|
||||
seconds = duration % 60
|
||||
duration_str = f"{minutes}:{seconds:02d}"
|
||||
|
||||
# Update labels with localized text
|
||||
self.title_label.setText(info.get("title", _("video_info.unknown_title")))
|
||||
self.channel_label.setText(f"{_("video_info.channel")}: {info.get('uploader', _("video_info.unknown_channel"))}")
|
||||
self.views_label.setText(f"{_("video_info.views")}: {formatted_views}")
|
||||
self.like_count_label.setText(f"{_("video_info.likes")}: {formatted_likes}")
|
||||
self.date_label.setText(f"{_("video_info.upload_date")}: {formatted_date}")
|
||||
self.duration_label.setText(f"{_("video_info.duration")}: {duration_str}")
|
||||
|
||||
def open_subtitle_dialog(self) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
|
||||
logger.warning("Subtitle info not loaded yet.")
|
||||
return
|
||||
|
||||
if not hasattr(self, "selected_subtitles"):
|
||||
self.selected_subtitles = []
|
||||
|
||||
dialog = SubtitleSelectionDialog(
|
||||
self.available_subtitles, # type: ignore[reportAttributeAccessIssue]
|
||||
self.available_automatic_subtitles, # type: ignore[reportAttributeAccessIssue]
|
||||
self.selected_subtitles,
|
||||
self, # Parent for the dialog
|
||||
)
|
||||
|
||||
# removed extra logic for mapping to main_windows
|
||||
merge_checkbox = getattr(self, "merge_subs_checkbox", None)
|
||||
|
||||
if self.run_dialog_with_blur(dialog): # If user clicks OK
|
||||
self.selected_subtitles = dialog.get_selected_subtitles()
|
||||
logger.info(f"Selected subtitles: {self.selected_subtitles}")
|
||||
# Update UI to reflect selection
|
||||
count = len(self.selected_subtitles)
|
||||
self.selected_subs_label.setText(_("subtitle_selection.count_selected", count=count))
|
||||
self.subtitle_select_btn.setProperty("subtitlesSelected", count > 0)
|
||||
|
||||
# Enable/disable the merge checkbox in the parent window
|
||||
if merge_checkbox:
|
||||
# Only enable merge checkbox if we're not in Audio Only mode and analysis is complete
|
||||
is_audio_only = hasattr(self, "audio_button") and self.audio_button.isChecked()
|
||||
has_analysis = getattr(self, "analysis_completed", False)
|
||||
# In audio-only mode, we still allow subtitle selection but not merging
|
||||
should_enable = count > 0 and not is_audio_only and has_analysis
|
||||
merge_checkbox.setEnabled(should_enable)
|
||||
# Update tooltip
|
||||
if not has_analysis:
|
||||
merge_checkbox.setToolTip(_("main_ui.analyze_first_tooltip"))
|
||||
elif is_audio_only:
|
||||
merge_checkbox.setToolTip(_("main_ui.audio_mode_disabled"))
|
||||
elif count == 0:
|
||||
merge_checkbox.setToolTip(_("main_ui.select_subtitles_first"))
|
||||
else:
|
||||
merge_checkbox.setToolTip("")
|
||||
else:
|
||||
logger.warning("merge_subs_checkbox not found on parent window.")
|
||||
|
||||
# Re-apply stylesheet to update button border if property changed
|
||||
self.subtitle_select_btn.style().unpolish(self.subtitle_select_btn)
|
||||
self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
|
||||
# No else needed for cancel, state remains unchanged
|
||||
|
||||
def open_sponsorblock_dialog(self) -> None:
|
||||
"""Open the SponsorBlock category selection dialog."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Initialize selected categories if not exists or empty (first time opening)
|
||||
if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories:
|
||||
# Use None to let the dialog set its own defaults
|
||||
dialog_categories = None
|
||||
else:
|
||||
dialog_categories = self.selected_sponsorblock_categories
|
||||
|
||||
dialog = SponsorBlockCategoryDialog(dialog_categories, self)
|
||||
|
||||
if self.run_dialog_with_blur(dialog):
|
||||
self.selected_sponsorblock_categories = dialog.get_selected_categories()
|
||||
logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}")
|
||||
self._update_sponsorblock_display()
|
||||
|
||||
def _update_sponsorblock_display(self) -> None:
|
||||
"""Update the SponsorBlock button and label to reflect current selection."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if not hasattr(self, "selected_sponsorblock_categories"):
|
||||
self.selected_sponsorblock_categories = []
|
||||
|
||||
count = len(self.selected_sponsorblock_categories)
|
||||
|
||||
# Update label text
|
||||
if count == 0:
|
||||
self.selected_sponsorblock_label.setText(_("selection.none_selected"))
|
||||
elif count == 1:
|
||||
self.selected_sponsorblock_label.setText(_("selection.one_selected"))
|
||||
else:
|
||||
self.selected_sponsorblock_label.setText(_("selection.count_selected", count=count))
|
||||
|
||||
# Update button property for styling
|
||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0)
|
||||
|
||||
# Force style refresh
|
||||
self.sponsorblock_select_btn.style().unpolish(self.sponsorblock_select_btn)
|
||||
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
|
||||
|
||||
def download_thumbnail(self, url) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Store both thumbnail URL and video URL
|
||||
self.thumbnail_url = url
|
||||
self.video_url = self.url_input.text() # Get actual video URL
|
||||
|
||||
# Create and start loader thread
|
||||
# Keep reference to avoid garbage collection
|
||||
self.thumbnail_thread = ThumbnailDownloadThread(url)
|
||||
self.thumbnail_thread.finished.connect(self._on_thumbnail_downloaded)
|
||||
self.thumbnail_thread.error.connect(lambda e: logger.error(f"Error loading thumbnail: {e}"))
|
||||
self.thumbnail_thread.start()
|
||||
|
||||
def _on_thumbnail_downloaded(self, content: bytes) -> None:
|
||||
self = cast("YTSageApp", self)
|
||||
try:
|
||||
self.thumbnail_image = Image.open(BytesIO(content))
|
||||
|
||||
# Display thumbnail
|
||||
image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS)
|
||||
img_byte_arr = BytesIO()
|
||||
image.save(img_byte_arr, format="PNG")
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(img_byte_arr.getvalue())
|
||||
|
||||
# Fade in the thumbnail
|
||||
self.thumbnail_label.setVisible(False)
|
||||
self.thumbnail_label.setPixmap(pixmap)
|
||||
|
||||
if hasattr(self, "animate_widget_fade_in"):
|
||||
self.animate_widget_fade_in(self.thumbnail_label)
|
||||
else:
|
||||
self.thumbnail_label.setVisible(True)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing thumbnail image: {e}")
|
||||
|
||||
def download_thumbnail_file(self, video_url, path) -> bool:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
if not self.save_thumbnail:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Use cached thumbnail image from analysis if available
|
||||
if self.thumbnail_image is None:
|
||||
logger.info("No thumbnail image cached from analysis")
|
||||
self.signals.update_status.emit(_("status.thumbnail_no_image"))
|
||||
return False
|
||||
|
||||
# Get video title from cached video_info
|
||||
video_title = "thumbnail"
|
||||
if self.video_info and "title" in self.video_info:
|
||||
video_title = self.video_info["title"]
|
||||
elif self.playlist_info and "title" in self.playlist_info:
|
||||
video_title = self.playlist_info["title"]
|
||||
|
||||
logger.debug(f"Saving cached thumbnail for: {video_title}")
|
||||
|
||||
# Save the thumbnail
|
||||
thumb_dir = Path(path).joinpath("Thumbnails")
|
||||
thumb_dir.mkdir(exist_ok=True)
|
||||
|
||||
filename = f"{self.sanitize_filename(video_title)}.jpg"
|
||||
thumbnail_path = thumb_dir.joinpath(filename)
|
||||
|
||||
# Save the cached PIL Image directly
|
||||
# Convert to RGB if necessary (in case of RGBA or other modes)
|
||||
if self.thumbnail_image.mode in ("RGBA", "P"):
|
||||
rgb_image = self.thumbnail_image.convert("RGB")
|
||||
rgb_image.save(thumbnail_path, "JPEG", quality=95)
|
||||
else:
|
||||
self.thumbnail_image.save(thumbnail_path, "JPEG", quality=95)
|
||||
|
||||
logger.info(f"Thumbnail saved to: {thumbnail_path}")
|
||||
self.signals.update_status.emit(_("status.thumbnail_saved", filename=filename))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Thumbnail Save Error: {e}")
|
||||
self.signals.update_status.emit(_("status.thumbnail_error", error=str(e)))
|
||||
return False
|
||||
|
||||
def sanitize_filename(self, name) -> str:
|
||||
"""Clean filename for filesystem safety"""
|
||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Watch tab - player plus play queue
|
||||
==================================
|
||||
|
||||
Hosts the embedded mpv PlayerPanel (or the libmpv-missing hint) and a simple
|
||||
play queue. Entries arrive via AppRouter.playVideo / queueVideo.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ytsage_gui_player import PlayerPanel, create_player_panel
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
POSITION_SAVE_INTERVAL_MS = 5000
|
||||
|
||||
|
||||
class WatchPage(QWidget):
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._queue: List[Dict[str, Any]] = []
|
||||
# What has already played this session, so "previous" has somewhere
|
||||
# to go. Not persisted: it is session history, not the queue.
|
||||
self._played: List[Dict[str, Any]] = []
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
layout.addWidget(splitter)
|
||||
|
||||
self.player = create_player_panel(self)
|
||||
splitter.addWidget(self.player)
|
||||
|
||||
# Kept as an attribute: FullscreenController hides it rather than
|
||||
# reparenting the player, which is what used to lose the GL context.
|
||||
self.queue_panel = queue_panel = QWidget(self)
|
||||
queue_layout = QVBoxLayout(queue_panel)
|
||||
queue_layout.setContentsMargins(4, 0, 0, 0)
|
||||
|
||||
queue_header = QHBoxLayout()
|
||||
queue_label = QLabel(_("player.queue"))
|
||||
queue_label.setStyleSheet("font-weight: bold;")
|
||||
queue_header.addWidget(queue_label)
|
||||
queue_header.addStretch()
|
||||
self.clear_queue_btn = QPushButton("✕")
|
||||
self.clear_queue_btn.setFixedWidth(28)
|
||||
self.clear_queue_btn.setToolTip(_("watch.clear_queue"))
|
||||
self.clear_queue_btn.clicked.connect(self.clear_queue)
|
||||
queue_header.addWidget(self.clear_queue_btn)
|
||||
queue_layout.addLayout(queue_header)
|
||||
|
||||
self.queue_list = QListWidget()
|
||||
self.queue_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
|
||||
self.queue_list.itemDoubleClicked.connect(self._on_queue_item_activated)
|
||||
self.queue_list.model().rowsMoved.connect(self._on_rows_moved)
|
||||
queue_layout.addWidget(self.queue_list)
|
||||
|
||||
splitter.addWidget(queue_panel)
|
||||
splitter.setStretchFactor(0, 4)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
splitter.setSizes([900, 240])
|
||||
|
||||
if isinstance(self.player, PlayerPanel):
|
||||
self.player.playbackEnded.connect(self._on_playback_ended)
|
||||
self._duration = 0.0
|
||||
self.player.durationChanged.connect(self._on_duration_changed)
|
||||
self._position_timer = QTimer(self)
|
||||
self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS)
|
||||
self._position_timer.timeout.connect(self._save_position)
|
||||
self.player.nowPlayingChanged.connect(self._on_now_playing)
|
||||
# The panel asks; the queue lives here, so the queue answers.
|
||||
self.player.nextRequested.connect(self.play_next)
|
||||
self.player.previousRequested.connect(self.play_previous)
|
||||
|
||||
self._restore_queue()
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def play_entry(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
|
||||
if isinstance(self.player, PlayerPanel):
|
||||
if resume_pos <= 0 and (ConfigManager.get("player.resume") or "auto") == "auto":
|
||||
video_id = entry.get("id") or entry.get("url")
|
||||
if video_id:
|
||||
resume_pos = LibraryManager.get_resume_position(str(video_id))
|
||||
self.player.play(entry, resume_pos=resume_pos)
|
||||
else:
|
||||
logger.warning("Play requested but libmpv is unavailable")
|
||||
|
||||
def enqueue(self, entry: Dict[str, Any]) -> None:
|
||||
self._queue.append(dict(entry))
|
||||
item = QListWidgetItem(entry.get("title") or entry.get("url") or "?")
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(entry))
|
||||
self.queue_list.addItem(item)
|
||||
self._persist_queue()
|
||||
# Start playing right away when nothing is on and this is the first item
|
||||
if isinstance(self.player, PlayerPanel) and not self.player.current_entry() and len(self._queue) == 1:
|
||||
self._play_next_from_queue()
|
||||
|
||||
def clear_queue(self) -> None:
|
||||
self._queue.clear()
|
||||
self.queue_list.clear()
|
||||
self._persist_queue()
|
||||
|
||||
def queue_entries(self) -> List[Dict[str, Any]]:
|
||||
return [self.queue_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.queue_list.count())]
|
||||
|
||||
def play_next(self) -> None:
|
||||
"""Next in the queue. Bound to N and the player's next button."""
|
||||
self._play_next_from_queue()
|
||||
|
||||
def play_previous(self) -> None:
|
||||
"""
|
||||
Back to what was playing before.
|
||||
|
||||
Restarts the current item first if it is more than a few seconds in,
|
||||
which is what every other player does with a "previous" press.
|
||||
"""
|
||||
if not isinstance(self.player, PlayerPanel):
|
||||
return
|
||||
if self.player.current_position() > 5.0:
|
||||
self.player.seek_absolute(0.0)
|
||||
return
|
||||
# _played ends with what is playing now, so going back needs two.
|
||||
if len(self._played) < 2:
|
||||
return
|
||||
current = self._played.pop()
|
||||
previous = self._played[-1]
|
||||
# Put the current item back at the head of the queue rather than
|
||||
# dropping it on the floor.
|
||||
if current:
|
||||
item = QListWidgetItem(current.get("title") or current.get("url") or "?")
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(current))
|
||||
self.queue_list.insertItem(0, item)
|
||||
self._queue.insert(0, dict(current))
|
||||
self._persist_queue()
|
||||
# play_entry re-pushes `previous`, so drop it here to avoid a double.
|
||||
self._played.pop()
|
||||
self.play_entry(previous)
|
||||
|
||||
# --------------------------------------------------------------- internal
|
||||
|
||||
def _play_next_from_queue(self) -> None:
|
||||
if self.queue_list.count() == 0:
|
||||
return
|
||||
item = self.queue_list.takeItem(0)
|
||||
entry = item.data(Qt.ItemDataRole.UserRole)
|
||||
if entry in self._queue:
|
||||
self._queue.remove(entry)
|
||||
self._persist_queue()
|
||||
self.play_entry(entry)
|
||||
|
||||
def _on_playback_ended(self, reason: str) -> None:
|
||||
self._save_position(final=True)
|
||||
if reason in ("eof", "") and self.queue_list.count() > 0:
|
||||
self._play_next_from_queue()
|
||||
|
||||
def _on_queue_item_activated(self, item: QListWidgetItem) -> None:
|
||||
entry = item.data(Qt.ItemDataRole.UserRole)
|
||||
row = self.queue_list.row(item)
|
||||
self.queue_list.takeItem(row)
|
||||
if entry in self._queue:
|
||||
self._queue.remove(entry)
|
||||
self._persist_queue()
|
||||
self.play_entry(entry)
|
||||
|
||||
def _on_rows_moved(self, *args) -> None:
|
||||
self._queue = self.queue_entries()
|
||||
self._persist_queue()
|
||||
|
||||
# ------------------------------------------------- history & persistence
|
||||
|
||||
def _on_now_playing(self, entry: Dict[str, Any]) -> None:
|
||||
LibraryManager.upsert_watch(entry)
|
||||
# Record what we were on before this, so "previous" can return to it.
|
||||
# The retry path replays the same entry; don't stack duplicates.
|
||||
if self._played[-1:] != [entry] and entry:
|
||||
self._played.append(dict(entry))
|
||||
del self._played[:-50]
|
||||
self._duration = 0.0
|
||||
self._position_timer.start()
|
||||
|
||||
def _on_duration_changed(self, duration: float) -> None:
|
||||
self._duration = duration
|
||||
|
||||
def _save_position(self, final: bool = False) -> None:
|
||||
if not isinstance(self.player, PlayerPanel):
|
||||
return
|
||||
entry = self.player.current_entry()
|
||||
video_id = entry.get("id") or entry.get("url")
|
||||
if not video_id:
|
||||
return
|
||||
pos = self.player.current_position()
|
||||
if pos > 0:
|
||||
LibraryManager.update_position(str(video_id), pos, self._duration or entry.get("duration"))
|
||||
if final:
|
||||
self._position_timer.stop()
|
||||
|
||||
def _persist_queue(self) -> None:
|
||||
try:
|
||||
LibraryManager.save_queue(self.queue_entries())
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not persist queue: {e}")
|
||||
|
||||
def _restore_queue(self) -> None:
|
||||
try:
|
||||
for entry in LibraryManager.load_queue():
|
||||
self._queue.append(entry)
|
||||
item = QListWidgetItem(entry.get("title") or entry.get("url") or "?")
|
||||
item.setData(Qt.ItemDataRole.UserRole, entry)
|
||||
self.queue_list.addItem(item)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not restore queue: {e}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if isinstance(self.player, PlayerPanel):
|
||||
self._save_position(final=True)
|
||||
self._persist_queue()
|
||||
self.player.shutdown()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Icons
|
||||
=====
|
||||
|
||||
The app had no icon system. Transport buttons used
|
||||
`QStyle.standardIcon(SP_MediaPlay)`, which returns the platform style's dark
|
||||
monochrome glyph -- painted onto the app's saturated red buttons, at a fixed
|
||||
36px with no text and no tooltip, that reads as a black or empty square. On
|
||||
styles that return a null icon for some standard pixmaps it *was* an empty
|
||||
square. Everything else called an "icon" was an emoji baked into the English
|
||||
translation file, which renders as tofu wherever the emoji font is missing.
|
||||
|
||||
Approach: a small set of hand-drawn SVGs rendered through QtSvg (part of
|
||||
PySide6-Essentials, so no new dependency), recoloured at load. Qt stylesheets
|
||||
cannot recolour a QIcon, which is the whole reason the old icons were
|
||||
unreadable -- so the colour is an argument here.
|
||||
|
||||
The sources live in this module rather than as asset files on purpose: no
|
||||
package-data to keep in sync, no path resolution, and nothing to go missing
|
||||
from a wheel or a frozen build.
|
||||
|
||||
Drawing conventions: 24x24 viewBox, 2px round strokes, `%COLOR%` wherever the
|
||||
colour goes. Shapes that must read as solid at 16px (the play triangle) carry
|
||||
their own fill.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QByteArray, QRectF, Qt
|
||||
from PySide6.QtGui import QIcon, QPainter, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
#: Default stroke colour. Overridden per call; kept in step with ytsage_theme.
|
||||
DEFAULT_COLOR = "#e8eaed"
|
||||
|
||||
_SVG: Dict[str, str] = {
|
||||
# --- transport -------------------------------------------------------
|
||||
"play": '<polygon points="7 4 20 12 7 20" fill="%COLOR%" stroke-linejoin="round"/>',
|
||||
"pause": '<rect x="6" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>'
|
||||
'<rect x="14" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>',
|
||||
"stop": '<rect x="5" y="5" width="14" height="14" rx="2" fill="%COLOR%"/>',
|
||||
"skip-back": '<polygon points="19 5 9 12 19 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="6" y1="5" x2="6" y2="19"/>',
|
||||
"skip-forward": '<polygon points="5 5 15 12 5 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="18" y1="5" x2="18" y2="19"/>',
|
||||
"rewind-10": '<path d="M11 20a8 8 0 1 0-8-8"/><polyline points="3 8 3 12 7 12"/>'
|
||||
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||
"forward-10": '<path d="M13 20a8 8 0 1 1 8-8"/><polyline points="21 8 21 12 17 12"/>'
|
||||
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||
# --- audio -----------------------------------------------------------
|
||||
"volume-high": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/>',
|
||||
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/>',
|
||||
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="16" y1="9" x2="22" y2="15"/><line x1="22" y1="9" x2="16" y2="15"/>',
|
||||
"captions": '<rect x="2" y="5" width="20" height="14" rx="3"/>'
|
||||
'<path d="M10 10.2a2.6 2.6 0 1 0 0 3.6"/><path d="M17.5 10.2a2.6 2.6 0 1 0 0 3.6"/>',
|
||||
# --- window ----------------------------------------------------------
|
||||
"maximize": '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/>'
|
||||
'<path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
||||
"minimize": '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>'
|
||||
'<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>',
|
||||
# --- tabs / navigation -----------------------------------------------
|
||||
"rss": '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/>'
|
||||
'<circle cx="5" cy="19" r="1.6" fill="%COLOR%" stroke="none"/>',
|
||||
"search": '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/>',
|
||||
"library": '<line x1="4" y1="4" x2="4" y2="20"/><line x1="9" y1="6" x2="9" y2="20"/>'
|
||||
'<path d="M14 6.5l4.5 13"/>',
|
||||
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>'
|
||||
'<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
"user-check": '<path d="M15 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>'
|
||||
'<polyline points="16 11 18 13 22 9"/>',
|
||||
# --- actions ---------------------------------------------------------
|
||||
"queue": '<line x1="3" y1="6" x2="16" y2="6"/><line x1="3" y1="12" x2="11" y2="12"/>'
|
||||
'<line x1="3" y1="18" x2="11" y2="18"/><line x1="18" y1="9" x2="18" y2="15"/>'
|
||||
'<line x1="21" y1="12" x2="15" y2="12"/>',
|
||||
"refresh": '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><polyline points="21 3 21 9 15 9"/>',
|
||||
"folder-open": '<path d="M4 20a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.6.8l1 1.4a2 2 0 0 0 1.6.8H18a2 2 0 0 1 2 2v1"/>'
|
||||
'<path d="M4 20l2.2-7a2 2 0 0 1 1.9-1.4h12a1.6 1.6 0 0 1 1.55 2l-1.7 5.4A2 2 0 0 1 18 20z"/>',
|
||||
"x": '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
"check": '<polyline points="20 6 9 17 4 12"/>',
|
||||
"plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
"trash": '<polyline points="3 6 21 6"/>'
|
||||
'<path d="M19 6v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>'
|
||||
'<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>',
|
||||
"settings": '<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>'
|
||||
'<circle cx="10" cy="7" r="2.4" fill="%COLOR%" stroke="none"/>'
|
||||
'<circle cx="15" cy="17" r="2.4" fill="%COLOR%" stroke="none"/>',
|
||||
"info": '<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16"/>'
|
||||
'<circle cx="12" cy="7.8" r="1.1" fill="%COLOR%" stroke="none"/>',
|
||||
"clock": '<circle cx="12" cy="12" r="9"/><polyline points="12 6.8 12 12 15.5 14"/>',
|
||||
"scissors": '<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
|
||||
'<line x1="20" y1="4" x2="8.1" y2="15.9"/><line x1="14.5" y1="14.5" x2="20" y2="20"/>'
|
||||
'<line x1="8.1" y1="8.1" x2="12" y2="12"/>',
|
||||
"external-link": '<path d="M14 3h7v7"/><line x1="10" y1="14" x2="21" y2="3"/>'
|
||||
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
"chevron-down": '<polyline points="6 9 12 15 18 9"/>',
|
||||
"chevron-right": '<polyline points="9 6 15 12 9 18"/>',
|
||||
"clipboard": '<rect x="8" y="3" width="8" height="4" rx="1"/>'
|
||||
'<path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/>',
|
||||
"play-circle": '<circle cx="12" cy="12" r="9"/><polygon points="10 8.5 16 12 10 15.5" fill="%COLOR%" stroke="none"/>',
|
||||
}
|
||||
|
||||
_DOC = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" '
|
||||
'fill="none" stroke="%COLOR%" stroke-width="2" stroke-linecap="round" '
|
||||
'stroke-linejoin="round">%BODY%</svg>'
|
||||
)
|
||||
|
||||
|
||||
def available() -> list:
|
||||
"""Every icon name this module can draw. Useful when adding call sites."""
|
||||
return sorted(_SVG)
|
||||
|
||||
|
||||
def _document(name: str, color: str) -> Optional[bytes]:
|
||||
body = _SVG.get(name)
|
||||
if body is None:
|
||||
return None
|
||||
return _DOC.replace("%BODY%", body).replace("%COLOR%", color).encode("utf-8")
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def pixmap(name: str, color: str = DEFAULT_COLOR, size: int = 20, dpr: float = 1.0) -> QPixmap:
|
||||
"""One rendered pixmap. A missing name yields a transparent one, never an error."""
|
||||
px = QPixmap(max(1, int(size * dpr)), max(1, int(size * dpr)))
|
||||
px.setDevicePixelRatio(dpr)
|
||||
px.fill(Qt.GlobalColor.transparent)
|
||||
|
||||
document = _document(name, color)
|
||||
if document is None:
|
||||
# A typo in a call site should show a gap, not take a dialog down.
|
||||
logger.debug(f"Unknown icon name: {name!r}")
|
||||
return px
|
||||
|
||||
renderer = QSvgRenderer(QByteArray(document))
|
||||
if not renderer.isValid():
|
||||
logger.debug(f"Icon {name!r} failed to parse")
|
||||
return px
|
||||
|
||||
painter = QPainter(px)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
try:
|
||||
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
|
||||
finally:
|
||||
painter.end()
|
||||
return px
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def icon(name: str, color: str = DEFAULT_COLOR, size: int = 20, disabled_color: str = "#6b7075") -> QIcon:
|
||||
"""
|
||||
A QIcon with its own Normal and Disabled artwork.
|
||||
|
||||
Both 1x and 2x are added so the icon stays sharp on a HiDPI screen without
|
||||
Qt upscaling a small bitmap.
|
||||
"""
|
||||
result = QIcon()
|
||||
for dpr in (1.0, 2.0):
|
||||
result.addPixmap(pixmap(name, color, size, dpr), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
result.addPixmap(pixmap(name, disabled_color, size, dpr), QIcon.Mode.Disabled, QIcon.State.Off)
|
||||
return result
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Fullscreen without reparenting the video widget
|
||||
===============================================
|
||||
|
||||
The obvious implementation -- take the player out of its layout, make it a
|
||||
top-level window, `showFullScreen()` -- is what SageTube did, and it is a
|
||||
reliable way to crash. Reparenting a widget into a new top-level destroys and
|
||||
recreates the QOpenGLContext of every QOpenGLWidget beneath it, and each
|
||||
recreation is another chance to lose the libmpv render context. A single
|
||||
fullscreen toggle did it twice.
|
||||
|
||||
So nothing is reparented here. The **main window** goes fullscreen and the
|
||||
chrome around the video is hidden. QMainWindow.showFullScreen() reuses the
|
||||
same QWindow and native surface -- on Wayland it is an
|
||||
`xdg_toplevel.set_fullscreen` -- so the GL context is untouched and the video
|
||||
never blinks.
|
||||
|
||||
The same mechanism gives "cinema mode" (hide the chrome, stay windowed) for
|
||||
free, and it gives Escape one unambiguous owner.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtWidgets import QMainWindow, QWidget
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
|
||||
class FullscreenController(QObject):
|
||||
"""
|
||||
Drives fullscreen for the Watch page.
|
||||
|
||||
Owns no widgets and destroys nothing; it only toggles visibility and the
|
||||
main window's state, and can always put things back.
|
||||
"""
|
||||
|
||||
changed = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window: QMainWindow,
|
||||
tabs: QWidget,
|
||||
watch_page: QWidget,
|
||||
parent: Optional[QObject] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._window = window
|
||||
self._tabs = tabs
|
||||
self._watch_page = watch_page
|
||||
self._active = False
|
||||
self._prev_window_state: Optional[Qt.WindowState] = None
|
||||
self._prev_margins = None
|
||||
|
||||
# ------------------------------------------------------------------ state
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
def toggle(self) -> None:
|
||||
# Trust the window, not just the flag. If something else put the
|
||||
# window into fullscreen -- a restored geometry, the window manager,
|
||||
# the user's own shortcut -- toggling must get *out* of it rather than
|
||||
# trying to enter a state it is already in and appearing to do nothing.
|
||||
if self._active or self._window.isFullScreen():
|
||||
self.exit()
|
||||
else:
|
||||
self.enter()
|
||||
|
||||
# ------------------------------------------------------------ transitions
|
||||
|
||||
def enter(self) -> None:
|
||||
if self._active:
|
||||
return
|
||||
try:
|
||||
# Fullscreening while another tab is showing would present an
|
||||
# empty screen, so make sure the video is the visible page first.
|
||||
self._show_watch_tab()
|
||||
|
||||
self._prev_window_state = self._window.windowState()
|
||||
|
||||
tab_bar = getattr(self._tabs, "tab_bar", None)
|
||||
if tab_bar is not None:
|
||||
tab_bar.hide()
|
||||
corner = getattr(self._tabs, "corner_widget", None)
|
||||
if corner is not None:
|
||||
corner.hide()
|
||||
|
||||
queue_panel = getattr(self._watch_page, "queue_panel", None)
|
||||
if queue_panel is not None:
|
||||
queue_panel.hide()
|
||||
|
||||
layout = self._watch_page.layout()
|
||||
if layout is not None:
|
||||
self._prev_margins = layout.contentsMargins()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._window.showFullScreen()
|
||||
self._active = True
|
||||
self.changed.emit(True)
|
||||
except Exception as e:
|
||||
logger.exception(f"Entering fullscreen failed: {e}")
|
||||
# Never leave the UI half-hidden with no way back.
|
||||
self.exit()
|
||||
|
||||
def exit(self) -> None:
|
||||
# Also runs when the controller was never active but the window is
|
||||
# fullscreen anyway, so restoring the chrome is unconditional.
|
||||
if not self._active and self._prev_window_state is None and not self._window.isFullScreen():
|
||||
return
|
||||
try:
|
||||
tab_bar = getattr(self._tabs, "tab_bar", None)
|
||||
if tab_bar is not None:
|
||||
tab_bar.show()
|
||||
corner = getattr(self._tabs, "corner_widget", None)
|
||||
if corner is not None:
|
||||
corner.show()
|
||||
|
||||
queue_panel = getattr(self._watch_page, "queue_panel", None)
|
||||
if queue_panel is not None:
|
||||
queue_panel.show()
|
||||
|
||||
layout = self._watch_page.layout()
|
||||
if layout is not None and self._prev_margins is not None:
|
||||
layout.setContentsMargins(self._prev_margins)
|
||||
|
||||
if self._prev_window_state is not None:
|
||||
# Never restore *into* fullscreen: that is what we are leaving.
|
||||
self._window.setWindowState(self._prev_window_state & ~Qt.WindowState.WindowFullScreen)
|
||||
else:
|
||||
self._window.showNormal()
|
||||
if self._window.isFullScreen():
|
||||
self._window.showNormal()
|
||||
except Exception as e:
|
||||
logger.exception(f"Leaving fullscreen failed: {e}")
|
||||
finally:
|
||||
self._prev_window_state = None
|
||||
self._prev_margins = None
|
||||
self._active = False
|
||||
self.changed.emit(False)
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
def _show_watch_tab(self) -> None:
|
||||
stack = getattr(self._tabs, "stack", None)
|
||||
set_index = getattr(self._tabs, "set_current_index", None)
|
||||
if stack is None or set_index is None:
|
||||
return
|
||||
for i in range(stack.count()):
|
||||
if stack.widget(i) is self._watch_page:
|
||||
set_index(i)
|
||||
return
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Player keyboard and mouse bindings
|
||||
==================================
|
||||
|
||||
The player had one `keyPressEvent` handling Space, F and Escape -- and it never
|
||||
ran. Nothing in the player set a focus policy, so focus always landed on a
|
||||
child button, slider or combo box, which swallowed Space and the arrow keys.
|
||||
There were no mouse handlers at all and no `QShortcut` anywhere in the
|
||||
application.
|
||||
|
||||
Why Qt handlers rather than mpv's own bindings
|
||||
----------------------------------------------
|
||||
`input_default_bindings` stays off. With `vo=libmpv` there is no mpv-owned
|
||||
window, so mpv's input layer receives nothing -- it is fed by the video
|
||||
output's windowing backend, which does not exist in this embedding. Turning
|
||||
default bindings on would only matter if key events were hand-forwarded with
|
||||
`keypress`, which needs a full Qt-to-mpv key-name table, hands mpv ownership of
|
||||
the OSD and OSC (both disabled here), and lets `q` quit the core out from under
|
||||
the Qt UI.
|
||||
|
||||
Routing through Qt also means one action table drives the shortcuts, the
|
||||
context menu and the buttons, and the labels stay translatable.
|
||||
|
||||
Focus, and why the shortcuts are scoped
|
||||
---------------------------------------
|
||||
Bindings are `QShortcut`s with `WidgetWithChildrenShortcut` context on the
|
||||
panel. That fires when the panel *or any descendant* has focus, so it works
|
||||
whichever child holds it -- and it does not steal keys from the Search box or
|
||||
the Downloads URL field on other tabs, which an application-wide shortcut
|
||||
would.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import QMenu, QWidget
|
||||
|
||||
from ..utils.ytsage_localization import _
|
||||
|
||||
#: Seek steps, in seconds.
|
||||
SEEK_SMALL = 1.0
|
||||
SEEK_MEDIUM = 5.0
|
||||
SEEK_LARGE = 10.0
|
||||
VOLUME_STEP = 5
|
||||
SPEED_STEP = 0.25
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""One thing the player can do, and every way to ask for it."""
|
||||
|
||||
ident: str
|
||||
keys: List[str]
|
||||
label_key: str
|
||||
method: str
|
||||
icon: Optional[str] = None
|
||||
#: Whether it belongs in the right-click menu.
|
||||
in_menu: bool = True
|
||||
args: tuple = field(default_factory=tuple)
|
||||
|
||||
|
||||
ACTIONS: List[Action] = [
|
||||
Action("play_pause", ["Space", "K", "Media Play"], "player.act_play_pause", "toggle_pause", "play"),
|
||||
Action("seek_back_5", ["Left"], "player.act_seek_back_5", "seek_relative", None, False, (-SEEK_MEDIUM,)),
|
||||
Action("seek_fwd_5", ["Right"], "player.act_seek_fwd_5", "seek_relative", None, False, (SEEK_MEDIUM,)),
|
||||
Action("seek_back_10", ["J"], "player.act_seek_back_10", "seek_relative", None, True, (-SEEK_LARGE,)),
|
||||
Action("seek_fwd_10", ["L"], "player.act_seek_fwd_10", "seek_relative", None, True, (SEEK_LARGE,)),
|
||||
Action("seek_back_1", ["Shift+Left"], "player.act_seek_back_1", "seek_relative", None, False, (-SEEK_SMALL,)),
|
||||
Action("seek_fwd_1", ["Shift+Right"], "player.act_seek_fwd_1", "seek_relative", None, False, (SEEK_SMALL,)),
|
||||
Action("frame_back", [","], "player.act_frame_back", "frame_step", None, False, (-1,)),
|
||||
Action("frame_fwd", ["."], "player.act_frame_fwd", "frame_step", None, False, (1,)),
|
||||
Action("vol_up", ["Up"], "player.act_vol_up", "nudge_volume", None, False, (VOLUME_STEP,)),
|
||||
Action("vol_down", ["Down"], "player.act_vol_down", "nudge_volume", None, False, (-VOLUME_STEP,)),
|
||||
Action("mute", ["M"], "player.act_mute", "toggle_mute", "volume-mute"),
|
||||
Action("speed_up", ["]"], "player.act_speed_up", "nudge_speed", None, False, (SPEED_STEP,)),
|
||||
Action("speed_down", ["["], "player.act_speed_down", "nudge_speed", None, False, (-SPEED_STEP,)),
|
||||
Action("speed_reset", ["Backspace"], "player.act_speed_reset", "reset_speed", None, True),
|
||||
Action("subtitles", ["C"], "player.act_subtitles", "cycle_subtitles", "captions"),
|
||||
Action("fullscreen", ["F"], "player.act_fullscreen", "toggle_fullscreen", "maximize"),
|
||||
Action("leave_fullscreen", ["Escape"], "player.act_leave_fullscreen", "exit_fullscreen", None, False),
|
||||
Action("next", ["N", "Ctrl+Right", "Media Next"], "player.act_next", "request_next", "skip-forward"),
|
||||
Action("previous", ["P", "Ctrl+Left", "Media Previous"], "player.act_previous", "request_previous", "skip-back"),
|
||||
Action("start", ["Home"], "player.act_start", "seek_absolute", None, False, (0.0,)),
|
||||
Action("end", ["End"], "player.act_end", "seek_to_end", None, False),
|
||||
]
|
||||
|
||||
#: 0-9 jump to that tenth of the video, as YouTube does.
|
||||
PERCENT_KEYS = [(str(n), n / 10.0) for n in range(10)]
|
||||
|
||||
|
||||
def install_player_shortcuts(panel: QWidget) -> List[QShortcut]:
|
||||
"""
|
||||
Bind every action to the panel.
|
||||
|
||||
Returns the shortcuts so the caller can keep them alive -- a QShortcut
|
||||
with no reference is collected and silently stops working.
|
||||
"""
|
||||
shortcuts: List[QShortcut] = []
|
||||
|
||||
def bind(sequence: str, handler: Callable) -> None:
|
||||
shortcut = QShortcut(QKeySequence(sequence), panel)
|
||||
shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
shortcut.activated.connect(handler)
|
||||
shortcuts.append(shortcut)
|
||||
|
||||
for action in ACTIONS:
|
||||
method = getattr(panel, action.method, None)
|
||||
if method is None:
|
||||
continue
|
||||
for key in action.keys:
|
||||
bind(key, (lambda m=method, a=action.args: m(*a)))
|
||||
|
||||
seek_percent = getattr(panel, "seek_percent", None)
|
||||
if seek_percent is not None:
|
||||
for key, fraction in PERCENT_KEYS:
|
||||
bind(key, (lambda f=fraction: seek_percent(f)))
|
||||
|
||||
return shortcuts
|
||||
|
||||
|
||||
def build_context_menu(panel: QWidget) -> QMenu:
|
||||
"""The right-click menu, from the same table as the shortcuts."""
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
|
||||
menu = QMenu(panel)
|
||||
for action in ACTIONS:
|
||||
if not action.in_menu:
|
||||
continue
|
||||
method = getattr(panel, action.method, None)
|
||||
if method is None:
|
||||
continue
|
||||
label = _(action.label_key)
|
||||
if action.keys:
|
||||
label = f"{label}\t{action.keys[0]}"
|
||||
entry = menu.addAction(label)
|
||||
if action.icon:
|
||||
entry.setIcon(icons.icon(action.icon, theme.ICON))
|
||||
entry.triggered.connect(lambda _checked=False, m=method, a=action.args: m(*a))
|
||||
|
||||
menu.addSeparator()
|
||||
copy_url = getattr(panel, "copy_video_url", None)
|
||||
if copy_url is not None:
|
||||
entry = menu.addAction(_("player.act_copy_url"))
|
||||
entry.setIcon(icons.icon("clipboard", theme.ICON))
|
||||
entry.triggered.connect(lambda _checked=False: copy_url())
|
||||
open_browser = getattr(panel, "open_in_browser", None)
|
||||
if open_browser is not None:
|
||||
entry = menu.addAction(_("player.act_open_browser"))
|
||||
entry.setIcon(icons.icon("external-link", theme.ICON))
|
||||
entry.triggered.connect(lambda _checked=False: open_browser())
|
||||
return menu
|
||||
@@ -0,0 +1,211 @@
|
||||
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QStackedWidget,
|
||||
QTabBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
class FadingStackedWidget(QStackedWidget):
|
||||
"""
|
||||
A QStackedWidget that cross-fades between widgets.
|
||||
"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.fade_duration = 300
|
||||
self.fade_easing = QEasingCurve.Type.OutQuad
|
||||
|
||||
@staticmethod
|
||||
def _contains_gl(widget):
|
||||
"""Whether an OpenGL surface lives anywhere under this page."""
|
||||
if widget is None:
|
||||
return False
|
||||
return isinstance(widget, QOpenGLWidget) or widget.findChild(QOpenGLWidget) is not None
|
||||
|
||||
def setCurrentIndex(self, index):
|
||||
curr_index = self.currentIndex()
|
||||
if index == curr_index:
|
||||
return
|
||||
|
||||
widget = self.widget(index)
|
||||
curr_widget = self.widget(curr_index)
|
||||
|
||||
# If widget isn't visible or valid, just swap
|
||||
if not self.isVisible() or not curr_widget:
|
||||
super().setCurrentIndex(index)
|
||||
return
|
||||
|
||||
# Never fade a page containing an OpenGL surface. grab() on one
|
||||
# returns a black rectangle -- so the "fade" was a black slab sliding
|
||||
# over the new tab -- and forcing a framebuffer readback plus a
|
||||
# QGraphicsEffect over that subtree is a way to lose the GL context,
|
||||
# which for the embedded mpv player means a crash.
|
||||
if self._contains_gl(widget) or self._contains_gl(curr_widget):
|
||||
super().setCurrentIndex(index)
|
||||
return
|
||||
|
||||
# 1. Capture the current view (the "old" tab)
|
||||
# Use grab() for simplicity and reliability in PySide6
|
||||
pixmap = self.grab()
|
||||
|
||||
# 2. Create an overlay label to hold this "old" view
|
||||
overlay = QLabel(self)
|
||||
overlay.setPixmap(pixmap)
|
||||
overlay.setGeometry(0, 0, self.width(), self.height())
|
||||
overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) # Don't block clicks
|
||||
overlay.show()
|
||||
|
||||
# 3. Switch the actual stack to the "new" view
|
||||
super().setCurrentIndex(index)
|
||||
|
||||
# CRITICAL: Ensure overlay stays on top of the new widget
|
||||
overlay.raise_()
|
||||
|
||||
# 4. Fade OUT the overlay, revealing the new view
|
||||
effect = QGraphicsOpacityEffect(overlay)
|
||||
overlay.setGraphicsEffect(effect)
|
||||
|
||||
|
||||
anim = QPropertyAnimation(effect, b"opacity", overlay)
|
||||
anim.setDuration(self.fade_duration)
|
||||
anim.setStartValue(1.0)
|
||||
anim.setEndValue(0.0)
|
||||
anim.setEasingCurve(self.fade_easing)
|
||||
|
||||
# Cleanup when done
|
||||
anim.finished.connect(lambda: self._cleanup(overlay))
|
||||
|
||||
# Keep reference to prevent GC
|
||||
self._active_anim = anim
|
||||
anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
|
||||
def _cleanup(self, overlay):
|
||||
overlay.hide()
|
||||
overlay.deleteLater()
|
||||
|
||||
|
||||
class SmoothTabWidget(QWidget):
|
||||
"""
|
||||
A unified Widget that behaves like a QTabWidget but uses smooth fading transitions.
|
||||
Includes a QTabBar and a FadingStackedWidget.
|
||||
"""
|
||||
#: Emitted after the visible page has actually changed. The QTabBar's own
|
||||
#: currentChanged is not usable for this: it fires before the stack has
|
||||
#: switched, and it re-enters through set_current_index.
|
||||
currentChanged = Signal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Main Layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setSpacing(0)
|
||||
|
||||
# Tab bar, plus a right-aligned slot for a corner widget. Wrapped in a
|
||||
# row so something (the account button) can sit opposite the tabs the
|
||||
# way QTabWidget::setCornerWidget would allow.
|
||||
self.tab_row = QWidget(self)
|
||||
tab_row_layout = QHBoxLayout(self.tab_row)
|
||||
tab_row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
tab_row_layout.setSpacing(0)
|
||||
|
||||
self.tab_bar = QTabBar(self.tab_row)
|
||||
self.tab_bar.setDrawBase(False) # We draw border on content instead
|
||||
self.tab_bar.currentChanged.connect(self.set_current_index)
|
||||
tab_row_layout.addWidget(self.tab_bar)
|
||||
tab_row_layout.addStretch(1)
|
||||
|
||||
self.corner_widget = None
|
||||
self._switching = False
|
||||
self._tab_row_layout = tab_row_layout
|
||||
self.layout.addWidget(self.tab_row)
|
||||
|
||||
# Content Area (Frame) - Mimics QTabWidget::pane
|
||||
self.content_frame = QFrame(self)
|
||||
self.content_frame.setObjectName("tabContent")
|
||||
|
||||
# Layout inside the content frame
|
||||
self.content_layout = QVBoxLayout(self.content_frame)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.content_layout.setSpacing(0)
|
||||
|
||||
# The Stack
|
||||
self.stack = FadingStackedWidget(self.content_frame)
|
||||
self.content_layout.addWidget(self.stack)
|
||||
|
||||
self.layout.addWidget(self.content_frame)
|
||||
|
||||
def addTab(self, widget, label, icon=None):
|
||||
"""Add a tab with the given widget, label and optional icon."""
|
||||
self.stack.addWidget(widget)
|
||||
if icon is not None:
|
||||
self.tab_bar.addTab(icon, label)
|
||||
else:
|
||||
self.tab_bar.addTab(label)
|
||||
|
||||
def setCornerWidget(self, widget):
|
||||
"""Place a widget at the right-hand end of the tab row."""
|
||||
if self.corner_widget is not None:
|
||||
self.corner_widget.setParent(None)
|
||||
self.corner_widget = widget
|
||||
if widget is not None:
|
||||
widget.setParent(self.tab_row)
|
||||
self._tab_row_layout.addWidget(widget)
|
||||
|
||||
def set_current_index(self, index):
|
||||
"""Slot to handle tab bar clicks."""
|
||||
if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index:
|
||||
return
|
||||
# setCurrentIndex on the bar emits its own currentChanged, which is
|
||||
# connected straight back here -- and at that moment the stack has not
|
||||
# moved yet, so a plain index comparison does not catch the re-entry.
|
||||
# Without this flag every switch emitted currentChanged twice and each
|
||||
# page's activation hook ran twice.
|
||||
if self._switching:
|
||||
return
|
||||
self._switching = True
|
||||
try:
|
||||
self.tab_bar.setCurrentIndex(index)
|
||||
self.stack.setCurrentIndex(index)
|
||||
finally:
|
||||
self._switching = False
|
||||
self.currentChanged.emit(index)
|
||||
|
||||
def currentWidget(self):
|
||||
return self.stack.currentWidget()
|
||||
|
||||
def currentIndex(self):
|
||||
return self.stack.currentIndex()
|
||||
|
||||
# --- QTabWidget-compatible API ---------------------------------------
|
||||
# This class claims to "behave like a QTabWidget" and is used as a drop-in
|
||||
# for one (CustomOptionsDialog builds its tabs on it), but it only ever
|
||||
# offered set_current_index. Anything calling the Qt spelling raised
|
||||
# AttributeError -- which is why "Sign in with cookies" appeared to do
|
||||
# nothing: show_cookie_login_dialog selects the Cookies tab by index
|
||||
# before showing the dialog, and died on that line.
|
||||
|
||||
def setCurrentIndex(self, index):
|
||||
self.set_current_index(index)
|
||||
|
||||
def count(self):
|
||||
return self.stack.count()
|
||||
|
||||
def widget(self, index):
|
||||
return self.stack.widget(index)
|
||||
|
||||
def indexOf(self, widget):
|
||||
return self.stack.indexOf(widget)
|
||||
|
||||
def tabText(self, index):
|
||||
return self.tab_bar.tabText(index)
|
||||
|
||||
def setTabText(self, index, text):
|
||||
self.tab_bar.setTabText(index, text)
|
||||
@@ -0,0 +1,438 @@
|
||||
|
||||
class StyleSheet:
|
||||
MAIN = """
|
||||
QMainWindow {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QWidget {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit {
|
||||
padding: 5px 15px;
|
||||
border: 2px solid #2a2d2e;
|
||||
border-radius: 6px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
padding: 10px 13px 6px 17px;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
}
|
||||
QTableWidget {
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
gridline-color: #1b2021;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background-color: #15181b;
|
||||
padding: 5px;
|
||||
border: 1px solid #1b2021;
|
||||
color: #ffffff;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #c90000;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
/* Style for filter buttons */
|
||||
QPushButton.filter-btn {
|
||||
background-color: #1b2021;
|
||||
padding: 5px 10px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
QPushButton.filter-btn:checked {
|
||||
background-color: #c90000;
|
||||
}
|
||||
QPushButton.filter-btn:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton.filter-btn:checked:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
/* Modern Scrollbar Styling */
|
||||
QScrollBar:vertical {
|
||||
border: none;
|
||||
background: #15181b;
|
||||
width: 14px;
|
||||
margin: 15px 0 15px 0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #404040;
|
||||
min-height: 30px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #505050;
|
||||
}
|
||||
QScrollBar::sub-line:vertical {
|
||||
border: none;
|
||||
background: #15181b;
|
||||
height: 15px;
|
||||
border-top-left-radius: 7px;
|
||||
border-top-right-radius: 7px;
|
||||
subcontrol-position: top;
|
||||
subcontrol-origin: margin;
|
||||
}
|
||||
QScrollBar::add-line:vertical {
|
||||
border: none;
|
||||
background: #15181b;
|
||||
height: 15px;
|
||||
border-bottom-left-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
subcontrol-position: bottom;
|
||||
subcontrol-origin: margin;
|
||||
}
|
||||
QScrollBar::sub-line:vertical:hover,
|
||||
QScrollBar::add-line:vertical:hover {
|
||||
background: #404040;
|
||||
}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
|
||||
background: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
|
||||
background: none;
|
||||
}
|
||||
"""
|
||||
|
||||
PASTE_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 9px 20px;
|
||||
background-color: #1b2021;
|
||||
border: 2px solid #2a2d2e;
|
||||
border-radius: 5px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #252829;
|
||||
border-color: #3a3d3e;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #1a1d1e;
|
||||
padding: 11px 18px 7px 22px;
|
||||
}
|
||||
"""
|
||||
|
||||
ANALYZE_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 9px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
padding: 11px 18px 7px 22px;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
}
|
||||
"""
|
||||
|
||||
PLAYLIST_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 6px 12px;
|
||||
background-color: #1d1e22;
|
||||
border: 1px solid #c90000;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #2a2d36;
|
||||
border-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #1d1e22;
|
||||
padding: 8px 10px 4px 12px;
|
||||
}
|
||||
"""
|
||||
|
||||
FORMAT_TOGGLE_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #1d1e22;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #c90000;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #2a2d36;
|
||||
}
|
||||
QPushButton:checked:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #151619;
|
||||
padding: 10px 13px 6px 17px;
|
||||
}
|
||||
QPushButton:checked:pressed {
|
||||
background-color: #800000;
|
||||
padding: 10px 13px 6px 17px;
|
||||
}
|
||||
"""
|
||||
|
||||
CHECKBOX = """
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
/* Square, not round: a fully-rounded indicator reads as a radio
|
||||
button, i.e. "one of these", when these are independent
|
||||
on/off options. */
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked:hover {
|
||||
border-color: #9aa0a6;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox:disabled { color: #888888; }
|
||||
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
|
||||
"""
|
||||
|
||||
PROGRESS_BAR = """
|
||||
QProgressBar {
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #363636;
|
||||
height: 25px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #ff0000;
|
||||
border-radius: 2px;
|
||||
}
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
"""
|
||||
|
||||
OPEN_FOLDER_BUTTON = """
|
||||
QPushButton {
|
||||
background-color: #2a2d2e;
|
||||
color: #cccccc;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
padding: 2px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3a3d3e;
|
||||
border: 1px solid #505050;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #1a1d1e;
|
||||
padding: 4px 0px 0px 4px;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_MESSAGE = """
|
||||
QLabel {
|
||||
background-color: #1d1e22;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_CHANGELOG = """
|
||||
QTextEdit {
|
||||
background-color: #1d1e22;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
padding: 10px;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
border: none;
|
||||
background: #1d1e22;
|
||||
width: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #404040;
|
||||
min-height: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #505050;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_DOWNLOAD_BTN = """
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-width: 140px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_REMIND_BTN = """
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #3d3d3d;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-width: 140px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4d4d4d;
|
||||
border-color: #666666;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_MAIN = """
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
"""
|
||||
|
||||
TIME_RANGE_BTN_ACTIVE = """
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border: 2px solid white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
|
||||
FILE_EXISTS_DIALOG = """
|
||||
QMessageBox {
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #ff0000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
"""
|
||||
|
||||
SETUP_SUCCESS_DIALOG = """
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Theme tokens and the stylesheet rules the app never had
|
||||
=======================================================
|
||||
|
||||
`StyleSheet.MAIN` (upstream's, in ytsage_stylesheet.py) styles the window,
|
||||
line edits, buttons, tables, progress bars and vertical scrollbars -- and
|
||||
nothing else. Everything it misses falls through to `QWidget { background:
|
||||
#15181b; color: #ffffff }` plus the platform style, which is why the app looked
|
||||
half-finished: the main tab bar was drawn by Fusion from the system palette
|
||||
with white text forced onto it, combo boxes and sliders were native, tooltips
|
||||
came out in the system's light style on a black app, and a native light
|
||||
horizontal scrollbar appeared under any wide view.
|
||||
|
||||
Notably `SmoothTabWidget` names its content frame `tabContent` with the comment
|
||||
"We draw border on content instead" -- and the only `QFrame#tabContent` rules
|
||||
in the codebase were inside two dialogs. The main window's tab bar got none of
|
||||
it.
|
||||
|
||||
This module holds the colour tokens and `EXTRA_QSS`, appended to
|
||||
`StyleSheet.MAIN` at the single application site. Keeping it separate means one
|
||||
changed line in an upstream-owned file, so the next merge from YTSage does not
|
||||
fight over it.
|
||||
|
||||
The two dialogs that define their own tab rules keep them. Unifying would cost
|
||||
more in merge conflicts than the duplication does.
|
||||
"""
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
# --- colour tokens -------------------------------------------------------
|
||||
# Names, not hexes, at the call sites. These match what StyleSheet.MAIN
|
||||
# already uses so the two halves agree.
|
||||
BG = "#15181b" # window
|
||||
SURFACE = "#1b2021" # panels, inputs, list backgrounds
|
||||
SURFACE_ALT = "#1d2124" # menus, tooltips, elevated surfaces
|
||||
SURFACE_HOVER = "#252a2d"
|
||||
BORDER = "#2a2d2e"
|
||||
BORDER_STRONG = "#3d3d3d"
|
||||
TEXT = "#ffffff"
|
||||
TEXT_MUTED = "#9aa0a6"
|
||||
TEXT_DISABLED = "#6b7075"
|
||||
ACCENT = "#c90000"
|
||||
ACCENT_HOVER = "#a50000"
|
||||
ACCENT_PRESSED = "#800000"
|
||||
FOCUS = "#ff6b6b"
|
||||
|
||||
#: Default icon colour: near-white, deliberately not pure #ffffff so it does
|
||||
#: not out-glare the text beside it.
|
||||
ICON = "#e8eaed"
|
||||
#: Icons sitting on an accent-coloured button.
|
||||
ICON_ON_ACCENT = "#ffffff"
|
||||
|
||||
|
||||
def ensure_ui_assets() -> dict:
|
||||
"""
|
||||
Write the few bitmaps Qt stylesheets can only reference as files.
|
||||
|
||||
QSS cannot draw a shape: `::down-arrow` and `::indicator` need an actual
|
||||
`image:`. Everything else here is drawn with borders and radii, but a
|
||||
caret and a tick are not expressible that way -- restyling a combo box
|
||||
without supplying one leaves it with a blank square where its arrow was.
|
||||
|
||||
Rendered from the same SVG set as every other icon and cached next to the
|
||||
thumbnails, so nothing extra ships and nothing is fetched.
|
||||
"""
|
||||
from . import ytsage_icons as icons
|
||||
from ..utils.ytsage_constants import APP_DATA_DIR
|
||||
|
||||
out_dir = APP_DATA_DIR / "ui"
|
||||
# Rendered larger than the box they are drawn into (see the QSS below):
|
||||
# a 24-unit viewBox squeezed straight into 12px leaves a 1px stroke that
|
||||
# all but disappears on this background.
|
||||
wanted = {
|
||||
"chevron": ("chevron-down", TEXT_MUTED, 14),
|
||||
"chevron_disabled": ("chevron-down", TEXT_DISABLED, 14),
|
||||
"check": ("check", TEXT, 12),
|
||||
}
|
||||
paths = {}
|
||||
try:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for key, (name, color, size) in wanted.items():
|
||||
target = out_dir / f"{key}.png"
|
||||
if not target.exists():
|
||||
# dpr 1.0 and the final pixel size: Qt draws a QSS `image:` at
|
||||
# its natural size, so anything else is scaled or clipped.
|
||||
icons.pixmap(name, color, size, 1.0).save(str(target), "PNG")
|
||||
# QSS wants forward slashes even on Windows.
|
||||
paths[key] = str(target).replace("\\", "/")
|
||||
except Exception as e: # pragma: no cover - cosmetic only
|
||||
logger.debug(f"Could not write UI assets, falling back to plain styling: {e}")
|
||||
return {}
|
||||
return paths
|
||||
|
||||
|
||||
def build_extra_qss() -> str:
|
||||
"""EXTRA_QSS plus the rules that need generated images, when available."""
|
||||
qss = EXTRA_QSS
|
||||
assets = ensure_ui_assets()
|
||||
if not assets:
|
||||
return qss
|
||||
return qss + f"""
|
||||
QComboBox::down-arrow {{
|
||||
image: url("{assets['chevron']}");
|
||||
border: none;
|
||||
}}
|
||||
QComboBox::down-arrow:disabled {{ image: url("{assets['chevron_disabled']}"); }}
|
||||
|
||||
/* Upstream styles checkboxes as filled circles (border-radius: 9px), which
|
||||
reads as a radio button -- a control that means "one of these" rather than
|
||||
"on or off". Square them off and show an actual tick. Widget-level
|
||||
stylesheets win over this one, so StyleSheet.CHECKBOX is corrected at
|
||||
source; these rules cover every checkbox that does not use it. */
|
||||
QCheckBox::indicator {{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid {BORDER_STRONG};
|
||||
background-color: {SURFACE};
|
||||
}}
|
||||
QCheckBox::indicator:hover {{ border-color: {TEXT_MUTED}; }}
|
||||
QCheckBox::indicator:checked {{
|
||||
border-color: {ACCENT};
|
||||
background-color: {ACCENT};
|
||||
image: url("{assets['check']}");
|
||||
}}
|
||||
QCheckBox::indicator:checked:hover {{ border-color: {FOCUS}; }}
|
||||
QCheckBox::indicator:disabled {{ border-color: {BORDER}; background-color: {BG}; }}
|
||||
"""
|
||||
|
||||
|
||||
EXTRA_QSS = f"""
|
||||
/* ---- main tab bar (SmoothTabWidget) --------------------------------- */
|
||||
QTabBar {{
|
||||
background-color: {BG};
|
||||
border: none;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background-color: transparent;
|
||||
color: {TEXT_MUTED};
|
||||
padding: 9px 18px;
|
||||
margin-right: 2px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
color: {TEXT};
|
||||
background-color: {SURFACE};
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
color: {TEXT};
|
||||
background-color: {SURFACE};
|
||||
border-bottom: 2px solid {ACCENT};
|
||||
}}
|
||||
QTabBar::tab:disabled {{
|
||||
color: {TEXT_DISABLED};
|
||||
}}
|
||||
QFrame#tabContent {{
|
||||
background-color: {BG};
|
||||
border: none;
|
||||
border-top: 1px solid {BORDER};
|
||||
}}
|
||||
|
||||
/* ---- combo boxes ---------------------------------------------------- */
|
||||
QComboBox {{
|
||||
background-color: {SURFACE};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
min-height: 18px;
|
||||
}}
|
||||
QComboBox:hover {{ border-color: {BORDER_STRONG}; }}
|
||||
QComboBox:focus {{ border-color: {FOCUS}; }}
|
||||
QComboBox:disabled {{ color: {TEXT_DISABLED}; background-color: {BG}; }}
|
||||
QComboBox::drop-down {{
|
||||
border: none;
|
||||
width: 22px;
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
}}
|
||||
/* ::down-arrow is deliberately not styled here -- QSS cannot draw a caret,
|
||||
and setting only a border leaves a small square where the arrow was.
|
||||
build_extra_qss() supplies a real image; without it Qt's native arrow is
|
||||
the better fallback. */
|
||||
QComboBox QAbstractItemView {{
|
||||
background-color: {SURFACE_ALT};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
selection-background-color: {ACCENT};
|
||||
selection-color: {TEXT};
|
||||
outline: none;
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
/* ---- lists ---------------------------------------------------------- */
|
||||
QListWidget, QListView {{
|
||||
background-color: {SURFACE};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}}
|
||||
QListWidget::item, QListView::item {{
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
}}
|
||||
QListWidget::item:hover, QListView::item:hover {{ background-color: {SURFACE_HOVER}; }}
|
||||
QListWidget::item:selected, QListView::item:selected {{
|
||||
background-color: {ACCENT};
|
||||
color: {TEXT};
|
||||
}}
|
||||
|
||||
/* ---- menus ---------------------------------------------------------- */
|
||||
QMenu {{
|
||||
background-color: {SURFACE_ALT};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
padding: 4px;
|
||||
}}
|
||||
QMenu::item {{ padding: 6px 22px 6px 14px; border-radius: 3px; }}
|
||||
QMenu::item:selected {{ background-color: {ACCENT}; }}
|
||||
QMenu::item:disabled {{ color: {TEXT_DISABLED}; }}
|
||||
QMenu::separator {{ height: 1px; background-color: {BORDER}; margin: 4px 6px; }}
|
||||
|
||||
/* ---- sliders (seek and volume were fully native) --------------------- */
|
||||
QSlider::groove:horizontal {{
|
||||
height: 4px;
|
||||
background-color: {BORDER};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
QSlider::sub-page:horizontal {{
|
||||
background-color: {ACCENT};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
background-color: {TEXT};
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -5px 0;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
QSlider::handle:horizontal:hover {{ background-color: {FOCUS}; }}
|
||||
QSlider::handle:horizontal:disabled {{ background-color: {TEXT_DISABLED}; }}
|
||||
QSlider::groove:vertical {{ width: 4px; background-color: {BORDER}; border-radius: 2px; }}
|
||||
QSlider::handle:vertical {{
|
||||
background-color: {TEXT}; height: 12px; margin: 0 -5px; border-radius: 6px;
|
||||
}}
|
||||
|
||||
/* ---- splitters ------------------------------------------------------- */
|
||||
QSplitter::handle {{ background-color: {BORDER}; }}
|
||||
QSplitter::handle:horizontal {{ width: 4px; }}
|
||||
QSplitter::handle:vertical {{ height: 4px; }}
|
||||
QSplitter::handle:hover {{ background-color: {ACCENT}; }}
|
||||
|
||||
/* ---- tooltips (were rendering in the system's light style) ----------- */
|
||||
QToolTip {{
|
||||
background-color: {SURFACE_ALT};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
|
||||
/* ---- horizontal scrollbar (only the vertical one was styled) --------- */
|
||||
QScrollBar:horizontal {{
|
||||
background-color: {BG};
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background-color: {BORDER_STRONG};
|
||||
min-width: 24px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background-color: {ACCENT}; }}
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
||||
width: 0; border: none; background: none;
|
||||
}}
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; }}
|
||||
|
||||
/* ---- assorted controls left unstyled --------------------------------- */
|
||||
QRadioButton, QCheckBox {{ color: {TEXT}; spacing: 7px; }}
|
||||
QRadioButton:disabled, QCheckBox:disabled {{ color: {TEXT_DISABLED}; }}
|
||||
QGroupBox {{
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QGroupBox::title {{
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 5px;
|
||||
color: {TEXT_MUTED};
|
||||
}}
|
||||
QSpinBox, QDoubleSpinBox {{
|
||||
background-color: {SURFACE};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
}}
|
||||
|
||||
/* ---- corrections to rules that already exist ------------------------- */
|
||||
/* Upstream's QPushButton:pressed changes the padding, which shifts every
|
||||
label two pixels down-and-right on click and clips the artwork on the
|
||||
fixed-width icon buttons. The background change alone reads as pressed. */
|
||||
QPushButton:pressed {{
|
||||
padding: 8px 15px;
|
||||
}}
|
||||
/* One consistent icon size everywhere, since icons are now real artwork. */
|
||||
QPushButton, QToolButton {{
|
||||
qproperty-iconSize: 18px 18px;
|
||||
}}
|
||||
QToolButton {{
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QToolButton:hover {{ background-color: {SURFACE_HOVER}; }}
|
||||
QToolButton:pressed {{ background-color: {BORDER}; }}
|
||||
"""
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Application-wide button polish
|
||||
==============================
|
||||
|
||||
Two problems, both spread across every file in the GUI:
|
||||
|
||||
- **Emoji standing in for icons.** `cards.download` is the single character
|
||||
"⬇" on a button with no text and no tooltip; the queue's clear button is
|
||||
"✕"; the downloads folder button is "📁". These depend entirely on a system
|
||||
emoji font, and render as tofu boxes without one.
|
||||
- **Almost nothing has a tooltip.** Three widgets in the whole application set
|
||||
one, and nothing sets an accessible name -- so icon-only buttons are
|
||||
unidentifiable to sighted users and invisible to a screen reader alike.
|
||||
|
||||
Fixing that at the call sites would mean editing well over a hundred of them,
|
||||
most in files owned by upstream YTSage, which would conflict on the next merge.
|
||||
|
||||
Instead this installs one event filter on the QApplication and handles
|
||||
`QEvent.Polish`, which Qt sends to every widget exactly once before it is first
|
||||
shown -- including widgets inside dialogs built by upstream code, and including
|
||||
any added later. One hook, whole tree, no call-site edits.
|
||||
|
||||
New code does not need the glyph map: give a button a `sageIcon` property and
|
||||
this will resolve it.
|
||||
|
||||
btn.setProperty("sageIcon", "download")
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject
|
||||
from PySide6.QtWidgets import QAbstractButton, QApplication
|
||||
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
#: Emoji that are standing in for icons, and what they actually mean.
|
||||
#: Matched as the whole label, or as a leading glyph followed by real text
|
||||
#: ("▶ Play"), never mid-string.
|
||||
GLYPH_ICONS: Dict[str, str] = {
|
||||
"⬇": "download",
|
||||
"▶": "play",
|
||||
"⏸": "pause",
|
||||
"⏹": "stop",
|
||||
"✕": "x",
|
||||
"✖": "x",
|
||||
"×": "x",
|
||||
"+": "plus",
|
||||
"+": "plus",
|
||||
"📁": "folder-open",
|
||||
"📂": "folder-open",
|
||||
"🔄": "refresh",
|
||||
"🗑": "trash",
|
||||
"⚙": "settings",
|
||||
"🔍": "search",
|
||||
}
|
||||
|
||||
#: Tooltips for buttons whose label is only artwork once the glyph is
|
||||
#: replaced, so they do not end up with no description at all.
|
||||
ICON_TOOLTIPS: Dict[str, str] = {
|
||||
"download": "Download",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"stop": "Stop",
|
||||
"x": "Clear",
|
||||
"plus": "Add",
|
||||
"folder-open": "Open folder",
|
||||
"refresh": "Refresh",
|
||||
"trash": "Delete",
|
||||
"settings": "Settings",
|
||||
"search": "Search",
|
||||
"maximize": "Fullscreen",
|
||||
"minimize": "Leave fullscreen",
|
||||
"volume-mute": "Mute",
|
||||
"skip-forward": "Next",
|
||||
"skip-back": "Previous",
|
||||
}
|
||||
|
||||
|
||||
def _strip_accelerator(text: str) -> str:
|
||||
""""&Yes" -> "Yes". Qt's mnemonic markers must not defeat matching."""
|
||||
return text.replace("&&", "\x00").replace("&", "").replace("\x00", "&")
|
||||
|
||||
|
||||
class ButtonPolisher(QObject):
|
||||
"""Turns placeholder glyphs into icons and fills in missing descriptions."""
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
if event.type() == QEvent.Type.Polish and isinstance(obj, QAbstractButton):
|
||||
try:
|
||||
self._polish_button(obj)
|
||||
except Exception as e:
|
||||
# Cosmetics must never break a dialog from opening.
|
||||
logger.debug(f"Button polish skipped: {e}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _polish_button(self, btn: QAbstractButton) -> None:
|
||||
icon_name: Optional[str] = None
|
||||
|
||||
# 1. Explicit opt-in wins.
|
||||
declared = btn.property("sageIcon")
|
||||
if declared:
|
||||
icon_name = str(declared)
|
||||
|
||||
raw_text = btn.text() or ""
|
||||
text = _strip_accelerator(raw_text).strip()
|
||||
|
||||
# 2. Otherwise infer from a placeholder glyph. Buttons carrying a
|
||||
# mnemonic are left alone: those are standard dialog buttons.
|
||||
if icon_name is None and "&" not in raw_text and text:
|
||||
first = text[0]
|
||||
if text in GLYPH_ICONS:
|
||||
icon_name = GLYPH_ICONS[text]
|
||||
text = "" # the glyph *was* the whole label
|
||||
elif first in GLYPH_ICONS and len(text) > 1 and text[1] in " \t":
|
||||
icon_name = GLYPH_ICONS[first]
|
||||
text = text[1:].strip()
|
||||
|
||||
if icon_name is not None and btn.icon().isNull():
|
||||
# White reads on the accent-coloured buttons; the muted token
|
||||
# would disappear into them.
|
||||
btn.setIcon(icons.icon(icon_name, theme.ICON_ON_ACCENT))
|
||||
if text != _strip_accelerator(raw_text).strip():
|
||||
btn.setText(text)
|
||||
|
||||
# 3. Every button gets something to describe it.
|
||||
label = _strip_accelerator(btn.text() or "").strip()
|
||||
if not btn.toolTip():
|
||||
if label:
|
||||
btn.setToolTip(label)
|
||||
elif icon_name and icon_name in ICON_TOOLTIPS:
|
||||
btn.setToolTip(ICON_TOOLTIPS[icon_name])
|
||||
elif not btn.icon().isNull():
|
||||
# An icon-only button nobody has described. Name it so the
|
||||
# gap is findable rather than silent.
|
||||
logger.debug(f"Icon-only button without a tooltip: {btn.objectName() or btn!r}")
|
||||
|
||||
if not btn.accessibleName():
|
||||
btn.setAccessibleName(label or btn.toolTip())
|
||||
|
||||
|
||||
_polisher: Optional[ButtonPolisher] = None
|
||||
|
||||
|
||||
def install(app: Optional[QApplication] = None) -> None:
|
||||
"""Install once, before the main window is built."""
|
||||
global _polisher
|
||||
if _polisher is not None:
|
||||
return
|
||||
app = app or QApplication.instance()
|
||||
if app is None:
|
||||
logger.debug("UI polish not installed: no QApplication yet.")
|
||||
return
|
||||
_polisher = ButtonPolisher(app)
|
||||
app.installEventFilter(_polisher)
|
||||
logger.debug("Button polish filter installed.")
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "العربية (Arabic)",
|
||||
"select_language": "اختر اللغة:",
|
||||
"current_language": "اللغة الحالية: {language}",
|
||||
"restart_required": "سيتم تطبيق تغيير اللغة بعد إعادة تشغيل التطبيق.",
|
||||
"english": "الإنجليزية",
|
||||
"spanish": "الإسبانية",
|
||||
"portuguese": "البرتغالية",
|
||||
"russian": "الروسية",
|
||||
"chinese": "الصينية",
|
||||
"german": "الألمانية",
|
||||
"french": "الفرنسية",
|
||||
"hindi": "الهندية",
|
||||
"indonesian": "الإندونيسية",
|
||||
"turkish": "التركية",
|
||||
"polish": "البولندية",
|
||||
"italian": "الإيطالية",
|
||||
"arabic": "العربية",
|
||||
"japanese": "اليابانية",
|
||||
"help_text": "اختر اللغة المفضلة للواجهة.",
|
||||
"restart_notice": "سيتم تطبيق تغيير اللغة بعد إعادة تشغيل التطبيق."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "جاهز"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "إظهار التنسيقات:",
|
||||
"video_format": "تنسيق الفيديو:",
|
||||
"audio_format": "تنسيق الصوت:",
|
||||
"no_formats": "لا توجد تنسيقات متاحة",
|
||||
"loading": "جاري تحميل التنسيقات...",
|
||||
"select": "اختر",
|
||||
"quality": "الجودة",
|
||||
"extension": "الامتداد",
|
||||
"resolution": "الدقة",
|
||||
"file_size": "حجم الملف",
|
||||
"codec": "الترميز",
|
||||
"audio": "الصوت",
|
||||
"fps": "إطار/ث",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "سيتم دمج الصوت",
|
||||
"has_audio": "✓ يحتوي على صوت",
|
||||
"audio_only": "صوت فقط",
|
||||
"best_4k": "الأفضل (4K)",
|
||||
"best_2k": "الأفضل (2K)",
|
||||
"high_1080p": "عالية (1080p)",
|
||||
"high_720p": "عالية (720p)",
|
||||
"medium_480p": "متوسطة (480p)",
|
||||
"low_quality": "جودة منخفضة",
|
||||
"best_audio": "أفضل صوت",
|
||||
"high_audio": "صوت عالي",
|
||||
"medium_audio": "صوت متوسط",
|
||||
"low_audio": "صوت منخفض",
|
||||
"audio_only_resolution": "صوت فقط"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "إعادة تعيين",
|
||||
"download": "تنزيل",
|
||||
"pause": "إيقاف مؤقت",
|
||||
"resume": "استئناف",
|
||||
"cancel": "إلغاء",
|
||||
"browse": "تصفح",
|
||||
"clear": "مسح",
|
||||
"ok": "موافق",
|
||||
"apply": "تطبيق",
|
||||
"close": "إغلاق",
|
||||
"run_command": "تشغيل الأمر",
|
||||
"custom_command_help": "مساعدة",
|
||||
"about": "حول",
|
||||
"analyze": "تحليل",
|
||||
"paste_url": "لصق الرابط",
|
||||
"select_videos": "اختر الفيديوهات...",
|
||||
"video": "فيديو",
|
||||
"audio_only": "صوت فقط",
|
||||
"custom_options": "خيارات مخصصة",
|
||||
"trim_video": "قص الفيديو",
|
||||
"download_settings": "إعدادات التنزيل",
|
||||
"update": "تحديث yt-dlp",
|
||||
"select_defaults": "اختر الافتراضي",
|
||||
"select_all": "تحديد الكل",
|
||||
"deselect_all": "إلغاء تحديد الكل",
|
||||
"open_folder": "فتح موقع المجلد",
|
||||
"history": "السجل",
|
||||
"save_playlist": "حفظ قائمة التشغيل باسم"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "خيارات مخصصة",
|
||||
"settings": "الإعدادات",
|
||||
"select_folder": "اختر مجلد التنزيل",
|
||||
"sponsorblock_categories": "فئات SponsorBlock",
|
||||
"sponsorblock_description": "اختر أنواع مقاطع الفيديو المراد إزالتها تلقائياً أثناء التنزيل.\nيستخدم SponsorBlock بيانات مقدمة من المجتمع لتحديد هذه المقاطع.",
|
||||
"select_subtitles": "اختر الترجمات",
|
||||
"filter_languages_placeholder": "تصفية اللغات (مثال: ar، en)...",
|
||||
"no_subtitles_available": "لا توجد ترجمات متاحة",
|
||||
"matching": "مطابقة",
|
||||
"ytdlp_log_title": "سجل yt-dlp",
|
||||
"filter_playlist_placeholder": "تصفية الفيديوهات..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "تسجيل الدخول بالكوكيز",
|
||||
"custom_command": "أمر مخصص",
|
||||
"proxy": "بروكسي",
|
||||
"language": "اللغة",
|
||||
"updater": "التحديث"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "اختر طريقة لتوفير ملفات تعريف الارتباط للمصادقة.\nيسمح هذا بتنزيل مقاطع الفيديو الخاصة وملفات الصوت عالية الجودة.",
|
||||
"cookie_source": "مصدر الكوكيز",
|
||||
"use_cookie_file": "استخدام ملف كوكيز",
|
||||
"extract_from_browser": "استخراج من المتصفح",
|
||||
"recommended": "موصى به",
|
||||
"cookie_file": "ملف الكوكيز",
|
||||
"cookie_file_placeholder": "مسار ملف cookies.txt...",
|
||||
"browser_selection": "اختيار المتصفح",
|
||||
"browser_help": "اختر المتصفح لاستخراج ملفات تعريف الارتباط منه:",
|
||||
"browser_label": "المتصفح:",
|
||||
"profile_label": "الملف الشخصي:",
|
||||
"profile_placeholder": "افتراضي",
|
||||
"browser_extract_message": "سيتم استخراج كوكيز المتصفح بعد التطبيق",
|
||||
"file_selected_message": "تم تحديد ملف الكوكيز - انقر على موافق للتطبيق",
|
||||
"select_file_title": "اختر ملف الكوكيز",
|
||||
"file_filter": "ملفات الكوكيز (*.txt *.lwp)",
|
||||
"file_selected_title": "تم تطبيق ملف الكوكيز",
|
||||
"file_applied_message": "تم تطبيق ملف الكوكيز: {path}",
|
||||
"browser_selected_title": "تم تطبيق كوكيز المتصفح",
|
||||
"browser_applied_message": "سيتم استخراج كوكيز المتصفح من: {browser}",
|
||||
"cleared_title": "تم مسح الكوكيز",
|
||||
"cleared_message": "تم مسح إعدادات الكوكيز",
|
||||
"active_browser": "✓ نشط: كوكيز المتصفح ({browser})",
|
||||
"active_file": "✓ نشط: ملف كوكيز ({file})",
|
||||
"none_active": "○ لا توجد كوكيز نشطة",
|
||||
"remember_settings": "تذكر إعدادات ملفات تعريف الارتباط عند بدء التشغيل التالي"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "أدخل أمر yt-dlp مخصص أدناه. سيتم إضافة الرابط الحالي تلقائياً.<br><br>للحصول على قائمة كاملة بالخيارات وأمثلة الاستخدام <a href=\"{docs_url}\">انقر هنا لمشاهدة الوثائق الرسمية لـ yt-dlp</a>.<br><br>ملاحظة: يتم التعامل مع مسار التنزيل ونموذج اسم الملف تلقائياً.",
|
||||
"input_label": "معاملات yt-dlp:",
|
||||
"input_placeholder": "أدخل معاملات yt-dlp هنا...\n\nمثال: --extract-audio --audio-format mp3",
|
||||
"output_label": "مخرجات الأمر:",
|
||||
"output_placeholder": "ستظهر مخرجات الأمر هنا...",
|
||||
"command_placeholder": "أدخل أمر yt-dlp مخصص...",
|
||||
"command_help": "العناصر المتاحة:\n{url} - رابط الفيديو\n{output} - مجلد الإخراج\n\nمثال: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 الأمر الكامل: {command}",
|
||||
"command_success": "✅ تم تنفيذ الأمر المخصص بنجاح!",
|
||||
"command_failed": "❌ فشل الأمر برمز الخروج {code}",
|
||||
"command_error": "❌ خطأ في تنفيذ الأمر المخصص: {error}",
|
||||
"error_no_url": "❌ خطأ: لم يتم تقديم عنوان URL. يرجى إدخال رابط في النافذة الرئيسية.",
|
||||
"error_no_command": "❌ خطأ: لم يتم تقديم أي أمر. يرجى إدخال معاملات yt-dlp.",
|
||||
"executing": "🚀 جارٍ تنفيذ أمر yt-dlp مخصص",
|
||||
"url_label": "📍 الرابط: {url}",
|
||||
"args_label": "⚙️ المعاملات: {command}",
|
||||
"download_path_label": "📁 مسار التنزيل: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "قم بتكوين إعدادات البروكسي للتنزيلات. اتركه فارغاً للاتصال المباشر.",
|
||||
"main_proxy": "البروكسي الرئيسي",
|
||||
"main_proxy_help": "خادم البروكسي الرئيسي لجميع التنزيلات. يدعم بروتوكولات HTTP/HTTPS و SOCKS5.",
|
||||
"proxy_url_label": "رابط البروكسي:",
|
||||
"proxy_url_placeholder": "http://proxy:port أو socks5://proxy:port",
|
||||
"proxy_examples": "أمثلة:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "بروكسي تجاوز الموقع الجغرافي",
|
||||
"geo_proxy_help": "بروكسي إضافي خصيصاً لتجاوز القيود الجغرافية.",
|
||||
"geo_proxy_url_label": "رابط بروكسي تجاوز الموقع:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "مسح البروكسي الرئيسي",
|
||||
"clear_geo_proxy": "مسح بروكسي الموقع",
|
||||
"proxy_url": "رابط البروكسي",
|
||||
"proxy_placeholder": "http://proxy:port أو socks5://proxy:port",
|
||||
"geo_bypass": "بروكسي تجاوز الموقع الجغرافي (للقيود الجغرافية)",
|
||||
"geo_bypass_placeholder": "http://proxy:port لتجاوز الحظر الجغرافي",
|
||||
"invalid_main_url": "تنسيق رابط البروكسي الرئيسي غير صالح",
|
||||
"invalid_geo_url": "تنسيق رابط بروكسي الموقع غير صالح",
|
||||
"main_configured": "تم تكوين البروكسي الرئيسي",
|
||||
"geo_configured": "تم تكوين بروكسي الموقع",
|
||||
"set_title": "تم تعيين الوكيل",
|
||||
"set_message": "تم تعيين الوكيل الرئيسي وحفظه: {proxy}",
|
||||
"geo_set_title": "تم تعيين وكيل جغرافي",
|
||||
"geo_set_message": "تم تعيين وكيل التحقق الجغرافي وحفظه: {proxy}",
|
||||
"cleared_title": "تم مسح إعدادات الوكيل",
|
||||
"cleared_message": "تم مسح جميع إعدادات الوكيل وحفظها.",
|
||||
"saved_main": "تم حفظ الوكيل الرئيسي: {proxy}",
|
||||
"saved_geo": "تم حفظ وكيل الموقع: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "جاري التحضير للتنزيل...",
|
||||
"starting": "🚀 بدء التنزيل...",
|
||||
"fetching_info": "🔍 جاري الحصول على معلومات الفيديو...",
|
||||
"preparing_streams": "🎯 جاري تحضير تدفقات الفيديو...",
|
||||
"downloading_audio": "⏬ جاري تنزيل الصوت...",
|
||||
"downloading_video": "⏬ جاري تنزيل الفيديو...",
|
||||
"downloading_subtitle": "⏬ جاري تنزيل الترجمات...",
|
||||
"downloading": "⏬ جاري التنزيل...",
|
||||
"completed": "✅ اكتمل التنزيل!",
|
||||
"video_completed": "✅ اكتمل تنزيل الفيديو!",
|
||||
"audio_completed": "✅ اكتمل تنزيل الصوت!",
|
||||
"subtitle_completed": "✅ اكتمل تنزيل الترجمات!",
|
||||
"completed_cleaning": "✅ اكتمل التنزيل! جاري التنظيف...",
|
||||
"cancelled": "تم إلغاء التنزيل",
|
||||
"processing_playlist": "📋 جاري معالجة بيانات قائمة التشغيل...",
|
||||
"paused": "تم إيقاف التنزيل مؤقتاً",
|
||||
"resumed": "تم استئناف التنزيل",
|
||||
"merging_formats": "✨ المعالجة اللاحقة: دمج التنسيقات...",
|
||||
"removing_sponsor_segments": "✨ المعالجة اللاحقة: إزالة مقاطع الرعاية...",
|
||||
"speed": "السرعة",
|
||||
"eta": "الوقت المتبقي",
|
||||
"please_enter_url": "الرجاء إدخال رابط أولاً.",
|
||||
"please_set_path": "الرجاء تحديد مسار التنزيل أولاً.",
|
||||
"please_enter_url_and_path": "الرجاء إدخال رابط وتحديد مسار التنزيل.",
|
||||
"please_select_format": "الرجاء اختيار تنسيق أولاً.",
|
||||
"downloading_fallback": "⚡ جاري التنزيل..."
|
||||
},
|
||||
"update": {
|
||||
"title": "تحديث yt-dlp",
|
||||
"checking": "جاري التحقق من التحديثات...",
|
||||
"update_available": "تحديث متاح!\nالإصدار الحالي: {current}\nأحدث إصدار: {latest}",
|
||||
"up_to_date": "yt-dlp محدث (الإصدار {version})",
|
||||
"could_not_determine": "تعذر تحديد الإصدار.",
|
||||
"error_comparing": "خطأ في مقارنة الإصدارات: {error}",
|
||||
"update_available_failed": "تحديث متاح! (فشلت المقارنة)\nالحالي: {current}\nالأحدث: {latest}",
|
||||
"updating": "جاري التحديث...",
|
||||
"initializing": "🚀 جاري تهيئة عملية التحديث...",
|
||||
"checking_current": "🔍 جاري التحقق من التثبيت الحالي...",
|
||||
"found_at": "📍 تم العثور على yt-dlp في: {path}",
|
||||
"error_getting_path": "❌ خطأ في الحصول على مسار yt-dlp: {error}",
|
||||
"updating_binary": "📦 جاري تحديث ملف yt-dlp الثنائي المدار بواسطة التطبيق...",
|
||||
"update_failed": "❌ فشل تحديث yt-dlp. حاول مرة أخرى أو تحقق من اتصال الإنترنت.",
|
||||
"binary_updated": "✅ تم تحديث الملف الثنائي بنجاح!",
|
||||
"update_failed_stderr": "❌ فشل تحديث yt-dlp: {error}",
|
||||
"update_timeout": "❌ انتهت مهلة تحديث yt-dlp.",
|
||||
"unexpected_error": "❌ خطأ غير متوقع أثناء التحديث: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp محدث بالفعل!",
|
||||
"update_success": "✅ تم تحديث yt-dlp بنجاح!",
|
||||
"already_latest": "yt-dlp محدث (الإصدار {version})",
|
||||
"network_error": "❌ خطأ في الشبكة أثناء التحديث: {error}",
|
||||
"general_error": "❌ فشل التحديث: {error}",
|
||||
"update_in_progress_title": "التحديث قيد التقدم",
|
||||
"update_in_progress_message": "يتم تحديث yt-dlp حاليًا. يرجى الانتظار لحظة."
|
||||
},
|
||||
"about": {
|
||||
"title": "حول YTSage",
|
||||
"version": "الإصدار {version}",
|
||||
"description": "برنامج تنزيل يوتيوب حديث مع واجهة PySide6 نظيفة.",
|
||||
"author": "المطور: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "معلومات النظام",
|
||||
"loading": "🔄 جاري تحميل معلومات النظام...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 السجلات",
|
||||
"logs_tooltip": "فتح مجلد سجلات التطبيق",
|
||||
"refreshing": "🔄 جاري التحديث...",
|
||||
"refresh_failed": "فشل التحديث",
|
||||
"refresh_failed_message": "تعذر تحديث معلومات الإصدار.",
|
||||
"detected": "✓ تم الكشف",
|
||||
"missing": "✗ مفقود",
|
||||
"not_available": "غير متاح"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "قص الفيديو",
|
||||
"time_range_group": "النطاق الزمني",
|
||||
"start_time": "وقت البدء (HH:MM:SS)",
|
||||
"end_time": "وقت الانتهاء (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "وقت البدء (HH:MM:SS)",
|
||||
"end_time_placeholder": "وقت الانتهاء (HH:MM:SS)",
|
||||
"force_keyframes": "فرض الإطارات الرئيسية عند نقاط القص",
|
||||
"help_text": "حدد أوقات البدء والانتهاء لقص الفيديو.\nاتركه فارغاً لتنزيل الفيديو كاملاً.",
|
||||
"invalid_format": "تنسيق الوقت غير صالح. استخدم تنسيق HH:MM:SS.",
|
||||
"start_after_end": "لا يمكن أن يكون وقت البدء بعد وقت الانتهاء."
|
||||
},
|
||||
"settings": {
|
||||
"title": "إعدادات التنزيل",
|
||||
"download_path": "مسار التنزيل",
|
||||
"browse": "تصفح...",
|
||||
"speed_limit": "حد السرعة",
|
||||
"speed_limit_placeholder": "بدون",
|
||||
"generic_mode": "الوضع العام",
|
||||
"enable_generic_mode": "تفعيل الوضع العام (دعم المواقع غير التابعة ليوتيوب)",
|
||||
"generic_mode_help": "يسمح بالتنزيل من Dailymotion وCBC Gem ومواقع أخرى يدعمها yt-dlp.",
|
||||
"auto_update_ytdlp": "التحديثات التلقائية لـ yt-dlp",
|
||||
"enable_auto_updates": "تفعيل التحديثات التلقائية لـ yt-dlp",
|
||||
"update_frequency": "تكرار التحديثات:",
|
||||
"check_startup": "تحقق عند كل بداية تشغيل (ساعة واحدة على الأقل بين الفحوصات)",
|
||||
"check_daily": "تحقق يومياً",
|
||||
"check_weekly": "تحقق أسبوعياً",
|
||||
"check_updates_now": "تحديث yt-dlp",
|
||||
"update_check_title": "التحقق من التحديثات",
|
||||
"could_not_determine_version": "تعذر تحديد الإصدار الحالي لـ yt-dlp.",
|
||||
"update_available_dialog": "تحديث متاح!\n\nالحالي: {current}\nالأحدث: {latest}\n\nانقر فوق موافق للمتابعة بالتحديث.",
|
||||
"up_to_date_dialog": "yt-dlp محدث!\n\nالإصدار الحالي: {version}",
|
||||
"error_checking_updates": "خطأ في التحقق من التحديثات: {error}",
|
||||
"settings_saved_title": "تم حفظ الإعدادات",
|
||||
"settings_saved_message": "تم حفظ إعدادات التحديث التلقائي بنجاح!",
|
||||
"error_title": "خطأ",
|
||||
"failed_save_settings": "فشل حفظ إعدادات التحديث التلقائي.",
|
||||
"error_saving_settings": "خطأ في حفظ إعدادات التحديث التلقائي: {error}",
|
||||
"ytdlp_channel": "قناة إصدار yt-dlp",
|
||||
"ytdlp_channel_stable": "مستقر (إصدارات مختبرة)",
|
||||
"ytdlp_channel_nightly": "ليلي (تحديثات يومية، موصى به من yt-dlp)",
|
||||
"ytdlp_channel_description": "اختر بين الإصدارات المستقرة والليلية. يتم تحديث الإصدارات الليلية يومياً بأحدث الإصلاحات والميزات. يمكنك تبديل القنوات في أي وقت.",
|
||||
"ytdlp_switching_channel": "التبديل إلى قناة {channel}...",
|
||||
"ytdlp_channel_switched": "✅ تم التبديل بنجاح إلى قناة {channel}!",
|
||||
"ytdlp_channel_switch_failed": "❌ فشل تبديل القناة: {error}",
|
||||
"ytdlp_current_channel": "القناة الحالية: {channel}",
|
||||
"app_updates_title": "تحديثات YTSage",
|
||||
"check_app_updates": "التحقق من تحديثات YTSage عند بدء التشغيل",
|
||||
"check_beta_updates": "تلقي تحديثات تجريبية (Beta)",
|
||||
"auto_update_title": "إعدادات التحديثات التلقائية",
|
||||
"auto_update_header": "🔄 إعدادات التحديثات التلقائية",
|
||||
"auto_update_description": "قم بتكوين التحديثات التلقائية لـ yt-dlp لضمان الحصول على أحدث الميزات وإصلاحات الأخطاء.",
|
||||
"current_status": "الحالة الحالية",
|
||||
"current_version_label": "إصدار yt-dlp الحالي: جاري التحقق...",
|
||||
"last_check_label": "آخر فحص للتحديثات: أبداً",
|
||||
"next_check_label": "الفحص التالي: بناءً على الإعدادات",
|
||||
"manual_check_button": "🔍 تحقق من التحديثات الآن",
|
||||
"update_frequency_group": "تكرار التحديثات",
|
||||
"save_settings": "حفظ الإعدادات",
|
||||
"settings_saved_successfully": "✅ تم حفظ الإعدادات بنجاح!",
|
||||
"error_saving": "❌ خطأ في حفظ الإعدادات: {error}",
|
||||
"output_format_settings": "إعدادات تنسيق الإخراج",
|
||||
"force_output_format": "فرض تنسيق الإخراج عند الدمج",
|
||||
"preferred_format": "التنسيق المفضل:",
|
||||
"force_format_help": "عند التمكين، سيتم تحويل الفيديوهات المدموجة إلى تنسيقك المفضل. قم بالتعطيل للسماح لـ yt-dlp بالقرار تلقائياً.",
|
||||
"format_mp4": "MP4 (الأكثر توافقاً)",
|
||||
"format_webm": "WebM (حديث، مفتوح)",
|
||||
"format_mkv": "MKV (غني بالميزات)",
|
||||
"audio_format_settings": "إعدادات تنسيق الصوت",
|
||||
"force_audio_format": "فرض تنسيق الصوت لتنزيلات الصوت فقط",
|
||||
"audio_normalization": "تسوية الصوت (EBU R128)",
|
||||
"audio_normalization_help": "عند التمكين، سيتم تسوية المسارات الصوتية. ملاحظة: هذا يتطلب إعادة الترميز، لذلك يجب فرض تنسيق صوتي محدد (مثل MP3 أو M4A).",
|
||||
"preferred_audio_format": "تنسيق الصوت المفضل:",
|
||||
"force_audio_format_help": "عند التمكين، سيتم تحويل تنزيلات الصوت فقط إلى التنسيق المفضل لديك. هذا ينطبق فقط عند تنزيل تنسيقات الصوت.",
|
||||
"audio_format_best": "الأفضل (بدون تحويل)",
|
||||
"audio_format_aac": "AAC (جيد للتحرير)",
|
||||
"audio_format_mp3": "MP3 (عالمي)",
|
||||
"audio_format_flac": "FLAC (بدون فقدان)",
|
||||
"audio_format_wav": "WAV (غير مضغوط)",
|
||||
"audio_format_opus": "Opus (فعال)",
|
||||
"audio_format_m4a": "M4A (أبل)",
|
||||
"audio_format_vorbis": "Vorbis (مفتوح)",
|
||||
"filename_format": "تنسيق اسم الملف الناتج",
|
||||
"filename_format_help": "المتغيرات المتاحة: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. يتم دعم صيغة قالب إخراج yt-dlp القياسية.",
|
||||
"tab_general": "عام",
|
||||
"tab_format": "تنسيق",
|
||||
"tab_file": "ملف",
|
||||
"concurrent_fragments": "اتصالات متزامنة",
|
||||
"concurrent_fragments_help": "عدد الاتصالات لكل عملية تحميل. القيم الأعلى تتجاوز الاختناق ولكنها قد تسبب حظراً مؤقتاً إذا كانت عالية جداً. الافتراضي: 1.",
|
||||
"defaults_settings": "إعدادات الاختيار الافتراضية",
|
||||
"default_video_quality": "دقة الفيديو الافتراضية (الطول):",
|
||||
"default_subtitle_language": "لغات الترجمة الافتراضية:",
|
||||
"defaults_help": "اضبط ارتفاع الفيديو المفضل ولغات الترجمة (مفصولة بفاصلة). سيتم اختيارها تلقائيًا إذا كانت متوفرة."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "أدخل رابط فيديو يوتيوب أو قائمة تشغيل",
|
||||
"url_placeholder_generic": "أدخل رابط فيديو أو قائمة تشغيل من أي موقع مدعوم",
|
||||
"merge_subtitles": "دمج الترجمات",
|
||||
"save_thumbnail": "حفظ الصورة المصغرة",
|
||||
"save_description": "حفظ الوصف",
|
||||
"embed_chapters": "تضمين الفصول",
|
||||
"subtitles_selected": "{count} محدد",
|
||||
"all_selected": "تم تحديد الكل",
|
||||
"select_videos_all": "اختر الفيديوهات... (تم تحديد الكل)",
|
||||
"please_enter_url": "الرجاء إدخال رابط أولاً",
|
||||
"error_title": "خطأ",
|
||||
"cookie_file_selected_title": "تم تحديد ملف الكوكيز",
|
||||
"cookie_file_selected_message": "ملف الكوكيز المحدد: {path}",
|
||||
"browser_cookies_selected_title": "تم تحديد كوكيز المتصفح",
|
||||
"browser_cookies_selected_message": "سيتم استخراج كوكيز المتصفح من: {browser}",
|
||||
"error_no_format_info": "خطأ: لا توجد معلومات تنسيق متاحة.",
|
||||
"error_extract_info": "خطأ: تعذر استخراج معلومات الفيديو الأساسية. تحقق من الرابط.",
|
||||
"analyzing_preparing": "جاري التحضير للطلب...",
|
||||
"analyzing_extracting_basic": "جاري استخراج المعلومات الأساسية...",
|
||||
"analyzing_extracting_detailed": "جاري استخراج المعلومات التفصيلية...",
|
||||
"analyzing_processing_video": "جاري معالجة بيانات الفيديو...",
|
||||
"analyzing_processing_formats": "جاري معالجة التنسيقات...",
|
||||
"analyzing_loading_thumbnail": "جاري تحميل الصورة المصغرة...",
|
||||
"analyzing_processing_subtitles": "جاري معالجة الترجمات...",
|
||||
"analyzing_updating_table": "جاري تحديث جدول التنسيقات...",
|
||||
"analysis_complete": "اكتمل التحليل!",
|
||||
"analyzing_extracting_ytdlp": "جاري استخراج المعلومات...",
|
||||
"analyzing_fetching_first_video": "جاري جلب التنسيقات للفيديو الأول...",
|
||||
"analyzing_processing_data": "جاري معالجة البيانات...",
|
||||
"analyzing_processing_formats_ytdlp": "جاري معالجة التنسيقات...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "جاري تحميل الصورة المصغرة...",
|
||||
"analyzing_processing_subtitles_ytdlp": "جاري معالجة الترجمات...",
|
||||
"select_subtitles": "اختر الترجمات...",
|
||||
"sponsorblock_categories": "فئات SponsorBlock...",
|
||||
"invalid_url_or_enter": "عنوان URL غير صالح أو الرجاء إدخال عنوان URL.",
|
||||
"zero_selected": "تم اختيار 0",
|
||||
"analyze_first_tooltip": "يرجى تحليل الفيديو أولاً",
|
||||
"audio_mode_disabled": "غير متاح في وضع الصوت فقط",
|
||||
"select_subtitles_first": "يرجى تحديد الترجمة أولاً",
|
||||
"settings_tooltip": "المسار الحالي: {path}\nحد السرعة: {speed_limit}",
|
||||
"speed_limit_none": "بدون",
|
||||
"open_folder_error": "تعذر فتح المجلد: {error}",
|
||||
"time_range_set": "تم تعيين المقطع: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "الراعي",
|
||||
"sponsor_desc": "الإعلانات المدفوعة والتوصيات المدفوعة والإعلانات المباشرة",
|
||||
"selfpromo": "غير مدفوع/ترويج ذاتي",
|
||||
"selfpromo_desc": "الترويج غير المدفوع لمحتوى المنشئ الخاص",
|
||||
"interaction": "تذكير التفاعل",
|
||||
"interaction_desc": "يُطلب من المشاهدين الإعجاب أو الاشتراك أو المتابعة على وسائل التواصل الاجتماعي",
|
||||
"intro": "المقدمة",
|
||||
"intro_desc": "مقدمة الفيديو التي يمكن تخطيها",
|
||||
"outro": "الخاتمة/البطاقات النهائية",
|
||||
"outro_desc": "الاعتمادات النهائية أو عندما ينتهي الفيديو",
|
||||
"preview": "المعاينة/الملخص",
|
||||
"preview_desc": "ملخص موجز لمقاطع الفيديو السابقة أو معاينة المحتوى القادم",
|
||||
"music_offtopic": "قسم غير موسيقي",
|
||||
"music_offtopic_desc": "لمقاطع الفيديو الموسيقية فقط. يشير إلى أقسام غير موسيقية",
|
||||
"filler": "حشو جانبي",
|
||||
"filler_desc": "مشاهد جانبية تُضاف فقط كحشو أو للفكاهة"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "القناة",
|
||||
"views": "👁 المشاهدات",
|
||||
"likes": "👍 الإعجابات",
|
||||
"upload_date": "📅 تاريخ الرفع",
|
||||
"duration": "⏱ المدة",
|
||||
"unknown_channel": "قناة غير معروفة",
|
||||
"unknown_date": "تاريخ غير معروف",
|
||||
"unknown_title": "عنوان غير معروف"
|
||||
},
|
||||
"command": {
|
||||
"running": "جاري التشغيل...",
|
||||
"run_command": "تشغيل الأمر"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 محدد",
|
||||
"one_selected": "فئة واحدة محددة",
|
||||
"count_selected": "{count} محدد"
|
||||
},
|
||||
"status": {
|
||||
"ready": "جاهز",
|
||||
"file_exists": "⚠️ الملف موجود بالفعل",
|
||||
"video_file_exists": "⚠️ ملف الفيديو موجود بالفعل",
|
||||
"audio_file_exists": "⚠️ ملف الصوت موجود بالفعل",
|
||||
"subtitle_file_exists": "⚠️ ملف الترجمات موجود بالفعل",
|
||||
"cancelling": "جاري إلغاء التنزيل...",
|
||||
"thumbnail_saved": "✅ تم حفظ الصورة المصغرة: {filename}",
|
||||
"thumbnail_error": "❌ خطأ في الصورة المصغرة: {error}",
|
||||
"thumbnail_no_image": "لا توجد صورة مصغرة متاحة للحفظ"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "خطأ: قائمة التشغيل لا تحتوي على مقاطع فيديو صالحة.",
|
||||
"playlist_no_url": "خطأ: تعذر الحصول على رابط أول فيديو في قائمة التشغيل.",
|
||||
"ytdlp_not_found": "خطأ: لم يتم العثور على ملف yt-dlp التنفيذي. الرجاء تثبيت yt-dlp أولاً.",
|
||||
"ytdlp_not_found_path": "خطأ: لم يتم العثور على ملف yt-dlp التنفيذي. قد يكون ذلك بسبب تثبيت غير صحيح أو مشكلة في PATH.",
|
||||
"no_data_returned": "خطأ: لم يتم إرجاع بيانات من yt-dlp",
|
||||
"no_format_info": "خطأ: لا توجد معلومات تنسيق متاحة.",
|
||||
"analysis_timeout": "خطأ: انتهت مهلة التحليل. الرجاء المحاولة مرة أخرى.",
|
||||
"invalid_speed_limit": "❌ خطأ: قيمة حد السرعة غير صالحة في الإعدادات.",
|
||||
"ytdlp_failed": "خطأ: فشل yt-dlp: {error}",
|
||||
"parse_failed": "خطأ: فشل تحليل مخرجات yt-dlp: {error}",
|
||||
"analysis_failed": "خطأ: فشل التحليل: {error}",
|
||||
"generic_error": "خطأ: {error}",
|
||||
"download_failed_return_code_conflict": "فشل التنزيل برمز الإرجاع {return_code}. قد يكون ذلك بسبب تعارض بين عدة عمليات تثبيت لـ yt-dlp. جرّب إزالة أي تثبيت لنظام التشغيل (مثل snap أو apt) ثم أعد تشغيل التطبيق.",
|
||||
"download_failed_return_code": "فشل التنزيل برمز الإرجاع {return_code}",
|
||||
"direct_command_error": "خطأ في الأمر المباشر: {error}",
|
||||
"private_video": "قد يكون هذا الفيديو خاصًا. يرجى استخدام ملفات تعريف الارتباط من الخيارات المخصصة."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "تحديث متاح",
|
||||
"new_version_available": "يتوفر إصدار جديد من YTSage!",
|
||||
"current_version_label": "الإصدار الحالي:",
|
||||
"latest_version_label": "أحدث إصدار:",
|
||||
"changelog": "سجل التغييرات",
|
||||
"download_update": "تنزيل التحديث",
|
||||
"remind_later": "ذكرني لاحقاً"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "قائمة تشغيل غير معروفة",
|
||||
"total_videos": "إجمالي الفيديوهات: {count}",
|
||||
"display_format": "قائمة التشغيل: {title} | {count} فيديوهات",
|
||||
"select_videos_title": "اختر مقاطع الفيديو من قائمة التشغيل",
|
||||
"save_as": "حفظ قائمة التشغيل باسم",
|
||||
"save_success_title": "تم بنجاح",
|
||||
"saved_successfully": "تم حفظ قائمة التشغيل بنجاح.",
|
||||
"save_error_title": "خطأ في الحفظ",
|
||||
"no_videos_to_save": "لم يتم العثور على أي مقاطع فيديو لحفظها!",
|
||||
"save_error_msg": "فشل في حفظ قائمة التشغيل."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} محدد"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "الملف موجود بالفعل",
|
||||
"message": "الملف موجود بالفعل:\n{filename}",
|
||||
"info": "تم تنزيل هذا الفيديو بالفعل."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "آخر فحص للتحديث: أبداً",
|
||||
"last_check": "آخر فحص للتحديث: {time}",
|
||||
"next_check_disabled": "الفحص التالي: معطل",
|
||||
"next_check_startup": "الفحص التالي: عند بدء التشغيل",
|
||||
"next_check_overdue": "الفحص التالي: الآن (متأخر)",
|
||||
"next_check": "الفحص التالي: {time}",
|
||||
"next_check_error": "الفحص التالي: خطأ في الحساب",
|
||||
"checking": "🔄 جاري الفحص...",
|
||||
"check_now": "🔍 التحقق من التحديثات الآن",
|
||||
"current_version": "إصدار yt-dlp الحالي: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "لا يمكن أن يكون الرابط فارغًا",
|
||||
"invalid_format": "تنسيق الرابط غير صالح",
|
||||
"invalid_scheme": "يجب أن يبدأ الرابط بـ http:// أو https://",
|
||||
"missing_domain": "رابط غير صالح: اسم النطاق مفقود",
|
||||
"unsupported_platform": "YTSage يدعم فقط روابط YouTube و YouTube Music.\nالنطاق '{domain}' غير مدعوم.",
|
||||
"invalid_youtu_be": "رابط youtu.be غير صالح: معرف الفيديو مفقود"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "هذا فيديو خاص. يمكنك تنزيله عن طريق تسجيل الدخول إلى حسابك باستخدام ملفات تعريف الارتباط.\nانتقل إلى 'خيارات مخصصة' ← 'تسجيل الدخول باستخدام ملفات تعريف الارتباط' ← 'استخراج ملفات تعريف الارتباط من المتصفح' للمصادقة.",
|
||||
"age_restricted": "هذا الفيديو مقيد بالعمر. تحتاج إلى تسجيل الدخول للوصول إليه.\nاستخدم 'خيارات مخصصة' ← 'تسجيل الدخول باستخدام ملفات تعريف الارتباط' للمصادقة بحسابك.",
|
||||
"geo_blocked": "هذا الفيديو غير متاح في منطقتك (محظور جغرافيًا).\nقد تحتاج إلى استخدام VPN أو قد يكون الفيديو مقيدًا في بلدك.",
|
||||
"video_unavailable": "تمت إزالة هذا الفيديو أو لم يعد متاحًا.\nربما تم حذف الفيديو بواسطة المُحمِّل أو إزالته بسبب انتهاك السياسات.",
|
||||
"live_stream": "هذا بث مباشر لا يمكن تنزيله أثناء نشاطه.\nانتظر حتى ينتهي البث، ثم حاول تنزيل النسخة المؤرشفة.",
|
||||
"playlist_error": "تعذر الوصول إلى قائمة التشغيل هذه. قد تكون خاصة أو محذوفة أو فارغة.\nتحقق من وجود قائمة التشغيل وأنها متاحة للعامة.",
|
||||
"network_error": "خطأ في الاتصال بالشبكة. يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.\nإذا استمرت المشكلة، فقد يكون خادم الفيديو غير متاح مؤقتًا.",
|
||||
"invalid_url": "رابط غير صالح أو غير مدعوم. يرجى التحقق من الرابط والمحاولة مرة أخرى.\nتأكد من استخدام رابط صالح لـ YouTube أو Vimeo أو منصة مدعومة أخرى.",
|
||||
"premium_content": "يتطلب هذا المحتوى YouTube Premium أو عضوية القناة.\nتحتاج إلى تسجيل الدخول بحساب لديه حق الوصول إلى هذا المحتوى.",
|
||||
"copyright_blocked": "هذا الفيديو محظور بسبب مطالبات حقوق النشر.\nصاحب المحتوى قيد الوصول إلى هذا الفيديو.",
|
||||
"extraction_failed": "فشل استخراج معلومات الفيديو. قد تكون هذه مشكلة مؤقتة.\nيرجى المحاولة مرة أخرى بعد بضع دقائق، أو تحقق من صحة رابط الفيديو.",
|
||||
"generic_error": "تعذر استخراج معلومات الفيديو. يرجى التحقق من الرابط الخاص بك.\nالتفاصيل التقنية: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "سجل التنزيلات",
|
||||
"clear_all": "مسح الكل",
|
||||
"clear_confirm_title": "مسح السجل؟",
|
||||
"clear_confirm_message": "هل أنت متأكد؟ لا يمكن التراجع عن هذا.",
|
||||
"no_history": "لا يوجد سجل بعد",
|
||||
"no_history_description": "ستظهر تنزيلاتك هنا",
|
||||
"loading": "جارٍ تحميل السجل...",
|
||||
"search_placeholder": "بحث...",
|
||||
"open_location": "فتح الموقع",
|
||||
"redownload": "إعادة التنزيل",
|
||||
"remove": "إزالة",
|
||||
"file_not_found": "الملف غير موجود",
|
||||
"file_not_found_message": "تم نقل الملف أو حذفه:\n{path}",
|
||||
"downloaded_on": "تم التنزيل: {date}",
|
||||
"file_size": "الحجم: {size}",
|
||||
"audio_download": "صوت",
|
||||
"video_download": "فيديو",
|
||||
"remove_confirm_title": "إزالة؟",
|
||||
"remove_confirm_message": "إزالة من السجل؟\n\n{title}",
|
||||
"item_removed": "تمت الإزالة",
|
||||
"history_cleared": "تم مسح السجل",
|
||||
"entries_count": "{count} تنزيلات",
|
||||
"one_entry": "تنزيل واحد",
|
||||
"redownload_confirm_title": "إعادة التنزيل؟",
|
||||
"redownload_confirm_message": "إعادة التنزيل؟\n\n{title}",
|
||||
"redownload_started": "بدأ التنزيل",
|
||||
"no_url_error": "لم يتم العثور على عنوان URL في سجل التاريخ",
|
||||
"redownload_failed": "فشل بدء إعادة التنزيل: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "تثبيت FFmpeg",
|
||||
"installation_message": "يحتاج YTSage إلى FFmpeg لمعالجة الفيديوهات.\n\nاختر خيار التثبيت أدناه:",
|
||||
"install_button": "تثبيت FFmpeg",
|
||||
"manual_guide": "الدليل اليدوي",
|
||||
"installation_failed": "واجه تثبيت FFmpeg مشكلة.",
|
||||
"already_installed": "تم تثبيت FFmpeg بالفعل!",
|
||||
"installation_complete": "اكتملت عملية التثبيت. يمكنك إغلاق هذا الحوار ومتابعة استخدام YTSage.",
|
||||
"installing": "جارٍ تثبيت FFmpeg... يرجى الانتظار",
|
||||
"install_success": "تم تثبيت FFmpeg بنجاح!",
|
||||
"installation_complete_close": "اكتملت عملية التثبيت. يمكنك الآن إغلاق هذا الحوار ومتابعة استخدام YTSage.",
|
||||
"try_manual": "يرجى محاولة استخدام دليل التثبيت اليدوي بدلاً من ذلك."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "إعداد yt-dlp مطلوب",
|
||||
"description": "يتطلب YTSage وجود yt-dlp لتنزيل الفيديوهات.<br><br>لم يتم العثور على yt-dlp في الدليل المحلي للتطبيق. يحتاج YTSage إلى إعداد yt-dlp لنظام {os_name}.<br><br>يرجى اختيار خيار أدناه:",
|
||||
"option_auto": "تنزيل تلقائيًا (موصى به)",
|
||||
"option_manual": "اختيار المسار يدويًا",
|
||||
"setup_button": "إعداد yt-dlp",
|
||||
"downloading": "جارٍ تنزيل yt-dlp...",
|
||||
"success": "تم تثبيت yt-dlp بنجاح!",
|
||||
"error": "خطأ: {error}",
|
||||
"download_failed_title": "فشل التنزيل",
|
||||
"download_failed_message": "فشل تنزيل yt-dlp: {error}",
|
||||
"select_executable_title": "اختر ملف yt-dlp التنفيذي",
|
||||
"copied_to": "تم نسخ yt-dlp بنجاح إلى {path}",
|
||||
"setup_error_title": "خطأ في الإعداد",
|
||||
"copy_error": "خطأ أثناء نسخ yt-dlp إلى مجلد التطبيق: {error}",
|
||||
"invalid_executable_title": "ملف تنفيذي غير صالح",
|
||||
"invalid_executable_message": "الملف المحدد لا يبدو كتنفيذ صحيح لـ yt-dlp.",
|
||||
"verify_error": "خطأ أثناء التحقق من ملف yt-dlp التنفيذي: {error}",
|
||||
"setup_failed_title": "فشل الإعداد",
|
||||
"setup_failed_message": "فشل إعداد yt-dlp. قد لا تعمل بعض الميزات بشكل صحيح.",
|
||||
"success_dialog_title": "إعداد yt-dlp",
|
||||
"success_dialog_message": "تم تكوين yt-dlp بنجاح في:\n{path}",
|
||||
"file_filter_windows": "ملفات تنفيذية (*.exe)",
|
||||
"file_filter_all": "كل الملفات (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "مدقق إصدار FFmpeg",
|
||||
"current_version": "الإصدار الحالي:",
|
||||
"latest_version": "أحدث إصدار:",
|
||||
"status_up_to_date": "✓ محدث",
|
||||
"status_update_available": "⚠ يتوفر تحديث",
|
||||
"status_not_installed": "✗ غير مثبت",
|
||||
"status_idle": "انقر على 'التحقق من الإصدار' للبدء",
|
||||
"status_checking": "🔄 جاري التحقق من الإصدار...",
|
||||
"check_updates": "التحقق من الإصدار",
|
||||
"check_failed": "فشل التحقق من الإصدار. يرجى التحقق من اتصال الإنترنت.",
|
||||
"description": "تحقق من إصدار FFmpeg الخاص بك وقارنه بأحدث إصدار متاح.",
|
||||
"guide_info": " لتثبيت أو تحديث FFmpeg، <a href='https://github.com/oop7/ffmpeg-install-guide'>انقر هنا لعرض دليل التثبيت الشامل</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "إعداد Deno مطلوب",
|
||||
"setup_description": "يتطلب YTSage Deno لتشغيل ميزات معينة.<br><br>لم يتم العثور على Deno في الدليل المحلي للتطبيق. يحتاج YTSage إلى إعداد Deno لنظام {os_name} الخاص بك.<br><br>انقر فوق 'إعداد Deno' للتنزيل والتثبيت تلقائياً.",
|
||||
"setup_button": "إعداد Deno",
|
||||
"downloading": "جاري تنزيل Deno...",
|
||||
"extracting": "جاري استخراج Deno...",
|
||||
"verifying": "جاري التحقق من Deno...",
|
||||
"success": "تم تثبيت Deno بنجاح!",
|
||||
"download_failed": "فشل التنزيل",
|
||||
"download_error": "فشل تنزيل Deno: {error}",
|
||||
"verification_failed": "فشل التحقق من SHA256. قد يكون الملف المنزل تالفاً أو تم العبث به.",
|
||||
"setup_failed": "فشل إعداد Deno. قد لا تعمل بعض الميزات بشكل صحيح.",
|
||||
"setup_error": "خطأ في الإعداد"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "فاحص ومحدث إصدار Deno",
|
||||
"description": "تحقق من إصدار Deno الخاص بك وقم بالتحديث إلى أحدث إصدار.",
|
||||
"current_version": "الإصدار الحالي:",
|
||||
"latest_version": "أحدث إصدار:",
|
||||
"status_idle": "انقر فوق 'التحقق من التحديثات' للبدء",
|
||||
"status_checking": "🔄 جارٍ التحقق من التحديثات...",
|
||||
"status_up_to_date": "✓ محدث",
|
||||
"status_update_available": "⚠ التحديث متاح",
|
||||
"status_not_installed": "✗ غير مثبت",
|
||||
"check_updates": "التحقق من التحديثات",
|
||||
"update_now": "تحديث Deno",
|
||||
"updating": "🔄 جارٍ تحديث Deno...",
|
||||
"update_success": "✅ تم تحديث Deno بنجاح!",
|
||||
"update_failed": "❌ فشل التحديث: {error}",
|
||||
"check_failed": "فشل التحقق من التحديثات. يرجى التحقق من اتصالك بالإنترنت."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Deutsch (German)",
|
||||
"select_language": "Sprache auswählen:",
|
||||
"current_language": "Aktuelle Sprache: {language}",
|
||||
"restart_required": "Sprachänderungen werden nach dem Neustart der Anwendung wirksam.",
|
||||
"english": "Englisch",
|
||||
"spanish": "Spanisch",
|
||||
"portuguese": "Portugiesisch",
|
||||
"russian": "Russisch",
|
||||
"chinese": "Chinesisch",
|
||||
"german": "Deutsch",
|
||||
"french": "Französisch",
|
||||
"hindi": "Hindi",
|
||||
"indonesian": "Indonesisch",
|
||||
"turkish": "Türkisch",
|
||||
"polish": "Polnisch",
|
||||
"italian": "Italienisch",
|
||||
"arabic": "Arabisch",
|
||||
"japanese": "Japanisch",
|
||||
"help_text": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche.",
|
||||
"restart_notice": "Die Sprachänderung wird nach dem Neustart der Anwendung wirksam."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Bereit"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Formate anzeigen:",
|
||||
"video_format": "Videoformat:",
|
||||
"audio_format": "Audioformat:",
|
||||
"no_formats": "Keine Formate verfügbar",
|
||||
"loading": "Lade Formate...",
|
||||
"select": "Auswählen",
|
||||
"quality": "Qualität",
|
||||
"extension": "Erweiterung",
|
||||
"resolution": "Auflösung",
|
||||
"file_size": "Dateigröße",
|
||||
"codec": "Codec",
|
||||
"audio": "Audio",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Audio wird zusammengeführt",
|
||||
"has_audio": "✓ Hat Audio",
|
||||
"audio_only": "Nur Audio",
|
||||
"best_4k": "Beste (4K)",
|
||||
"best_2k": "Beste (2K)",
|
||||
"high_1080p": "Hoch (1080p)",
|
||||
"high_720p": "Hoch (720p)",
|
||||
"medium_480p": "Mittel (480p)",
|
||||
"low_quality": "Niedrige Qualität",
|
||||
"best_audio": "Bestes Audio",
|
||||
"high_audio": "Hohes Audio",
|
||||
"medium_audio": "Mittleres Audio",
|
||||
"low_audio": "Niedriges Audio",
|
||||
"audio_only_resolution": "Nur Audio"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Zurücksetzen",
|
||||
"download": "Herunterladen",
|
||||
"pause": "Pausieren",
|
||||
"resume": "Fortsetzen",
|
||||
"cancel": "Abbrechen",
|
||||
"browse": "Durchsuchen",
|
||||
"clear": "Löschen",
|
||||
"ok": "OK",
|
||||
"apply": "Anwenden",
|
||||
"close": "Schließen",
|
||||
"run_command": "Befehl ausführen",
|
||||
"custom_command_help": "Hilfe",
|
||||
"about": "Über",
|
||||
"analyze": "Analysieren",
|
||||
"paste_url": "URL einfügen",
|
||||
"select_videos": "Videos auswählen...",
|
||||
"video": "Video",
|
||||
"audio_only": "Nur Audio",
|
||||
"custom_options": "Benutzerdefinierte Optionen",
|
||||
"trim_video": "Video schneiden",
|
||||
"download_settings": "Download-Einstellungen",
|
||||
"update": "yt-dlp aktualisieren",
|
||||
"select_defaults": "Standard auswählen",
|
||||
"select_all": "Alle auswählen",
|
||||
"deselect_all": "Alle abwählen",
|
||||
"open_folder": "Ordnerspeicherort öffnen",
|
||||
"history": "Verlauf",
|
||||
"save_playlist": "Playlist speichern unter"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Benutzerdefinierte Optionen",
|
||||
"settings": "Einstellungen",
|
||||
"select_folder": "Download-Ordner auswählen",
|
||||
"sponsorblock_categories": "SponsorBlock-Kategorien",
|
||||
"sponsorblock_description": "Wählen Sie aus, welche Arten von Videosegmenten während des Downloads automatisch entfernt werden sollen.\nSponsorBlock verwendet von der Community übermittelte Daten, um diese Segmente zu identifizieren.",
|
||||
"select_subtitles": "Untertitel auswählen",
|
||||
"filter_languages_placeholder": "Sprachen filtern (z.B. en, de)...",
|
||||
"no_subtitles_available": "Keine Untertitel verfügbar",
|
||||
"matching": "passend",
|
||||
"ytdlp_log_title": "yt-dlp-Protokoll",
|
||||
"filter_playlist_placeholder": "Videos filtern..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Mit Cookies anmelden",
|
||||
"custom_command": "Benutzerdefinierter Befehl",
|
||||
"proxy": "Proxy",
|
||||
"language": "Sprache",
|
||||
"updater": "Updater"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Wählen Sie aus, wie Cookies für die Anmeldung bereitgestellt werden sollen.\nDies ermöglicht das Herunterladen privater Videos und hochwertiger Audio-Dateien.",
|
||||
"cookie_source": "Cookie-Quelle",
|
||||
"use_cookie_file": "Cookie-Datei verwenden",
|
||||
"extract_from_browser": "Aus Browser extrahieren",
|
||||
"recommended": "Empfohlen",
|
||||
"cookie_file": "Cookie-Datei",
|
||||
"cookie_file_placeholder": "Pfad zur cookies.txt-Datei...",
|
||||
"browser_selection": "Browser-Auswahl",
|
||||
"browser_help": "Wählen Sie den Browser aus, aus dem Cookies extrahiert werden sollen:",
|
||||
"browser_label": "Browser:",
|
||||
"profile_label": "Profil:",
|
||||
"profile_placeholder": "Standard",
|
||||
"browser_extract_message": "Browser-Cookies werden beim Anwenden extrahiert",
|
||||
"file_selected_message": "Cookie-Datei ausgewählt - Klicken Sie OK zum Anwenden",
|
||||
"select_file_title": "Cookie-Datei auswählen",
|
||||
"file_filter": "Cookie-Dateien (*.txt *.lwp)",
|
||||
"file_selected_title": "Cookie-Datei angewendet",
|
||||
"file_applied_message": "Cookie-Datei angewendet: {path}",
|
||||
"browser_selected_title": "Browser-Cookies angewendet",
|
||||
"browser_applied_message": "Browser-Cookies werden extrahiert von: {browser}",
|
||||
"cleared_title": "Cookies gelöscht",
|
||||
"cleared_message": "Cookie-Einstellungen wurden gelöscht",
|
||||
"active_browser": "✓ Aktiv: Browser-Cookies ({browser})",
|
||||
"active_file": "✓ Aktiv: Cookie-Datei ({file})",
|
||||
"none_active": "○ Keine Cookies aktiv",
|
||||
"remember_settings": "Cookie-Einstellungen beim nächsten Start merken"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Geben Sie Ihren benutzerdefinierten yt-dlp-Befehl unten ein. Die aktuelle URL wird automatisch angehängt.<br><br>Für die vollständige Liste der Optionen und Verwendungsbeispiele <a href=\"{docs_url}\">klicken Sie hier, um die offizielle yt-dlp-Dokumentation anzuzeigen</a>.<br><br>Hinweis: Download-Pfad und Dateinamen-Vorlage werden automatisch behandelt.",
|
||||
"input_label": "yt-dlp-Argumente:",
|
||||
"input_placeholder": "Geben Sie yt-dlp-Argumente hier ein...\n\nz.B.: --extract-audio --audio-format mp3",
|
||||
"output_label": "Befehlsausgabe:",
|
||||
"output_placeholder": "Die Befehlsausgabe wird hier erscheinen...",
|
||||
"command_placeholder": "Benutzerdefinierten yt-dlp-Befehl eingeben...",
|
||||
"command_help": "Verfügbare Platzhalter:\n{url} - Video-URL\n{output} - Ausgabeordner\n\nBeispiel: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Vollständiger Befehl: {command}",
|
||||
"command_success": "✅ Benutzerdefinierter Befehl erfolgreich ausgeführt!",
|
||||
"command_failed": "❌ Befehl fehlgeschlagen mit Exit-Code {code}",
|
||||
"command_error": "❌ Fehler beim Ausführen des benutzerdefinierten Befehls: {error}",
|
||||
"error_no_url": "❌ Fehler: Keine URL angegeben. Bitte geben Sie im Hauptfenster eine URL ein.",
|
||||
"error_no_command": "❌ Fehler: Kein Befehl angegeben. Bitte yt-dlp-Argumente eingeben.",
|
||||
"executing": "🚀 Benutzerdefinierter yt-dlp-Befehl wird ausgeführt",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Argumente: {command}",
|
||||
"download_path_label": "📁 Download-Pfad: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Proxy-Einstellungen für Downloads konfigurieren. Leer lassen für direkte Verbindung.",
|
||||
"main_proxy": "Haupt-Proxy",
|
||||
"main_proxy_help": "Primärer Proxy-Server für alle Downloads. Unterstützt HTTP/HTTPS- und SOCKS5-Protokolle.",
|
||||
"proxy_url_label": "Proxy-URL:",
|
||||
"proxy_url_placeholder": "http://proxy:port oder socks5://proxy:port",
|
||||
"proxy_examples": "Beispiele:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Geo-Umgehungs-Proxy",
|
||||
"geo_proxy_help": "Sekundärer Proxy speziell zur Umgehung geografischer Beschränkungen.",
|
||||
"geo_proxy_url_label": "Geo-Umgehungs-Proxy-URL:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "Haupt-Proxy löschen",
|
||||
"clear_geo_proxy": "Geo-Proxy löschen",
|
||||
"proxy_url": "Proxy-URL",
|
||||
"proxy_placeholder": "http://proxy:port oder socks5://proxy:port",
|
||||
"geo_bypass": "Geo-Umgehungs-Proxy (für geografische Beschränkungen)",
|
||||
"geo_bypass_placeholder": "http://proxy:port zur Umgehung von Geo-Blockierungen",
|
||||
"invalid_main_url": "Ungültiges Haupt-Proxy-URL-Format",
|
||||
"invalid_geo_url": "Ungültiges Geo-Proxy-URL-Format",
|
||||
"main_configured": "Haupt-Proxy konfiguriert",
|
||||
"geo_configured": "Geo-Proxy konfiguriert",
|
||||
"set_title": "Proxy gesetzt",
|
||||
"set_message": "Haupt-Proxy gesetzt und gespeichert: {proxy}",
|
||||
"geo_set_title": "Geo-Proxy gesetzt",
|
||||
"geo_set_message": "Geo-Prüf-Proxy gesetzt und gespeichert: {proxy}",
|
||||
"cleared_title": "Proxy-Einstellungen gelöscht",
|
||||
"cleared_message": "Alle Proxy-Einstellungen wurden gelöscht und gespeichert.",
|
||||
"saved_main": "Gespeicherter Haupt-Proxy: {proxy}",
|
||||
"saved_geo": "Gespeicherter Geo-Proxy: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Download wird vorbereitet...",
|
||||
"starting": "🚀 Download wird gestartet...",
|
||||
"fetching_info": "🔍 Video-Informationen werden abgerufen...",
|
||||
"preparing_streams": "🎯 Video-Streams werden vorbereitet...",
|
||||
"downloading_audio": "⏬ Audio wird heruntergeladen...",
|
||||
"downloading_video": "⏬ Video wird heruntergeladen...",
|
||||
"downloading_subtitle": "⏬ Untertitel werden heruntergeladen...",
|
||||
"downloading": "⏬ Wird heruntergeladen...",
|
||||
"completed": "✅ Download abgeschlossen!",
|
||||
"video_completed": "✅ Video-Download abgeschlossen!",
|
||||
"audio_completed": "✅ Audio-Download abgeschlossen!",
|
||||
"subtitle_completed": "✅ Untertitel-Download abgeschlossen!",
|
||||
"completed_cleaning": "✅ Download abgeschlossen! Wird aufgeräumt...",
|
||||
"cancelled": "Download abgebrochen",
|
||||
"processing_playlist": "📋 Playlist-Daten werden verarbeitet...",
|
||||
"paused": "Download pausiert",
|
||||
"resumed": "Download fortgesetzt",
|
||||
"merging_formats": "✨ Nachbearbeitung: Formate werden zusammengeführt...",
|
||||
"removing_sponsor_segments": "✨ Nachbearbeitung: Sponsor-Segmente werden entfernt...",
|
||||
"speed": "Geschwindigkeit",
|
||||
"eta": "Verbleibende Zeit",
|
||||
"please_enter_url": "Bitte geben Sie zuerst eine URL ein.",
|
||||
"please_set_path": "Bitte setzen Sie zuerst einen Download-Pfad.",
|
||||
"please_enter_url_and_path": "Bitte geben Sie eine URL ein und setzen Sie einen Download-Pfad.",
|
||||
"please_select_format": "Bitte wählen Sie zuerst ein Format aus.",
|
||||
"downloading_fallback": "⚡ Wird heruntergeladen..."
|
||||
},
|
||||
"update": {
|
||||
"title": "yt-dlp aktualisieren",
|
||||
"checking": "Suche nach Updates...",
|
||||
"update_available": "Update verfügbar!\nAktuelle Version: {current}\nNeueste Version: {latest}",
|
||||
"up_to_date": "yt-dlp ist aktuell (Version {version})",
|
||||
"could_not_determine": "Versionen konnten nicht bestimmt werden.",
|
||||
"error_comparing": "Fehler beim Vergleichen der Versionen: {error}",
|
||||
"update_available_failed": "Update verfügbar! (Vergleich fehlgeschlagen)\nAktuell: {current}\nNeuest: {latest}",
|
||||
"updating": "Wird aktualisiert...",
|
||||
"initializing": "🚀 Update-Prozess wird initialisiert...",
|
||||
"checking_current": "🔍 Aktuelle Installation wird überprüft...",
|
||||
"found_at": "📍 yt-dlp gefunden in: {path}",
|
||||
"error_getting_path": "❌ Fehler beim Abrufen des yt-dlp-Pfads: {error}",
|
||||
"updating_binary": "📦 App-verwaltete yt-dlp-Binärdatei wird aktualisiert...",
|
||||
"update_failed": "❌ yt-dlp-Update fehlgeschlagen. Bitte versuchen Sie es erneut oder überprüfen Sie Ihre Internetverbindung.",
|
||||
"binary_updated": "✅ Binärdatei erfolgreich aktualisiert!",
|
||||
"update_failed_stderr": "❌ yt-dlp-Update fehlgeschlagen: {error}",
|
||||
"update_timeout": "❌ yt-dlp-Update-Zeitüberschreitung.",
|
||||
"unexpected_error": "❌ Unerwarteter Fehler während des Updates: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp ist bereits auf dem neuesten Stand!",
|
||||
"update_success": "✅ yt-dlp wurde erfolgreich aktualisiert!",
|
||||
"already_latest": "yt-dlp ist auf dem neuesten Stand (Version {version})",
|
||||
"network_error": "❌ Netzwerkfehler während des Updates: {error}",
|
||||
"general_error": "❌ Update fehlgeschlagen: {error}",
|
||||
"update_in_progress_title": "Update läuft",
|
||||
"update_in_progress_message": "yt-dlp wird gerade aktualisiert. Bitte warten Sie einen Moment."
|
||||
},
|
||||
"about": {
|
||||
"title": "Über YTSage",
|
||||
"version": "Version {version}",
|
||||
"description": "Moderner YouTube-Downloader mit sauberer PySide6-Oberfläche.",
|
||||
"author": "Von: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Systeminformationen",
|
||||
"loading": "🔄 Systeminformationen werden geladen...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Logs",
|
||||
"logs_tooltip": "Anwendungs-Protokollordner öffnen",
|
||||
"refreshing": "🔄 Wird aktualisiert...",
|
||||
"refresh_failed": "Aktualisierung fehlgeschlagen",
|
||||
"refresh_failed_message": "Versionsinformationen konnten nicht aktualisiert werden.",
|
||||
"detected": "✓ Erkannt",
|
||||
"missing": "✗ Fehlend",
|
||||
"not_available": "Nicht verfügbar"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Video schneiden",
|
||||
"time_range_group": "Zeitbereich",
|
||||
"start_time": "Startzeit (HH:MM:SS)",
|
||||
"end_time": "Endzeit (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Startzeit (HH:MM:SS)",
|
||||
"end_time_placeholder": "Endzeit (HH:MM:SS)",
|
||||
"force_keyframes": "Keyframes an Schnittpunkten erzwingen",
|
||||
"help_text": "Setzen Sie Start- und Endzeit, um das Video zu schneiden.\nLeer lassen, um das vollständige Video herunterzuladen.",
|
||||
"invalid_format": "Ungültiges Zeitformat. Verwenden Sie das Format HH:MM:SS.",
|
||||
"start_after_end": "Startzeit kann nicht nach der Endzeit liegen."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Download-Einstellungen",
|
||||
"download_path": "Download-Pfad",
|
||||
"browse": "Durchsuchen...",
|
||||
"speed_limit": "Geschwindigkeitsbegrenzung",
|
||||
"speed_limit_placeholder": "Keine",
|
||||
"generic_mode": "Generischer Modus",
|
||||
"enable_generic_mode": "Generischen Modus aktivieren (Unterstützung für Nicht-YouTube-Seiten)",
|
||||
"generic_mode_help": "Ermöglicht Downloads von Dailymotion, CBC Gem und anderen von yt-dlp unterstützten Seiten.",
|
||||
"auto_update_ytdlp": "yt-dlp automatisch aktualisieren",
|
||||
"enable_auto_updates": "Automatische yt-dlp-Updates aktivieren",
|
||||
"update_frequency": "Update-Häufigkeit:",
|
||||
"check_startup": "Bei jedem Start prüfen (mindestens 1 Stunde zwischen Prüfungen)",
|
||||
"check_daily": "Täglich prüfen",
|
||||
"check_weekly": "Wöchentlich prüfen",
|
||||
"check_updates_now": "yt-dlp aktualisieren",
|
||||
"update_check_title": "Update-Prüfung",
|
||||
"could_not_determine_version": "Aktuelle yt-dlp-Version konnte nicht bestimmt werden.",
|
||||
"update_available_dialog": "Update verfügbar!\n\nAktuell: {current}\nNeuest: {latest}\n\nKlicken Sie auf OK, um mit dem Update fortzufahren.",
|
||||
"up_to_date_dialog": "yt-dlp ist aktuell!\n\nAktuelle Version: {version}",
|
||||
"error_checking_updates": "Fehler bei der Update-Prüfung: {error}",
|
||||
"settings_saved_title": "Einstellungen gespeichert",
|
||||
"settings_saved_message": "Auto-Update-Einstellungen wurden erfolgreich gespeichert!",
|
||||
"error_title": "Fehler",
|
||||
"failed_save_settings": "Speichern der Auto-Update-Einstellungen fehlgeschlagen.",
|
||||
"error_saving_settings": "Fehler beim Speichern der Auto-Update-Einstellungen: {error}",
|
||||
"ytdlp_channel": "yt-dlp Release-Kanal",
|
||||
"ytdlp_channel_stable": "Stabil (Getestete Versionen)",
|
||||
"ytdlp_channel_nightly": "Nightly (Tägliche Updates, empfohlen von yt-dlp)",
|
||||
"ytdlp_channel_description": "Wählen Sie zwischen stabilen und nächtlichen Versionen. Nightly-Builds werden täglich mit den neuesten Korrekturen und Funktionen aktualisiert. Sie können jederzeit zwischen den Kanälen wechseln.",
|
||||
"ytdlp_switching_channel": "Wechsle zu {channel}-Kanal...",
|
||||
"ytdlp_channel_switched": "✅ Erfolgreich zu {channel}-Kanal gewechselt!",
|
||||
"ytdlp_channel_switch_failed": "❌ Kanalwechsel fehlgeschlagen: {error}",
|
||||
"ytdlp_current_channel": "Aktueller Kanal: {channel}",
|
||||
"app_updates_title": "YTSage-Updates",
|
||||
"check_app_updates": "Beim Start nach YTSage-Updates suchen",
|
||||
"check_beta_updates": "Beta-Updates erhalten",
|
||||
"auto_update_title": "Auto-Update-Einstellungen",
|
||||
"auto_update_header": "🔄 Auto-Update-Einstellungen",
|
||||
"auto_update_description": "Konfigurieren Sie automatische Updates für yt-dlp, um sicherzustellen, dass Sie immer die neuesten Funktionen und Fehlerbehebungen haben.",
|
||||
"current_status": "Aktueller Status",
|
||||
"current_version_label": "Aktuelle yt-dlp-Version: Wird überprüft...",
|
||||
"last_check_label": "Letzte Update-Prüfung: Nie",
|
||||
"next_check_label": "Nächste Prüfung: Basierend auf Einstellungen",
|
||||
"manual_check_button": "🔍 Jetzt nach Updates suchen",
|
||||
"update_frequency_group": "Update-Häufigkeit",
|
||||
"save_settings": "Einstellungen speichern",
|
||||
"settings_saved_successfully": "✅ Einstellungen erfolgreich gespeichert!",
|
||||
"error_saving": "❌ Fehler beim Speichern der Einstellungen: {error}",
|
||||
"output_format_settings": "Ausgabeformat-Einstellungen",
|
||||
"force_output_format": "Ausgabeformat beim Zusammenführen erzwingen",
|
||||
"preferred_format": "Bevorzugtes Format:",
|
||||
"force_format_help": "Wenn aktiviert, werden zusammengeführte Videos in Ihr bevorzugtes Format konvertiert. Deaktivieren Sie diese Option, damit yt-dlp automatisch entscheidet.",
|
||||
"format_mp4": "MP4 (Am kompatibelsten)",
|
||||
"format_webm": "WebM (Modern, offen)",
|
||||
"format_mkv": "MKV (Funktionsreich)",
|
||||
"audio_format_settings": "Audioformat-Einstellungen",
|
||||
"force_audio_format": "Audioformat für Nur-Audio-Downloads erzwingen",
|
||||
"audio_normalization": "Audio-Normalisierung (EBU R128)",
|
||||
"audio_normalization_help": "Wenn aktiviert, werden Audiospuren normalisiert. Hinweis: Dies erfordert eine Neukodierung, daher muss ein bestimmtes Audioformat (wie MP3 oder M4A) erzwungen werden.",
|
||||
"preferred_audio_format": "Bevorzugtes Audioformat:",
|
||||
"force_audio_format_help": "Wenn aktiviert, werden Nur-Audio-Downloads in Ihr bevorzugtes Format konvertiert. Dies gilt nur beim Herunterladen von Audioformaten.",
|
||||
"audio_format_best": "Beste (Keine Konvertierung)",
|
||||
"audio_format_aac": "AAC (Gut zum Bearbeiten)",
|
||||
"audio_format_mp3": "MP3 (Universal)",
|
||||
"audio_format_flac": "FLAC (Verlustfrei)",
|
||||
"audio_format_wav": "WAV (Unkomprimiert)",
|
||||
"audio_format_opus": "Opus (Effizient)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Offen)",
|
||||
"filename_format": "Ausgabe-Dateinamenformat",
|
||||
"filename_format_help": "Verfügbare Variablen: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. Die Standard-yt-dlp-Ausgabevorlagensyntax wird unterstützt.",
|
||||
"tab_general": "Allgemein",
|
||||
"tab_format": "Format",
|
||||
"tab_file": "Datei",
|
||||
"concurrent_fragments": "Gleichzeitige Verbindungen",
|
||||
"concurrent_fragments_help": "Anzahl der Verbindungen pro Download. Höhere Werte umgehen Drosselungen, können aber bei zu hohen Werten zu temporären Sperren führen. Standard: 1.",
|
||||
"defaults_settings": "Standard-Auswahleinstellungen",
|
||||
"default_video_quality": "Standard-Videoauflösung (Höhe):",
|
||||
"default_subtitle_language": "Standard- Untertitelsprache(n):",
|
||||
"defaults_help": "Legen Sie Ihre bevorzugte Videohöhe und Untertitelsprachen fest (kommagetrennt). Diese werden automatisch ausgewählt, falls verfügbar."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "YouTube-Video- oder Playlist-URL eingeben",
|
||||
"url_placeholder_generic": "Video- oder Playlist-URL von jeder unterstützten Seite eingeben",
|
||||
"merge_subtitles": "Untertitel zusammenführen",
|
||||
"save_thumbnail": "Thumbnail speichern",
|
||||
"save_description": "Beschreibung speichern",
|
||||
"embed_chapters": "Kapitel einbetten",
|
||||
"subtitles_selected": "{count} ausgewählt",
|
||||
"all_selected": "Alle ausgewählt",
|
||||
"select_videos_all": "Videos auswählen... (Alle ausgewählt)",
|
||||
"please_enter_url": "Bitte geben Sie zuerst eine URL ein",
|
||||
"error_title": "Fehler",
|
||||
"cookie_file_selected_title": "Cookie-Datei ausgewählt",
|
||||
"cookie_file_selected_message": "Cookie-Datei ausgewählt: {path}",
|
||||
"browser_cookies_selected_title": "Browser-Cookies ausgewählt",
|
||||
"browser_cookies_selected_message": "Browser-Cookies werden aus folgendem Browser extrahiert: {browser}",
|
||||
"error_no_format_info": "Fehler: Keine Formatinformationen verfügbar.",
|
||||
"error_extract_info": "Fehler: Grundlegende Videoinformationen konnten nicht extrahiert werden. Bitte überprüfen Sie Ihren Link.",
|
||||
"analyzing_preparing": "Anfrage wird vorbereitet...",
|
||||
"analyzing_extracting_basic": "Grundlegende Informationen werden extrahiert...",
|
||||
"analyzing_extracting_detailed": "Detaillierte Informationen werden extrahiert...",
|
||||
"analyzing_processing_video": "Videodaten werden verarbeitet...",
|
||||
"analyzing_processing_formats": "Formate werden verarbeitet...",
|
||||
"analyzing_loading_thumbnail": "Thumbnail wird geladen...",
|
||||
"analyzing_processing_subtitles": "Untertitel werden verarbeitet...",
|
||||
"analyzing_updating_table": "Formattabelle wird aktualisiert...",
|
||||
"analysis_complete": "Analyse abgeschlossen!",
|
||||
"analyzing_extracting_ytdlp": "Informationen werden extrahiert...",
|
||||
"analyzing_fetching_first_video": "Formate für das erste Video werden abgerufen...",
|
||||
"analyzing_processing_data": "Daten werden verarbeitet...",
|
||||
"analyzing_processing_formats_ytdlp": "Formate werden verarbeitet...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Thumbnail wird geladen...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Untertitel werden verarbeitet...",
|
||||
"select_subtitles": "Untertitel auswählen...",
|
||||
"sponsorblock_categories": "SponsorBlock-Kategorien...",
|
||||
"invalid_url_or_enter": "Ungültige URL oder bitte geben Sie eine URL ein.",
|
||||
"zero_selected": "0 ausgewählt",
|
||||
"analyze_first_tooltip": "Bitte analysieren Sie zuerst das Video",
|
||||
"audio_mode_disabled": "Nicht verfügbar im Nur-Audio-Modus",
|
||||
"select_subtitles_first": "Bitte wählen Sie zuerst Untertitel aus",
|
||||
"settings_tooltip": "Aktueller Pfad: {path}\nGeschwindigkeitslimit: {speed_limit}",
|
||||
"speed_limit_none": "Keines",
|
||||
"open_folder_error": "Ordner konnte nicht geöffnet werden: {error}",
|
||||
"time_range_set": "Abschnitt gesetzt: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Sponsor",
|
||||
"sponsor_desc": "Bezahlte Werbung, bezahlte Empfehlungen und direkte Werbung",
|
||||
"selfpromo": "Unbezahlte/Eigenwerbung",
|
||||
"selfpromo_desc": "Unbezahlte Werbung für eigene Inhalte der Ersteller",
|
||||
"interaction": "Interaktions-Erinnerung",
|
||||
"interaction_desc": "Zuschauer werden gebeten zu liken, zu abonnieren oder sozialen Medien zu folgen",
|
||||
"intro": "Intro",
|
||||
"intro_desc": "Video-Einleitung, die übersprungen werden kann",
|
||||
"outro": "Outro/Abspann-Karten",
|
||||
"outro_desc": "Credits oder wenn das Video endet",
|
||||
"preview": "Vorschau/Zusammenfassung",
|
||||
"preview_desc": "Kurze Zusammenfassung vorheriger Videos oder Vorschau auf kommende Inhalte",
|
||||
"music_offtopic": "Nicht-Musik-Bereich",
|
||||
"music_offtopic_desc": "Nur für Musikvideos. Markiert Nicht-Musik-Bereiche",
|
||||
"filler": "Füller-Exkurs",
|
||||
"filler_desc": "Abschweifende Szenen, die nur als Füller oder für Humor hinzugefügt wurden"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Kanal",
|
||||
"views": "👁 Aufrufe",
|
||||
"likes": "👍 Gefällt mir",
|
||||
"upload_date": "📅 Upload-Datum",
|
||||
"duration": "⏱ Dauer",
|
||||
"unknown_channel": "Unbekannter Kanal",
|
||||
"unknown_date": "Unbekanntes Datum",
|
||||
"unknown_title": "Unbekannter Titel"
|
||||
},
|
||||
"command": {
|
||||
"running": "Läuft...",
|
||||
"run_command": "Befehl ausführen"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 ausgewählt",
|
||||
"one_selected": "1 Kategorie ausgewählt",
|
||||
"count_selected": "{count} ausgewählt"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Bereit",
|
||||
"file_exists": "⚠️ Datei existiert bereits",
|
||||
"video_file_exists": "⚠️ Video-Datei existiert bereits",
|
||||
"audio_file_exists": "⚠️ Audio-Datei existiert bereits",
|
||||
"subtitle_file_exists": "⚠️ Untertitel-Datei existiert bereits",
|
||||
"cancelling": "Download wird abgebrochen...",
|
||||
"thumbnail_saved": "✅ Miniaturbild gespeichert: {filename}",
|
||||
"thumbnail_error": "❌ Miniaturbild-Fehler: {error}",
|
||||
"thumbnail_no_image": "Kein Miniaturbild zum Speichern verfügbar"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Fehler: Playlist enthält keine gültigen Videos.",
|
||||
"playlist_no_url": "Fehler: URL für das erste Playlist-Video konnte nicht abgerufen werden.",
|
||||
"ytdlp_not_found": "Fehler: yt-dlp-Ausführbare Datei nicht gefunden. Bitte installieren Sie zuerst yt-dlp.",
|
||||
"ytdlp_not_found_path": "Fehler: yt-dlp-Ausführbare Datei nicht gefunden. Dies könnte auf eine fehlerhafte Installation oder ein PATH-Problem zurückzuführen sein.",
|
||||
"no_data_returned": "Fehler: Keine Daten von yt-dlp zurückgegeben",
|
||||
"no_format_info": "Fehler: Keine Formatinformationen verfügbar.",
|
||||
"analysis_timeout": "Fehler: Analyse-Zeitüberschreitung. Bitte versuchen Sie es erneut.",
|
||||
"invalid_speed_limit": "❌ Fehler: Ungültiger Geschwindigkeitsbegrenzungswert in den Einstellungen.",
|
||||
"ytdlp_failed": "Fehler: yt-dlp fehlgeschlagen: {error}",
|
||||
"parse_failed": "Fehler: Fehler beim Parsen der yt-dlp-Ausgabe: {error}",
|
||||
"analysis_failed": "Fehler: Analyse fehlgeschlagen: {error}",
|
||||
"generic_error": "Fehler: {error}",
|
||||
"download_failed_return_code_conflict": "Download fehlgeschlagen mit Rückgabecode {return_code}. Dies kann an einem Konflikt mit mehreren yt-dlp-Installationen liegen. Deinstalliere ggf. eine systemweit installierte yt-dlp-Version (z. B. über snap oder apt) und starte die Anwendung neu.",
|
||||
"download_failed_return_code": "Download fehlgeschlagen mit Rückgabecode {return_code}",
|
||||
"direct_command_error": "Fehler im direkten Befehl: {error}",
|
||||
"private_video": "Dieses Video ist möglicherweise privat. Bitte verwenden Sie Cookies aus den benutzerdefinierten Optionen."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Update verfügbar",
|
||||
"new_version_available": "Eine neue Version von YTSage ist verfügbar!",
|
||||
"current_version_label": "Aktuelle Version:",
|
||||
"latest_version_label": "Neueste Version:",
|
||||
"changelog": "Änderungsprotokoll",
|
||||
"download_update": "Update herunterladen",
|
||||
"remind_later": "Später erinnern"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Unbekannte Playlist",
|
||||
"total_videos": "Gesamtanzahl Videos: {count}",
|
||||
"display_format": "Playlist: {title} | {count} Videos",
|
||||
"select_videos_title": "Playlist-Videos auswählen",
|
||||
"save_as": "Playlist speichern unter",
|
||||
"save_success_title": "Erfolg",
|
||||
"saved_successfully": "Playlist erfolgreich gespeichert.",
|
||||
"save_error_title": "Fehler beim Speichern",
|
||||
"no_videos_to_save": "Keine Playlist-Einträge gesammelt!",
|
||||
"save_error_msg": "Fehler beim Speichern der Playlist."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} ausgewählt"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "Datei existiert bereits",
|
||||
"message": "Die Datei existiert bereits:\n{filename}",
|
||||
"info": "Dieses Video wurde bereits heruntergeladen."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Letzte Aktualisierungsprüfung: Nie",
|
||||
"last_check": "Letzte Aktualisierungsprüfung: {time}",
|
||||
"next_check_disabled": "Nächste Prüfung: Deaktiviert",
|
||||
"next_check_startup": "Nächste Prüfung: Beim Start",
|
||||
"next_check_overdue": "Nächste Prüfung: Jetzt (überfällig)",
|
||||
"next_check": "Nächste Prüfung: {time}",
|
||||
"next_check_error": "Nächste Prüfung: Fehler bei Berechnung",
|
||||
"checking": "🔄 Prüfen...",
|
||||
"check_now": "🔍 Jetzt nach Updates suchen",
|
||||
"current_version": "Aktuelle yt-dlp-Version: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL kann nicht leer sein",
|
||||
"invalid_format": "Ungültiges URL-Format",
|
||||
"invalid_scheme": "URL muss mit http:// oder https:// beginnen",
|
||||
"missing_domain": "Ungültige URL: Domain-Name fehlt",
|
||||
"unsupported_platform": "YTSage unterstützt nur YouTube- und YouTube Music-URLs.\nDie Domain '{domain}' wird nicht unterstützt.",
|
||||
"invalid_youtu_be": "Ungültige youtu.be-URL: Video-ID fehlt"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Dies ist ein privates Video. Sie können es herunterladen, indem Sie sich mit Cookies in Ihr Konto einloggen.\nGehen Sie zu 'Benutzerdefinierte Optionen' → 'Mit Cookies anmelden' → 'Cookies aus Browser extrahieren', um sich zu authentifizieren.",
|
||||
"age_restricted": "Dieses Video ist altersbeschränkt. Sie müssen angemeldet sein, um darauf zuzugreifen.\nVerwenden Sie 'Benutzerdefinierte Optionen' → 'Mit Cookies anmelden', um sich mit Ihrem Konto zu authentifizieren.",
|
||||
"geo_blocked": "Dieses Video ist in Ihrer Region nicht verfügbar (geografisch gesperrt).\nSie benötigen möglicherweise ein VPN oder das Video ist in Ihrem Land eingeschränkt.",
|
||||
"video_unavailable": "Dieses Video wurde entfernt oder ist nicht mehr verfügbar.\nDas Video wurde möglicherweise vom Uploader gelöscht oder wegen Richtlinienverstößen entfernt.",
|
||||
"live_stream": "Dies ist ein Live-Stream, der während der Aktivität nicht heruntergeladen werden kann.\nWarten Sie, bis der Stream endet, und versuchen Sie dann, die archivierte Version herunterzuladen.",
|
||||
"playlist_error": "Zugriff auf diese Playlist nicht möglich. Sie ist möglicherweise privat, gelöscht oder leer.\nÜberprüfen Sie, ob die Playlist existiert und öffentlich zugänglich ist.",
|
||||
"network_error": "Netzwerkverbindungsfehler. Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.\nWenn das Problem weiterhin besteht, ist der Videoserver möglicherweise vorübergehend nicht verfügbar.",
|
||||
"invalid_url": "Ungültige oder nicht unterstützte URL. Bitte überprüfen Sie den Link und versuchen Sie es erneut.\nStellen Sie sicher, dass Sie eine gültige YouTube-, Vimeo- oder andere unterstützte Plattform-URL verwenden.",
|
||||
"premium_content": "Dieser Inhalt erfordert YouTube Premium oder eine Kanalmitgliedschaft.\nSie müssen mit einem Konto angemeldet sein, das Zugriff auf diesen Inhalt hat.",
|
||||
"copyright_blocked": "Dieses Video ist aufgrund von Urheberrechtsansprüchen gesperrt.\nDer Inhaltsinhaber hat den Zugriff auf dieses Video eingeschränkt.",
|
||||
"extraction_failed": "Fehler beim Extrahieren von Videoinformationen. Dies könnte ein vorübergehendes Problem sein.\nBitte versuchen Sie es in ein paar Minuten erneut oder überprüfen Sie, ob der Videolink korrekt ist.",
|
||||
"generic_error": "Videoinformationen konnten nicht extrahiert werden. Bitte überprüfen Sie Ihren Link.\nTechnische Details: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Download-Verlauf",
|
||||
"clear_all": "Alles Löschen",
|
||||
"clear_confirm_title": "Verlauf Löschen?",
|
||||
"clear_confirm_message": "Sind Sie sicher? Dies kann nicht rückgängig gemacht werden.",
|
||||
"no_history": "Noch kein Verlauf",
|
||||
"no_history_description": "Ihre Downloads werden hier angezeigt",
|
||||
"loading": "Verlauf wird geladen...",
|
||||
"search_placeholder": "Suchen...",
|
||||
"open_location": "Speicherort Öffnen",
|
||||
"redownload": "Erneut Laden",
|
||||
"remove": "Entfernen",
|
||||
"file_not_found": "Datei nicht gefunden",
|
||||
"file_not_found_message": "Die Datei wurde verschoben oder gelöscht:\n{path}",
|
||||
"downloaded_on": "Geladen: {date}",
|
||||
"file_size": "Größe: {size}",
|
||||
"audio_download": "Audio",
|
||||
"video_download": "Video",
|
||||
"remove_confirm_title": "Entfernen?",
|
||||
"remove_confirm_message": "Aus Verlauf entfernen?\n\n{title}",
|
||||
"item_removed": "Entfernt",
|
||||
"history_cleared": "Verlauf gelöscht",
|
||||
"entries_count": "{count} Downloads",
|
||||
"one_entry": "1 Download",
|
||||
"redownload_confirm_title": "Erneut Laden?",
|
||||
"redownload_confirm_message": "Erneut herunterladen?\n\n{title}",
|
||||
"redownload_started": "Download gestartet",
|
||||
"no_url_error": "Keine URL im Verlaufseintrag gefunden",
|
||||
"redownload_failed": "Neuer Download konnte nicht gestartet werden: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "FFmpeg-Installation",
|
||||
"installation_message": "YTSage benötigt FFmpeg zur Verarbeitung von Videos.\n\nWähle unten eine Installationsoption:",
|
||||
"install_button": "FFmpeg installieren",
|
||||
"manual_guide": "Manuelle Anleitung",
|
||||
"installation_failed": "Bei der FFmpeg-Installation ist ein Problem aufgetreten.",
|
||||
"already_installed": "FFmpeg ist bereits installiert!",
|
||||
"installation_complete": "Installation abgeschlossen. Sie können diesen Dialog schließen und YTSage weiter nutzen.",
|
||||
"installing": "FFmpeg wird installiert... Bitte warten",
|
||||
"install_success": "FFmpeg wurde erfolgreich installiert!",
|
||||
"installation_complete_close": "Installation abgeschlossen. Sie können diesen Dialog jetzt schließen und YTSage weiter nutzen.",
|
||||
"try_manual": "Bitte versuchen Sie stattdessen die manuelle Installationsanleitung."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "yt-dlp-Einrichtung erforderlich",
|
||||
"description": "YTSage benötigt yt-dlp zum Herunterladen von Videos.<br><br>yt-dlp wurde im lokalen App-Verzeichnis nicht gefunden. YTSage muss yt-dlp für dein {os_name}-System einrichten.<br><br>Bitte wähle unten eine Option:",
|
||||
"option_auto": "Automatisch herunterladen (empfohlen)",
|
||||
"option_manual": "Pfad manuell auswählen",
|
||||
"setup_button": "yt-dlp einrichten",
|
||||
"downloading": "yt-dlp wird heruntergeladen...",
|
||||
"success": "yt-dlp wurde erfolgreich installiert!",
|
||||
"error": "Fehler: {error}",
|
||||
"download_failed_title": "Download fehlgeschlagen",
|
||||
"download_failed_message": "yt-dlp konnte nicht heruntergeladen werden: {error}",
|
||||
"select_executable_title": "yt-dlp-Programm auswählen",
|
||||
"copied_to": "yt-dlp erfolgreich nach {path} kopiert",
|
||||
"setup_error_title": "Einrichtungsfehler",
|
||||
"copy_error": "Fehler beim Kopieren von yt-dlp ins App-Verzeichnis: {error}",
|
||||
"invalid_executable_title": "Ungültige Datei",
|
||||
"invalid_executable_message": "Die ausgewählte Datei scheint kein gültiges yt-dlp-Programm zu sein.",
|
||||
"verify_error": "Fehler beim Prüfen der yt-dlp-Datei: {error}",
|
||||
"setup_failed_title": "Einrichtung fehlgeschlagen",
|
||||
"setup_failed_message": "yt-dlp konnte nicht eingerichtet werden. Einige Funktionen funktionieren möglicherweise nicht korrekt.",
|
||||
"success_dialog_title": "yt-dlp-Einrichtung",
|
||||
"success_dialog_message": "yt-dlp wurde erfolgreich eingerichtet unter:\n{path}",
|
||||
"file_filter_windows": "Ausführbare Dateien (*.exe)",
|
||||
"file_filter_all": "Alle Dateien (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "FFmpeg-Versionsprüfer",
|
||||
"current_version": "Aktuelle Version:",
|
||||
"latest_version": "Neueste Version:",
|
||||
"status_up_to_date": "✓ Auf dem neuesten Stand",
|
||||
"status_update_available": "⚠ Update verfügbar",
|
||||
"status_not_installed": "✗ Nicht installiert",
|
||||
"status_idle": "Klicken Sie auf 'Version prüfen' um zu beginnen",
|
||||
"status_checking": "🔄 Version wird geprüft...",
|
||||
"check_updates": "Version prüfen",
|
||||
"check_failed": "Versionprüfung fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||
"description": "Überprüfen Sie Ihre FFmpeg-Version und vergleichen Sie sie mit der neuesten verfügbaren Version.",
|
||||
"guide_info": " Um FFmpeg zu installieren oder zu aktualisieren, <a href='https://github.com/oop7/ffmpeg-install-guide'>klicken Sie hier, um unseren umfassenden Installationsleitfaden anzuzeigen</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Deno-Einrichtung erforderlich",
|
||||
"setup_description": "YTSage benötigt Deno, um bestimmte Funktionen auszuführen.<br><br>Deno wurde im lokalen Verzeichnis der App nicht gefunden. YTSage muss Deno für Ihr {os_name}-System einrichten.<br><br>Klicken Sie auf 'Deno einrichten', um es automatisch herunterzuladen und zu installieren.",
|
||||
"setup_button": "Deno einrichten",
|
||||
"downloading": "Deno wird heruntergeladen...",
|
||||
"extracting": "Deno wird extrahiert...",
|
||||
"verifying": "Deno wird überprüft...",
|
||||
"success": "Deno wurde erfolgreich installiert!",
|
||||
"download_failed": "Download fehlgeschlagen",
|
||||
"download_error": "Deno konnte nicht heruntergeladen werden: {error}",
|
||||
"verification_failed": "SHA256-Überprüfung fehlgeschlagen. Die heruntergeladene Datei ist möglicherweise beschädigt oder manipuliert.",
|
||||
"setup_failed": "Deno konnte nicht eingerichtet werden. Einige Funktionen funktionieren möglicherweise nicht richtig.",
|
||||
"setup_error": "Einrichtungsfehler"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Deno Versionsprüfer & Updater",
|
||||
"description": "Überprüfen Sie Ihre Deno-Version und aktualisieren Sie auf die neueste Version.",
|
||||
"current_version": "Aktuelle Version:",
|
||||
"latest_version": "Neueste Version:",
|
||||
"status_idle": "Klicken Sie auf 'Nach Updates suchen', um zu beginnen",
|
||||
"status_checking": "🔄 Suche nach Updates...",
|
||||
"status_up_to_date": "✓ Aktuell",
|
||||
"status_update_available": "⚠ Update verfügbar",
|
||||
"status_not_installed": "✗ Nicht installiert",
|
||||
"check_updates": "Nach Updates suchen",
|
||||
"update_now": "Deno aktualisieren",
|
||||
"updating": "🔄 Deno wird aktualisiert...",
|
||||
"update_success": "✅ Deno wurde erfolgreich aktualisiert!",
|
||||
"update_failed": "❌ Update fehlgeschlagen: {error}",
|
||||
"check_failed": "Suche nach Updates fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "English",
|
||||
"select_language": "Select language:",
|
||||
"current_language": "Current language: {language}",
|
||||
"restart_required": "Language changes will take effect after restarting the application.",
|
||||
"english": "English",
|
||||
"spanish": "Español (Spanish)",
|
||||
"portuguese": "Português (Portuguese)",
|
||||
"russian": "Русский (Russian)",
|
||||
"chinese": "中文 (简体) (Chinese Simplified)",
|
||||
"german": "Deutsch (German)",
|
||||
"french": "Français (French)",
|
||||
"hindi": "हिन्दी (Hindi)",
|
||||
"indonesian": "Bahasa Indonesia (Indonesian)",
|
||||
"turkish": "Türkçe (Turkish)",
|
||||
"polish": "Polski (Polish)",
|
||||
"italian": "Italiano (Italian)",
|
||||
"arabic": "العربية (Arabic)",
|
||||
"japanese": "日本語 (Japanese)",
|
||||
"help_text": "Select your preferred language for the interface.",
|
||||
"restart_notice": "Language change will take effect after restarting the application."
|
||||
},
|
||||
"app": {
|
||||
"title": "SageTube",
|
||||
"version": "v{version}",
|
||||
"ready": "Ready"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Show Formats:",
|
||||
"video_format": "Video Format:",
|
||||
"audio_format": "Audio Format:",
|
||||
"no_formats": "No formats available",
|
||||
"loading": "Loading formats...",
|
||||
"select": "Select",
|
||||
"quality": "Quality",
|
||||
"extension": "Extension",
|
||||
"resolution": "Resolution",
|
||||
"file_size": "File Size",
|
||||
"codec": "Codec",
|
||||
"audio": "Audio",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Will merge audio",
|
||||
"has_audio": "✓ Has Audio",
|
||||
"audio_only": "Audio Only",
|
||||
"best_4k": "Best (4K)",
|
||||
"best_2k": "Best (2K)",
|
||||
"high_1080p": "High (1080p)",
|
||||
"high_720p": "High (720p)",
|
||||
"medium_480p": "Medium (480p)",
|
||||
"low_quality": "Low Quality",
|
||||
"best_audio": "Best Audio",
|
||||
"high_audio": "High Audio",
|
||||
"medium_audio": "Medium Audio",
|
||||
"low_audio": "Low Audio",
|
||||
"audio_only_resolution": "Audio only"
|
||||
},
|
||||
"buttons": {
|
||||
"download": "Download",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"cancel": "Cancel",
|
||||
"browse": "Browse",
|
||||
"clear": "Clear",
|
||||
"ok": "OK",
|
||||
"apply": "Apply",
|
||||
"close": "Close",
|
||||
"run_command": "Run Command",
|
||||
"custom_command_help": "Help",
|
||||
"about": "About",
|
||||
"history": "History",
|
||||
"analyze": "Analyze",
|
||||
"paste_url": "Paste URL",
|
||||
"select_videos": "Select Videos...",
|
||||
"video": "Video",
|
||||
"audio_only": "Audio Only",
|
||||
"custom_options": "Custom Options",
|
||||
"trim_video": "Trim Video",
|
||||
"download_settings": "Download Settings",
|
||||
"update": "Update yt-dlp",
|
||||
"select_defaults": "Select Defaults",
|
||||
"select_all": "Select All",
|
||||
"deselect_all": "Deselect All",
|
||||
"open_folder": "Open folder location",
|
||||
"save_playlist": "Save Playlist As",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Custom Options",
|
||||
"settings": "Settings",
|
||||
"select_folder": "Select Download Folder",
|
||||
"sponsorblock_categories": "SponsorBlock Categories",
|
||||
"sponsorblock_description": "Select which types of video segments to automatically remove during download.\nSponsorBlock uses community-submitted data to identify these segments.",
|
||||
"select_subtitles": "Select Subtitles",
|
||||
"filter_languages_placeholder": "Filter languages (e.g., en, es)...",
|
||||
"filter_playlist_placeholder": "Filter videos...",
|
||||
"no_subtitles_available": "No subtitles available",
|
||||
"matching": "matching",
|
||||
"ytdlp_log_title": "yt-dlp Log"
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Login with Cookies",
|
||||
"custom_command": "Custom Command",
|
||||
"proxy": "Proxy",
|
||||
"language": "Language",
|
||||
"updater": "Updater"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Choose how to provide cookies for logging in.\nThis allows downloading of private videos and premium quality audio.",
|
||||
"cookie_source": "Cookie Source",
|
||||
"use_cookie_file": "Use cookie file",
|
||||
"extract_from_browser": "Extract from browser",
|
||||
"recommended": "Recommended",
|
||||
"cookie_file": "Cookie File",
|
||||
"cookie_file_placeholder": "Path to cookies.txt file...",
|
||||
"browser_selection": "Browser Selection",
|
||||
"browser_help": "Select the browser to extract cookies from:",
|
||||
"browser_label": "Browser:",
|
||||
"profile_label": "Profile:",
|
||||
"profile_placeholder": "default",
|
||||
"browser_extract_message": "Browser cookies will be extracted when applied",
|
||||
"file_selected_message": "Cookie file selected - Click OK to apply",
|
||||
"select_file_title": "Select Cookie File",
|
||||
"file_filter": "Cookies files (*.txt *.lwp)",
|
||||
"file_selected_title": "Cookie File Applied",
|
||||
"file_applied_message": "Cookie file applied: {path}",
|
||||
"browser_selected_title": "Browser Cookies Applied",
|
||||
"browser_applied_message": "Browser cookies will be extracted from: {browser}",
|
||||
"cleared_title": "Cookies Cleared",
|
||||
"cleared_message": "Cookie settings have been cleared",
|
||||
"active_browser": "✓ Active: Browser cookies ({browser})",
|
||||
"active_file": "✓ Active: Cookie file ({file})",
|
||||
"none_active": "○ No cookies active",
|
||||
"remember_settings": "Remember cookie settings on next startup"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Enter your custom yt-dlp command below. The current URL will be appended automatically.<br><br>For the full list of options and usage examples, <a href=\"{docs_url}\">click here to view the official yt-dlp documentation</a>.<br><br>Note: Download path and filename template will be handled automatically.",
|
||||
"input_label": "yt-dlp Arguments:",
|
||||
"input_placeholder": "Enter yt-dlp arguments here...\n\ne.g. --extract-audio --audio-format mp3",
|
||||
"output_label": "Command Output:",
|
||||
"output_placeholder": "Command output will appear here...",
|
||||
"command_placeholder": "Enter custom yt-dlp command...",
|
||||
"command_help": "Available placeholders:\n{url} - Video URL\n{output} - Output directory\n\nExample: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Full command: {command}",
|
||||
"command_success": "✅ Custom command completed successfully!",
|
||||
"command_failed": "❌ Command failed with exit code {code}",
|
||||
"command_error": "❌ Error running custom command: {error}",
|
||||
"error_no_url": "❌ Error: No URL provided. Please enter a URL in the main window.",
|
||||
"error_no_command": "❌ Error: No command provided. Please enter yt-dlp arguments.",
|
||||
"executing": "🚀 Executing custom yt-dlp command",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Arguments: {command}",
|
||||
"download_path_label": "📁 Download path: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Configure proxy settings for downloading. Leave empty to use direct connection.",
|
||||
"main_proxy": "Main Proxy",
|
||||
"main_proxy_help": "Primary proxy server for all downloads. Supports HTTP/HTTPS and SOCKS5 protocols.",
|
||||
"proxy_url_label": "Proxy URL:",
|
||||
"proxy_url_placeholder": "http://proxy:port or socks5://proxy:port",
|
||||
"proxy_examples": "Examples:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Geo-bypass Proxy",
|
||||
"geo_proxy_help": "Secondary proxy specifically for bypassing geographic restrictions.",
|
||||
"geo_proxy_url_label": "Geo-bypass Proxy URL:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "Clear Main Proxy",
|
||||
"clear_geo_proxy": "Clear Geo Proxy",
|
||||
"proxy_url": "Proxy URL",
|
||||
"proxy_placeholder": "http://proxy:port or socks5://proxy:port",
|
||||
"geo_bypass": "Geo-bypass proxy (for geographic restrictions)",
|
||||
"geo_bypass_placeholder": "http://proxy:port for bypassing geo-blocks",
|
||||
"invalid_main_url": "Invalid main proxy URL format",
|
||||
"invalid_geo_url": "Invalid geo proxy URL format",
|
||||
"main_configured": "Main proxy configured",
|
||||
"geo_configured": "Geo proxy configured",
|
||||
"set_title": "Proxy Set",
|
||||
"set_message": "Main proxy set and saved: {proxy}",
|
||||
"geo_set_title": "Geo Proxy Set",
|
||||
"geo_set_message": "Geo-verification proxy set and saved: {proxy}",
|
||||
"cleared_title": "Proxy Settings Cleared",
|
||||
"cleared_message": "All proxy settings have been cleared and saved.",
|
||||
"saved_main": "Saved main proxy: {proxy}",
|
||||
"saved_geo": "Saved geo proxy: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Preparing download...",
|
||||
"starting": "🚀 Starting download...",
|
||||
"fetching_info": "🔍 Fetching video information...",
|
||||
"preparing_streams": "🎯 Preparing video streams...",
|
||||
"downloading_audio": "⏬ Downloading audio...",
|
||||
"downloading_video": "⏬ Downloading video...",
|
||||
"downloading_subtitle": "⏬ Downloading subtitle...",
|
||||
"downloading": "⏬ Downloading...",
|
||||
"completed": "✅ Download completed!",
|
||||
"video_completed": "✅ Video download completed!",
|
||||
"audio_completed": "✅ Audio download completed!",
|
||||
"subtitle_completed": "✅ Subtitle download completed!",
|
||||
"completed_cleaning": "✅ Download completed! Cleaning up...",
|
||||
"cancelled": "Download cancelled",
|
||||
"processing_playlist": "📋 Processing playlist data...",
|
||||
"paused": "Download paused",
|
||||
"resumed": "Download resumed",
|
||||
"merging_formats": "✨ Post-processing: Merging formats...",
|
||||
"removing_sponsor_segments": "✨ Post-processing: Removing sponsor segments...",
|
||||
"speed": "Speed",
|
||||
"eta": "ETA",
|
||||
"please_enter_url": "Please enter a URL first.",
|
||||
"please_set_path": "Please set a download path first.",
|
||||
"please_enter_url_and_path": "Please enter a URL and set download path.",
|
||||
"please_select_format": "Please select a format first.",
|
||||
"downloading_fallback": "⚡ Downloading..."
|
||||
},
|
||||
"update": {
|
||||
"title": "Update yt-dlp",
|
||||
"checking": "Checking for updates...",
|
||||
"update_available": "Update available!\nCurrent version: {current}\nLatest version: {latest}",
|
||||
"up_to_date": "yt-dlp is up to date (version {version})",
|
||||
"already_up_to_date": "✅ yt-dlp is already up to date!",
|
||||
"could_not_determine": "Could not determine versions.",
|
||||
"error_comparing": "Error comparing versions: {error}",
|
||||
"update_available_failed": "Update available! (Comparison failed)\nCurrent: {current}\nLatest: {latest}",
|
||||
"updating": "Updating...",
|
||||
"initializing": "🚀 Initializing update process...",
|
||||
"checking_current": "🔍 Checking current installation...",
|
||||
"found_at": "📍 Found yt-dlp at: {path}",
|
||||
"error_getting_path": "❌ Error getting yt-dlp path: {error}",
|
||||
"updating_binary": "📦 Updating app-managed yt-dlp binary...",
|
||||
"update_failed": "❌ Failed to update yt-dlp. Please try again or check your internet connection.",
|
||||
"binary_updated": "✅ Binary successfully updated!",
|
||||
"update_failed_stderr": "❌ yt-dlp update failed: {error}",
|
||||
"update_timeout": "❌ yt-dlp update timed out.",
|
||||
"unexpected_error": "❌ Unexpected error during update: {error}",
|
||||
"update_success": "✅ yt-dlp has been successfully updated!",
|
||||
"already_latest": "yt-dlp is up to date (version {version})",
|
||||
"network_error": "❌ Network error during update: {error}",
|
||||
"general_error": "❌ Update failed: {error}",
|
||||
"update_in_progress_title": "Update in Progress",
|
||||
"update_in_progress_message": "yt-dlp is currently updating. Please wait a moment."
|
||||
},
|
||||
"about": {
|
||||
"title": "About SageTube",
|
||||
"version": "Version {version}",
|
||||
"description": "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage by oop7 (MIT).",
|
||||
"author": "By: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "System Information",
|
||||
"loading": "🔄 Loading system information...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Logs",
|
||||
"logs_tooltip": "Open application logs folder",
|
||||
"refreshing": "🔄 Refreshing...",
|
||||
"refresh_failed": "Refresh Failed",
|
||||
"refresh_failed_message": "Could not refresh version information.",
|
||||
"detected": "✓ Detected",
|
||||
"missing": "✗ Missing",
|
||||
"not_available": "Not Available"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Trim Video",
|
||||
"time_range_group": "Time Range",
|
||||
"start_time": "Start Time (HH:MM:SS)",
|
||||
"end_time": "End Time (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Start time (HH:MM:SS)",
|
||||
"end_time_placeholder": "End time (HH:MM:SS)",
|
||||
"force_keyframes": "Force keyframes at cut points",
|
||||
"help_text": "Set the start and end times to trim the video.\nLeave empty to download the full video.",
|
||||
"invalid_format": "Invalid time format. Use HH:MM:SS format.",
|
||||
"start_after_end": "Start time cannot be after end time."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Download Settings",
|
||||
"download_path": "Download Path",
|
||||
"browse": "Browse...",
|
||||
"speed_limit": "Speed Limit",
|
||||
"speed_limit_placeholder": "None",
|
||||
"generic_mode": "Generic Mode",
|
||||
"enable_generic_mode": "Enable Generic Mode (support non-YouTube sites)",
|
||||
"generic_mode_help": "Allows downloading from Dailymotion, CBC Gem, and other sites supported by yt-dlp.",
|
||||
"auto_update_ytdlp": "Auto-Update yt-dlp",
|
||||
"enable_auto_updates": "Enable automatic yt-dlp updates",
|
||||
"update_frequency": "Update frequency:",
|
||||
"check_startup": "Check on every startup (minimum 1 hour between checks)",
|
||||
"check_daily": "Check daily",
|
||||
"check_weekly": "Check weekly",
|
||||
"check_updates_now": "Update yt-dlp",
|
||||
"update_check_title": "Update Check",
|
||||
"could_not_determine_version": "Could not determine current yt-dlp version.",
|
||||
"update_available_dialog": "Update available!\n\nCurrent: {current}\nLatest: {latest}\n\nClick OK to proceed with the update.",
|
||||
"up_to_date_dialog": "yt-dlp is up to date!\n\nCurrent version: {version}",
|
||||
"error_checking_updates": "Error checking for updates: {error}",
|
||||
"settings_saved_title": "Settings Saved",
|
||||
"settings_saved_message": "Auto-update settings have been saved successfully!",
|
||||
"error_title": "Error",
|
||||
"failed_save_settings": "Failed to save auto-update settings.",
|
||||
"error_saving_settings": "Error saving auto-update settings: {error}",
|
||||
"ytdlp_channel": "yt-dlp Release Channel",
|
||||
"ytdlp_channel_stable": "Stable (Tested releases)",
|
||||
"ytdlp_channel_nightly": "Nightly (Daily updates, recommended by yt-dlp)",
|
||||
"ytdlp_channel_description": "Choose between stable and nightly releases. Nightly builds are updated daily with the latest fixes and features. You can switch channels at any time.",
|
||||
"ytdlp_switching_channel": "Switching to {channel} channel...",
|
||||
"ytdlp_channel_switched": "✅ Successfully switched to {channel} channel!",
|
||||
"ytdlp_channel_switch_failed": "❌ Failed to switch channel: {error}",
|
||||
"ytdlp_current_channel": "Current channel: {channel}",
|
||||
"app_updates_title": "SageTube Updates",
|
||||
"check_app_updates": "Check for SageTube updates on startup",
|
||||
"check_beta_updates": "Receive Beta Updates",
|
||||
"auto_update_title": "Auto-Update Settings",
|
||||
"auto_update_header": "🔄 Auto-Update Settings",
|
||||
"auto_update_description": "Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.",
|
||||
"current_status": "Current Status",
|
||||
"current_version_label": "Current yt-dlp version: Checking...",
|
||||
"last_check_label": "Last update check: Never",
|
||||
"next_check_label": "Next check: Based on settings",
|
||||
"manual_check_button": "🔍 Check for Updates Now",
|
||||
"update_frequency_group": "Update Frequency",
|
||||
"save_settings": "Save Settings",
|
||||
"settings_saved_successfully": "✅ Settings saved successfully!",
|
||||
"error_saving": "❌ Error saving settings: {error}",
|
||||
"output_format_settings": "Output Format Settings",
|
||||
"force_output_format": "Force output format when merging",
|
||||
"preferred_format": "Preferred format:",
|
||||
"force_format_help": "When enabled, merged videos will be converted to your preferred format. Disable to let yt-dlp decide automatically.",
|
||||
"format_mp4": "MP4 (Most compatible)",
|
||||
"format_webm": "WebM (Modern, open)",
|
||||
"format_mkv": "MKV (Feature-rich)",
|
||||
"audio_format_settings": "Audio Format Settings",
|
||||
"force_audio_format": "Force audio format for audio-only downloads",
|
||||
"audio_normalization": "Audio Normalization (EBU R128)",
|
||||
"audio_normalization_help": "When enabled, audio tracks will be normalized. Note: This requires re-encoding, so a specific audio format (like MP3 or M4A) must be forced.",
|
||||
"filename_format": "Output Filename Format",
|
||||
"filename_format_help": "Available variables: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s, %(playlist_index)s. Standard yt-dlp output template syntax is supported.",
|
||||
"defaults_settings": "Default Selection Settings",
|
||||
"default_video_quality": "Default Video Resolution (Height):",
|
||||
"default_subtitle_language": "Default Subtitle Language(s):",
|
||||
"defaults_help": "Set your preferred video height and subtitle languages (comma-separated). They will be auto-selected if available.",
|
||||
"concurrent_fragments": "Concurrent Connections",
|
||||
"concurrent_fragments_help": "Number of connections per download. Higher values bypass throttling but may cause temporary blocks if set too high. Default: 1.",
|
||||
"preferred_audio_format": "Preferred audio format:",
|
||||
"force_audio_format_help": "When enabled, audio-only downloads will be converted to your preferred format. This only applies when downloading audio formats.",
|
||||
"audio_format_best": "Best (No conversion)",
|
||||
"audio_format_aac": "AAC (Good for editing)",
|
||||
"audio_format_mp3": "MP3 (Universal)",
|
||||
"audio_format_flac": "FLAC (Lossless)",
|
||||
"audio_format_wav": "WAV (Uncompressed)",
|
||||
"audio_format_opus": "Opus (Efficient)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Open)",
|
||||
"tab_general": "General",
|
||||
"tab_format": "Format",
|
||||
"tab_file": "File"
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Enter YouTube video or playlist URL",
|
||||
"url_placeholder_generic": "Enter video or playlist URL from any supported site",
|
||||
"merge_subtitles": "Merge Subtitles",
|
||||
"save_thumbnail": "Save Thumbnail",
|
||||
"save_description": "Save Description",
|
||||
"embed_chapters": "Embed Chapters",
|
||||
"subtitles_selected": "{count} selected",
|
||||
"all_selected": "All selected",
|
||||
"select_videos_all": "Select Videos... (All selected)",
|
||||
"please_enter_url": "Please enter a URL first",
|
||||
"error_title": "Error",
|
||||
"cookie_file_selected_title": "Cookie File Selected",
|
||||
"cookie_file_selected_message": "Cookie file selected: {path}",
|
||||
"browser_cookies_selected_title": "Browser Cookies Selected",
|
||||
"browser_cookies_selected_message": "Browser cookies will be extracted from: {browser}",
|
||||
"error_no_format_info": "Error: No format information available.",
|
||||
"error_extract_info": "Error: Could not extract basic video information. Please check your link.",
|
||||
"analyzing_preparing": "Preparing request...",
|
||||
"analyzing_extracting_basic": "Extracting basic info...",
|
||||
"analyzing_extracting_detailed": "Extracting detailed info...",
|
||||
"analyzing_processing_video": "Processing video data...",
|
||||
"analyzing_processing_formats": "Processing formats...",
|
||||
"analyzing_loading_thumbnail": "Loading thumbnail...",
|
||||
"analyzing_processing_subtitles": "Processing subtitles...",
|
||||
"analyzing_updating_table": "Updating format table...",
|
||||
"analysis_complete": "Analysis complete!",
|
||||
"analyzing_extracting_ytdlp": "Extracting info...",
|
||||
"analyzing_fetching_first_video": "Fetching formats for first video...",
|
||||
"analyzing_processing_data": "Processing data...",
|
||||
"analyzing_processing_formats_ytdlp": "Processing formats...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Loading thumbnail...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Processing subtitles...",
|
||||
"select_subtitles": "Select Subtitles...",
|
||||
"sponsorblock_categories": "SponsorBlock Categories...",
|
||||
"invalid_url_or_enter": "Invalid URL or please enter a URL.",
|
||||
"zero_selected": "0 selected",
|
||||
"analyze_first_tooltip": "Please analyze the video first",
|
||||
"audio_mode_disabled": "Not available in audio-only mode",
|
||||
"select_subtitles_first": "Please select subtitles first",
|
||||
"settings_tooltip": "Current Path: {path}\nSpeed Limit: {speed_limit}",
|
||||
"speed_limit_none": "None",
|
||||
"open_folder_error": "Could not open folder: {error}",
|
||||
"time_range_set": "Section set: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Sponsor",
|
||||
"sponsor_desc": "Paid promotion, paid referrals and direct advertisements",
|
||||
"selfpromo": "Unpaid/Self Promotion",
|
||||
"selfpromo_desc": "Unpaid promotion of creators' own content",
|
||||
"interaction": "Interaction Reminder",
|
||||
"interaction_desc": "Asking viewers to like, subscribe, or follow social media",
|
||||
"intro": "Intro",
|
||||
"intro_desc": "Video introduction that can be skipped",
|
||||
"outro": "Outro/End Cards",
|
||||
"outro_desc": "Credits or when the video ends",
|
||||
"preview": "Preview/Recap",
|
||||
"preview_desc": "Quick recap of previous videos or preview of what's coming up",
|
||||
"music_offtopic": "Non-Music Section",
|
||||
"music_offtopic_desc": "Only for music videos. Marks non-music sections",
|
||||
"filler": "Filler Tangent",
|
||||
"filler_desc": "Tangential scenes added only for filler or humor"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Channel",
|
||||
"views": "👁 Views",
|
||||
"likes": "👍 Likes",
|
||||
"upload_date": "📅 Upload date",
|
||||
"duration": "⏱ Duration",
|
||||
"unknown_channel": "Unknown channel",
|
||||
"unknown_date": "Unknown date",
|
||||
"unknown_title": "Unknown title"
|
||||
},
|
||||
"command": {
|
||||
"running": "Running...",
|
||||
"run_command": "Run Command"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 selected",
|
||||
"one_selected": "1 category selected",
|
||||
"count_selected": "{count} selected"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Ready",
|
||||
"file_exists": "⚠️ File already exists",
|
||||
"video_file_exists": "⚠️ Video file already exists",
|
||||
"audio_file_exists": "⚠️ Audio file already exists",
|
||||
"subtitle_file_exists": "⚠️ Subtitle file already exists",
|
||||
"cancelling": "Cancelling download...",
|
||||
"thumbnail_saved": "✅ Thumbnail saved: {filename}",
|
||||
"thumbnail_error": "❌ Thumbnail error: {error}",
|
||||
"thumbnail_no_image": "No thumbnail available to save"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Error: Playlist contains no valid videos.",
|
||||
"playlist_no_url": "Error: Could not get URL for the first playlist video.",
|
||||
"ytdlp_not_found": "Error: yt-dlp executable not found. Please install yt-dlp first.",
|
||||
"ytdlp_not_found_path": "Error: yt-dlp executable not found. This could be due to improper installation or a PATH issue.",
|
||||
"no_data_returned": "Error: No data returned from yt-dlp",
|
||||
"no_format_info": "Error: No format information available.",
|
||||
"analysis_timeout": "Error: Analysis timed out. Please try again.",
|
||||
"invalid_speed_limit": "❌ Error: Invalid speed limit value set in settings.",
|
||||
"ytdlp_failed": "Error: yt-dlp failed: {error}",
|
||||
"private_video": "This video might be private. Please use cookies from the custom options.",
|
||||
"parse_failed": "Error: Failed to parse yt-dlp output: {error}",
|
||||
"analysis_failed": "Error: Analysis failed: {error}",
|
||||
"generic_error": "Error: {error}",
|
||||
"download_failed_return_code_conflict": "Download failed with return code {return_code}. This may be due to a conflict with multiple yt-dlp installations. Try uninstalling any system-installed yt-dlp (e.g. through snap or apt) and restart the application.",
|
||||
"download_failed_return_code": "Download failed with return code {return_code}",
|
||||
"direct_command_error": "Error in direct command: {error}"
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Update Available",
|
||||
"new_version_available": "A new version of SageTube is available!",
|
||||
"current_version_label": "Current version:",
|
||||
"latest_version_label": "Latest version:",
|
||||
"changelog": "Changelog",
|
||||
"download_update": "Download Update",
|
||||
"remind_later": "Remind Me Later",
|
||||
"skip_version": "Skip This Version",
|
||||
"skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.",
|
||||
"changelog_unavailable": "No release notes were published for this version."
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Unknown Playlist",
|
||||
"total_videos": "Total Videos: {count}",
|
||||
"display_format": "Playlist: {title} | {count} videos",
|
||||
"select_videos_title": "Select Playlist Videos",
|
||||
"save_as": "Save Playlist As",
|
||||
"save_success_title": "Success",
|
||||
"saved_successfully": "Playlist saved successfully.",
|
||||
"save_error_title": "Save Error",
|
||||
"no_videos_to_save": "No playlist entries gathered!",
|
||||
"save_error_msg": "Failed to save playlist."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} selected"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "File Already Exists",
|
||||
"message": "The file already exists:\n{filename}",
|
||||
"info": "This video has already been downloaded."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Last update check: Never",
|
||||
"last_check": "Last update check: {time}",
|
||||
"next_check_disabled": "Next check: Disabled",
|
||||
"next_check_startup": "Next check: On next startup",
|
||||
"next_check_overdue": "Next check: Now (overdue)",
|
||||
"next_check": "Next check: {time}",
|
||||
"next_check_error": "Next check: Error calculating",
|
||||
"checking": "🔄 Checking...",
|
||||
"check_now": "🔍 Check for Updates Now",
|
||||
"current_version": "Current yt-dlp version: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL cannot be empty",
|
||||
"invalid_format": "Invalid URL format",
|
||||
"invalid_scheme": "URL must start with http:// or https://",
|
||||
"missing_domain": "Invalid URL: missing domain name",
|
||||
"unsupported_platform": "YTSage only supports YouTube and YouTube Music URLs.\nThe domain '{domain}' is not supported.",
|
||||
"invalid_youtu_be": "Invalid youtu.be URL: missing video ID"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "This is a private video. You can download it by logging into your account using cookies.\nGo to 'Custom Options' → 'Login with Cookies' → 'Extract cookies from browser' to authenticate.",
|
||||
"age_restricted": "This video is age-restricted. You need to be logged in to access it.\nUse 'Custom Options' → 'Login with Cookies' to authenticate with your account.",
|
||||
"geo_blocked": "This video is not available in your region (geo-blocked).\nYou may need to use a VPN or the video might be restricted in your country.",
|
||||
"video_unavailable": "This video has been removed or is no longer available.\nThe video may have been deleted by the uploader or removed due to policy violations.",
|
||||
"live_stream": "This is a live stream that cannot be downloaded while active.\nWait for the stream to end, then try downloading the archived version.",
|
||||
"playlist_error": "Unable to access this playlist. It may be private, deleted, or empty.\nCheck if the playlist exists and is publicly accessible.",
|
||||
"network_error": "Network connection error. Please check your internet connection and try again.\nIf the problem persists, the video server might be temporarily unavailable.",
|
||||
"invalid_url": "Invalid or unsupported URL. Please check the link and try again.\nMake sure you're using a valid YouTube, Vimeo, or other supported platform URL.",
|
||||
"premium_content": "This content requires YouTube Premium or channel membership.\nYou need to be logged in with an account that has access to this content.",
|
||||
"copyright_blocked": "This video is blocked due to copyright claims.\nThe content owner has restricted access to this video.",
|
||||
"extraction_failed": "Failed to extract video information. This might be a temporary issue.\nPlease try again in a few minutes, or check if the video link is correct.",
|
||||
"generic_error": "Could not extract video information. Please check your link.\nTechnical details: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Download History",
|
||||
"clear_all": "Clear All History",
|
||||
"clear_confirm_title": "Clear History?",
|
||||
"clear_confirm_message": "Are you sure you want to clear all download history? This cannot be undone.",
|
||||
"no_history": "No download history yet",
|
||||
"no_history_description": "Your downloaded videos and audio will appear here",
|
||||
"loading": "Loading history...",
|
||||
"search_placeholder": "Search history...",
|
||||
"open_location": "Open File Location",
|
||||
"redownload": "Redownload",
|
||||
"remove": "Remove from History",
|
||||
"file_not_found": "File not found at original location",
|
||||
"file_not_found_message": "The file was moved or deleted:\n{path}",
|
||||
"downloaded_on": "Downloaded on {date}",
|
||||
"file_size": "Size: {size}",
|
||||
"audio_download": "Audio",
|
||||
"video_download": "Video",
|
||||
"remove_confirm_title": "Remove from History?",
|
||||
"remove_confirm_message": "Remove this item from history?\n\n{title}",
|
||||
"item_removed": "Item removed from history",
|
||||
"history_cleared": "History cleared",
|
||||
"entries_count": "{count} downloads",
|
||||
"one_entry": "1 download",
|
||||
"redownload_confirm_title": "Redownload Video?",
|
||||
"redownload_confirm_message": "Download this video again using the same settings?\n\n{title}",
|
||||
"redownload_started": "Redownload started",
|
||||
"no_url_error": "No URL found in history entry",
|
||||
"redownload_failed": "Failed to start redownload: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "FFmpeg Installation",
|
||||
"installation_message": "YTSage needs FFmpeg to process videos.\n\nChoose an installation option below:",
|
||||
"install_button": "Install FFmpeg",
|
||||
"manual_guide": "Manual Guide",
|
||||
"installation_failed": "FFmpeg installation encountered an issue.",
|
||||
"already_installed": "FFmpeg is already installed!",
|
||||
"installation_complete": "Installation complete. You can close this dialog and continue using YTSage.",
|
||||
"installing": "Installing FFmpeg... Please wait",
|
||||
"install_success": "FFmpeg has been installed successfully!",
|
||||
"installation_complete_close": "Installation complete. You can now close this dialog and continue using YTSage.",
|
||||
"try_manual": "Please try using the manual installation guide instead."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "yt-dlp Setup Required",
|
||||
"description": "YTSage requires yt-dlp to download videos.<br><br>yt-dlp was not found in the app's local directory. YTSage needs to set up yt-dlp for your {os_name} system.<br><br>Please choose an option below:",
|
||||
"option_auto": "Download automatically (Recommended)",
|
||||
"option_manual": "Select path manually",
|
||||
"setup_button": "Setup yt-dlp",
|
||||
"downloading": "Downloading yt-dlp...",
|
||||
"success": "yt-dlp was successfully installed!",
|
||||
"error": "Error: {error}",
|
||||
"download_failed_title": "Download Failed",
|
||||
"download_failed_message": "Failed to download yt-dlp: {error}",
|
||||
"select_executable_title": "Select yt-dlp executable",
|
||||
"copied_to": "yt-dlp successfully copied to {path}",
|
||||
"setup_error_title": "Setup Error",
|
||||
"copy_error": "Error copying yt-dlp to app directory: {error}",
|
||||
"invalid_executable_title": "Invalid Executable",
|
||||
"invalid_executable_message": "The selected file does not appear to be a valid yt-dlp executable.",
|
||||
"verify_error": "Error verifying yt-dlp executable: {error}",
|
||||
"setup_failed_title": "Setup Failed",
|
||||
"setup_failed_message": "Failed to set up yt-dlp. Some features may not work correctly.",
|
||||
"success_dialog_title": "yt-dlp Setup",
|
||||
"success_dialog_message": "yt-dlp has been successfully configured at:\n{path}",
|
||||
"file_filter_windows": "Executable Files (*.exe)",
|
||||
"file_filter_all": "All Files (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "FFmpeg Version Checker",
|
||||
"current_version": "Current Version:",
|
||||
"latest_version": "Latest Version:",
|
||||
"status_up_to_date": "✓ Up to date",
|
||||
"status_update_available": "⚠ Update available",
|
||||
"status_not_installed": "✗ Not installed",
|
||||
"status_idle": "Click 'Check Version' to get started",
|
||||
"status_checking": "🔄 Checking version...",
|
||||
"check_updates": "Check Version",
|
||||
"check_failed": "Failed to check version. Please check your internet connection.",
|
||||
"description": "Check your FFmpeg version and compare it with the latest available version.",
|
||||
"guide_info": " To install or update FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>click here to view our comprehensive installation guide</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Deno Setup Required",
|
||||
"setup_description": "YTSage requires Deno to run certain features.<br><br>Deno was not found in the app's local directory. YTSage needs to set up Deno for your {os_name} system.<br><br>Click 'Setup Deno' to download and install automatically.",
|
||||
"setup_button": "Setup Deno",
|
||||
"downloading": "Downloading Deno...",
|
||||
"extracting": "Extracting Deno...",
|
||||
"verifying": "Verifying Deno...",
|
||||
"success": "Deno was successfully installed!",
|
||||
"download_failed": "Download Failed",
|
||||
"download_error": "Failed to download Deno: {error}",
|
||||
"verification_failed": "SHA256 verification failed. The downloaded file may be corrupted or tampered with.",
|
||||
"setup_failed": "Failed to set up Deno. Some features may not work correctly.",
|
||||
"setup_error": "Setup Error"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Deno Version Checker & Updater",
|
||||
"description": "Check your Deno version and update to the latest release.",
|
||||
"current_version": "Current Version:",
|
||||
"latest_version": "Latest Version:",
|
||||
"status_idle": "Click 'Check for Updates' to get started",
|
||||
"status_checking": "🔄 Checking for updates...",
|
||||
"status_up_to_date": "✓ Up to date",
|
||||
"status_update_available": "⚠ Update available",
|
||||
"status_not_installed": "✗ Not installed",
|
||||
"check_updates": "Check for Updates",
|
||||
"update_now": "Update Deno",
|
||||
"updating": "🔄 Updating Deno...",
|
||||
"update_success": "✅ Deno has been successfully updated!",
|
||||
"update_failed": "❌ Update failed: {error}",
|
||||
"check_failed": "Failed to check for updates. Please check your internet connection."
|
||||
},
|
||||
"player": {
|
||||
"quality_auto": "Auto",
|
||||
"subtitles": "CC",
|
||||
"unavailable": "Video player unavailable",
|
||||
"now_playing": "Now Playing",
|
||||
"queue": "Queue",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.",
|
||||
"play_pause": "Play / pause (Space)",
|
||||
"fullscreen": "Fullscreen (F)",
|
||||
"seek_tooltip": "Seek through the video",
|
||||
"quality_tooltip": "Maximum playback resolution",
|
||||
"speed_tooltip": "Playback speed",
|
||||
"volume_tooltip": "Volume",
|
||||
"subtitles_tooltip": "Toggle subtitles (C)",
|
||||
"previous": "Previous in queue (P)",
|
||||
"next": "Next in queue (N)",
|
||||
"buffering": "Buffering…",
|
||||
"act_play_pause": "Play / pause",
|
||||
"act_seek_back_5": "Back 5 seconds",
|
||||
"act_seek_fwd_5": "Forward 5 seconds",
|
||||
"act_seek_back_10": "Back 10 seconds",
|
||||
"act_seek_fwd_10": "Forward 10 seconds",
|
||||
"act_seek_back_1": "Back 1 second",
|
||||
"act_seek_fwd_1": "Forward 1 second",
|
||||
"act_frame_back": "Previous frame",
|
||||
"act_frame_fwd": "Next frame",
|
||||
"act_vol_up": "Volume up",
|
||||
"act_vol_down": "Volume down",
|
||||
"act_mute": "Mute",
|
||||
"act_unmute": "Unmute",
|
||||
"act_speed_up": "Speed up",
|
||||
"act_speed_down": "Slow down",
|
||||
"act_speed_reset": "Normal speed",
|
||||
"act_subtitles": "Subtitles on / off",
|
||||
"act_fullscreen": "Fullscreen",
|
||||
"act_leave_fullscreen": "Leave fullscreen",
|
||||
"act_next": "Next in queue",
|
||||
"act_previous": "Previous",
|
||||
"act_start": "Back to start",
|
||||
"act_end": "Jump to end",
|
||||
"act_copy_url": "Copy video link",
|
||||
"act_open_browser": "Open in browser"
|
||||
},
|
||||
"cards": {
|
||||
"play": "▶ Play",
|
||||
"queue": "+ Queue",
|
||||
"download": "⬇",
|
||||
"load_more": "Load more",
|
||||
"empty": "Nothing here yet",
|
||||
"play_tooltip": "Play this video now",
|
||||
"queue_tooltip": "Add to the play queue",
|
||||
"download_tooltip": "Open in Downloads with this video ready to analyse",
|
||||
"load_more_tooltip": "Load the next page of results"
|
||||
},
|
||||
"main_tabs": {
|
||||
"watch": "Watch",
|
||||
"search": "Search",
|
||||
"feed": "Feed",
|
||||
"browse": "Browse",
|
||||
"downloads": "Downloads"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search YouTube...",
|
||||
"button": "Search",
|
||||
"searching": "Searching...",
|
||||
"results_count": "{count} results",
|
||||
"failed": "Search failed: {error}",
|
||||
"button_tooltip": "Search YouTube"
|
||||
},
|
||||
"watch": {
|
||||
"clear_queue": "Clear queue"
|
||||
},
|
||||
"browse": {
|
||||
"placeholder": "Paste a channel or playlist URL...",
|
||||
"open": "Open",
|
||||
"subscribe": "Subscribe",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"play_all": "Play all",
|
||||
"download_playlist": "Download",
|
||||
"tab_videos": "Videos",
|
||||
"tab_shorts": "Shorts",
|
||||
"tab_live": "Live",
|
||||
"loading": "Loading...",
|
||||
"entry_count": "{count} videos",
|
||||
"failed": "Failed to load: {error}",
|
||||
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.",
|
||||
"open_tooltip": "Open this channel or playlist",
|
||||
"subscribe_tooltip": "Follow this channel so its uploads appear in your Feed",
|
||||
"unsubscribe_tooltip": "Stop following this channel",
|
||||
"play_all_tooltip": "Queue everything loaded here",
|
||||
"download_playlist_tooltip": "Open this playlist in Downloads",
|
||||
"queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest"
|
||||
},
|
||||
"feed": {
|
||||
"mode_local": "Local subscriptions",
|
||||
"mode_account": "YouTube account (cookies)",
|
||||
"refresh": "Refresh",
|
||||
"subscriptions": "Subscriptions",
|
||||
"no_subscriptions": "No subscriptions yet - subscribe from a channel page in Browse",
|
||||
"refreshing": "Refreshing... {done}/{total}",
|
||||
"refreshed": "{count} videos in feed",
|
||||
"channel_failed": "A channel failed to refresh: {error}",
|
||||
"fetching_account": "Fetching your subscription feed...",
|
||||
"account_failed": "Account feed failed (are cookies valid?): {error}",
|
||||
"subscribed": "Subscribed to {title}",
|
||||
"unsubscribed": "Unsubscribed from {title}",
|
||||
"open_channel": "Open channel",
|
||||
"refresh_tooltip": "Fetch the latest uploads from every subscribed channel",
|
||||
"mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account",
|
||||
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
|
||||
"account_needs_refresh": "Press Refresh to load your account feed",
|
||||
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
|
||||
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
|
||||
"sign_in": "Sign in with cookies…",
|
||||
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
|
||||
"account_requires_cookies": "Requires signing in with cookies",
|
||||
"empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed."
|
||||
},
|
||||
"account": {
|
||||
"signed_out": "Not signed in",
|
||||
"signed_out_tooltip": "SageTube is not using a YouTube account. Sign in with browser cookies to use your subscription feed and reach age-restricted or members-only videos.",
|
||||
"signed_in": "Account: {source}",
|
||||
"signed_in_tooltip": "Using cookies from {source}. Click to change or clear them.",
|
||||
"source_file": "cookie file"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Español (Spanish)",
|
||||
"select_language": "Seleccionar idioma:",
|
||||
"current_language": "Idioma actual: {language}",
|
||||
"restart_required": "Los cambios de idioma tendrán efecto después de reiniciar la aplicación.",
|
||||
"english": "Inglés",
|
||||
"spanish": "Español (Spanish)",
|
||||
"portuguese": "Português (Portuguese)",
|
||||
"russian": "Русский (Russian)",
|
||||
"chinese": "中文 (简体) (Chinese Simplified)",
|
||||
"german": "Deutsch (German)",
|
||||
"french": "Français (French)",
|
||||
"hindi": "Hindi",
|
||||
"indonesian": "Indonesio",
|
||||
"turkish": "Turco",
|
||||
"polish": "Polaco",
|
||||
"italian": "Italiano",
|
||||
"arabic": "Árabe",
|
||||
"japanese": "Japonés",
|
||||
"help_text": "Seleccione su idioma preferido para la interfaz.",
|
||||
"restart_notice": "El cambio de idioma tendrá efecto después de reiniciar la aplicación."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Listo"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Restablecer",
|
||||
"download": "Descargar",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar",
|
||||
"cancel": "Cancelar",
|
||||
"browse": "Examinar",
|
||||
"clear": "Limpiar",
|
||||
"ok": "Aceptar",
|
||||
"apply": "Aplicar",
|
||||
"close": "Cerrar",
|
||||
"run_command": "Ejecutar Comando",
|
||||
"about": "Acerca de",
|
||||
"update": "Actualizar",
|
||||
"analyze": "Analizar",
|
||||
"paste_url": "Pegar URL",
|
||||
"select_videos": "Seleccionar Videos...",
|
||||
"video": "Video",
|
||||
"audio_only": "Solo Audio",
|
||||
"custom_options": "Opciones Personalizadas",
|
||||
"trim_video": "Recortar Video",
|
||||
"download_settings": "Configuración de Descarga",
|
||||
"select_defaults": "Seleccionar Predeterminados",
|
||||
"select_all": "Seleccionar Todos",
|
||||
"deselect_all": "Deseleccionar Todos",
|
||||
"open_folder": "Abrir ubicación de carpeta",
|
||||
"history": "Historial",
|
||||
"save_playlist": "Guardar playlist como",
|
||||
"custom_command_help": "Ayuda"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Opciones Personalizadas",
|
||||
"settings": "Configuración",
|
||||
"select_folder": "Seleccionar Carpeta de Descarga",
|
||||
"sponsorblock_categories": "Categorías SponsorBlock",
|
||||
"sponsorblock_description": "Selecciona qué tipos de segmentos de video se eliminarán automáticamente durante la descarga.\nSponsorBlock utiliza datos enviados por la comunidad para identificar estos segmentos.",
|
||||
"select_subtitles": "Seleccionar Subtítulos",
|
||||
"filter_languages_placeholder": "Filtrar idiomas (ej., en, es)...",
|
||||
"no_subtitles_available": "No hay subtítulos disponibles",
|
||||
"matching": "que coincidan con",
|
||||
"ytdlp_log_title": "Registro de yt-dlp",
|
||||
"filter_playlist_placeholder": "Filtrar videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Iniciar sesión con Cookies",
|
||||
"custom_command": "Comando Personalizado",
|
||||
"proxy": "Proxy",
|
||||
"language": "Idioma",
|
||||
"updater": "Actualizador"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Elige cómo proporcionar cookies para iniciar sesión.\nEsto permite la descarga de videos privados y audio de calidad premium.",
|
||||
"cookie_source": "Origen de Cookie",
|
||||
"use_cookie_file": "Usar archivo de cookie (formato Netscape)",
|
||||
"extract_from_browser": "Extraer cookies del navegador",
|
||||
"recommended": "Recomendado",
|
||||
"cookie_file": "Archivo de Cookie",
|
||||
"cookie_file_placeholder": "Ruta al archivo de cookies (formato Netscape)",
|
||||
"browser_selection": "Selección de Navegador",
|
||||
"browser_help": "Selecciona el navegador del cual extraer cookies. Asegúrate de que el navegador esté cerrado antes de la extracción.",
|
||||
"browser_label": "Navegador:",
|
||||
"profile_label": "Perfil (opcional):",
|
||||
"profile_placeholder": "Nombre o ruta del perfil (dejar vacío para el predeterminado)",
|
||||
"browser_extract_message": "Las cookies del navegador se extraerán al aplicar",
|
||||
"file_selected_message": "Archivo de cookies seleccionado - Haz clic en Aceptar para aplicar",
|
||||
"select_file_title": "Seleccionar Archivo de Cookies",
|
||||
"file_filter": "Archivos de cookies (*.txt *.lwp)",
|
||||
"file_selected_title": "Archivo de Cookies Aplicado",
|
||||
"file_applied_message": "Archivo de cookies aplicado: {path}",
|
||||
"browser_selected_title": "Cookies del Navegador Aplicadas",
|
||||
"browser_applied_message": "Las cookies del navegador se extraerán de: {browser}",
|
||||
"cleared_title": "Cookies Borradas",
|
||||
"cleared_message": "La configuración de cookies se ha borrado",
|
||||
"active_browser": "✓ Activas: cookies del navegador ({browser})",
|
||||
"active_file": "✓ Activo: archivo de cookies ({file})",
|
||||
"none_active": "○ No hay cookies activas",
|
||||
"remember_settings": "Recordar configuración de cookies en el próximo inicio"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Ingresa tu comando yt-dlp personalizado a continuación. La URL actual se añadirá automáticamente.<br><br>Para ver la lista completa de opciones y ejemplos de uso, <a href=\"{docs_url}\">haz clic aquí para ver la documentación oficial de yt-dlp</a>.<br><br>Nota: La ruta de descarga y la plantilla de nombre de archivo se manejarán automáticamente.",
|
||||
"input_label": "Argumentos de yt-dlp:",
|
||||
"input_placeholder": "Ingresa argumentos de yt-dlp aquí...\n\nej. --extract-audio --audio-format mp3",
|
||||
"output_label": "Salida del Comando:",
|
||||
"output_placeholder": "La salida del comando aparecerá aquí...",
|
||||
"full_command": "🔧 Comando completo: {command}",
|
||||
"command_failed": "❌ El comando falló con código de salida {code}",
|
||||
"command_success": "✅ ¡Comando completado exitosamente!",
|
||||
"command_error": "❌ Error ejecutando comando: {error}",
|
||||
"error_no_url": "❌ Error: No se proporcionó URL. Ingresa una URL en la ventana principal.",
|
||||
"error_no_command": "❌ Error: No se proporcionó comando. Ingresa argumentos de yt-dlp.",
|
||||
"executing": "🚀 Ejecutando comando personalizado de yt-dlp",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Argumentos: {command}",
|
||||
"download_path_label": "📁 Ruta de descarga: {path}",
|
||||
"separator": "==================================================",
|
||||
"command_placeholder": "Enter custom yt-dlp command...",
|
||||
"command_help": "Available placeholders:\n{url} - Video URL\n{output} - Output directory\n\nExample: --write-info-json --write-thumbnail"
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Configurar ajustes de proxy para conexiones de red y geo-verificación.\nEl proxy puede ayudar a evitar restricciones regionales y mejorar el rendimiento de descarga.",
|
||||
"main_proxy": "Proxy Principal",
|
||||
"main_proxy_help": "Usar el proxy HTTP/HTTPS/SOCKS especificado para todas las conexiones.",
|
||||
"proxy_url_label": "URL del Proxy:",
|
||||
"proxy_url_placeholder": "ej., http://proxy.example.com:8080 o socks5://user:pass@127.0.0.1:1080",
|
||||
"proxy_examples": "Ejemplos: http://proxy.com:8080, https://proxy.com:8080, socks5://127.0.0.1:1080",
|
||||
"geo_proxy": "Proxy de Geo-verificación",
|
||||
"geo_proxy_help": "Usar este proxy para verificar dirección IP para sitios con restricciones geográficas. El proxy principal (si está configurado) se usa para la descarga real.",
|
||||
"geo_proxy_url_label": "URL del Geo Proxy:",
|
||||
"geo_proxy_url_placeholder": "ej., http://us-proxy.example.com:8080",
|
||||
"clear_main_proxy": "Limpiar Proxy Principal",
|
||||
"clear_geo_proxy": "Limpiar Geo Proxy",
|
||||
"invalid_main_url": "Formato de URL de proxy principal inválido",
|
||||
"invalid_geo_url": "Formato de URL de geo proxy inválido",
|
||||
"main_configured": "Proxy principal configurado",
|
||||
"geo_configured": "Geo proxy configurado",
|
||||
"set_title": "Proxy establecido",
|
||||
"set_message": "Proxy principal establecido y guardado: {proxy}",
|
||||
"geo_set_title": "Proxy geo establecido",
|
||||
"geo_set_message": "Proxy de verificación geográfica establecido y guardado: {proxy}",
|
||||
"cleared_title": "Configuración de proxy borrada",
|
||||
"cleared_message": "Se borró y guardó toda la configuración de proxy.",
|
||||
"saved_main": "Proxy principal guardado: {proxy}",
|
||||
"saved_geo": "Proxy geo guardado: {proxy}",
|
||||
"proxy_url": "Proxy URL",
|
||||
"proxy_placeholder": "http://proxy:port or socks5://proxy:port",
|
||||
"geo_bypass": "Geo-bypass proxy (for geographic restrictions)",
|
||||
"geo_bypass_placeholder": "http://proxy:port for bypassing geo-blocks"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Preparando descarga...",
|
||||
"starting": "🚀 Iniciando descarga...",
|
||||
"fetching_info": "🔍 Obteniendo información del video...",
|
||||
"preparing_streams": "🎯 Preparando flujos de video...",
|
||||
"downloading_audio": "⏬ Descargando audio...",
|
||||
"downloading_video": "⏬ Descargando video...",
|
||||
"downloading_subtitle": "⏬ Descargando subtítulos...",
|
||||
"downloading": "⏬ Descargando...",
|
||||
"completed": "✅ ¡Descarga completada!",
|
||||
"video_completed": "✅ ¡Descarga de video completada!",
|
||||
"audio_completed": "✅ ¡Descarga de audio completada!",
|
||||
"subtitle_completed": "✅ ¡Descarga de subtítulos completada!",
|
||||
"completed_cleaning": "✅ ¡Descarga completada! Limpiando...",
|
||||
"cancelled": "Descarga cancelada",
|
||||
"processing_playlist": "📋 Procesando datos de lista de reproducción...",
|
||||
"paused": "Descarga pausada",
|
||||
"resumed": "Descarga reanudada",
|
||||
"merging_formats": "✨ Post-procesamiento: Fusionando formatos...",
|
||||
"removing_sponsor_segments": "✨ Post-procesamiento: Eliminando segmentos patrocinados...",
|
||||
"speed": "Velocidad",
|
||||
"eta": "Tiempo restante",
|
||||
"please_enter_url": "Por favor ingresa una URL primero.",
|
||||
"please_set_path": "Por favor establece una ruta de descarga primero.",
|
||||
"please_enter_url_and_path": "Por favor ingresa URL y establece ruta de descarga.",
|
||||
"please_select_format": "Por favor selecciona un formato primero.",
|
||||
"downloading_fallback": "⚡ Descargando..."
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Mostrar formatos:",
|
||||
"select": "Seleccionar",
|
||||
"quality": "Calidad",
|
||||
"extension": "Extensión",
|
||||
"resolution": "Resolución",
|
||||
"file_size": "Tamaño",
|
||||
"codec": "Códec",
|
||||
"audio": "Audio",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Se combinará audio",
|
||||
"has_audio": "✓ Tiene Audio",
|
||||
"audio_only": "Solo Audio",
|
||||
"best_4k": "Óptima (4K)",
|
||||
"best_2k": "Óptima (2K)",
|
||||
"high_1080p": "Alta (1080p)",
|
||||
"high_720p": "Alta (720p)",
|
||||
"medium_480p": "Media (480p)",
|
||||
"low_quality": "Baja Calidad",
|
||||
"best_audio": "Mejor Audio",
|
||||
"high_audio": "Audio Alto",
|
||||
"medium_audio": "Audio Medio",
|
||||
"low_audio": "Audio Bajo",
|
||||
"audio_only_resolution": "Solo audio",
|
||||
"video_format": "Video Format:",
|
||||
"audio_format": "Audio Format:",
|
||||
"no_formats": "No formats available",
|
||||
"loading": "Loading formats..."
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de YTSage",
|
||||
"version": "Versión {version}",
|
||||
"description": "Descargador moderno de YouTube con una interfaz limpia de PySide6.",
|
||||
"author": "Por: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Información del Sistema",
|
||||
"loading": "🔄 Cargando información del sistema...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Registros",
|
||||
"logs_tooltip": "Abrir carpeta de registros de la aplicación",
|
||||
"refreshing": "🔄 Actualizando...",
|
||||
"refresh_failed": "Actualización Fallida",
|
||||
"refresh_failed_message": "No se pudo actualizar la información de versión.",
|
||||
"detected": "✓ Detectado",
|
||||
"missing": "✗ Faltante",
|
||||
"not_available": "No Disponible"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Descargar Sección del Video",
|
||||
"help_text": "Descarga solo partes específicas de un video especificando rangos de tiempo.\nUsa formato HH:MM:SS o segundos. Deja inicio o fin vacío para descargar desde el principio o hasta el final.",
|
||||
"time_range_group": "Rango de Tiempo",
|
||||
"start_time": "Tiempo de Inicio:",
|
||||
"end_time": "Tiempo de Fin:",
|
||||
"start_time_placeholder": "00:00:00 (o dejar vacío para inicio)",
|
||||
"end_time_placeholder": "00:10:00 (o dejar vacío para fin)",
|
||||
"force_keyframes": "Forzar fotogramas clave en cortes (mejor precisión, más lento)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"invalid_format": "Invalid time format. Use HH:MM:SS format.",
|
||||
"start_after_end": "Start time cannot be after end time."
|
||||
},
|
||||
"update": {
|
||||
"title": "Actualizar yt-dlp",
|
||||
"checking": "Verificando actualizaciones...",
|
||||
"update_available": "¡Actualización disponible!\nVersión actual: {current}\nÚltima versión: {latest}",
|
||||
"up_to_date": "yt-dlp está actualizado (versión {version})",
|
||||
"already_up_to_date": "✅ ¡yt-dlp ya está actualizado!",
|
||||
"could_not_determine": "No se pudieron determinar las versiones.",
|
||||
"error_comparing": "Error al comparar versiones: {error}",
|
||||
"update_available_failed": "¡Actualización disponible! (Comparación fallida)\nActual: {current}\nÚltima: {latest}",
|
||||
"updating": "Actualizando...",
|
||||
"initializing": "🚀 Inicializando proceso de actualización...",
|
||||
"checking_current": "🔍 Verificando instalación actual...",
|
||||
"found_at": "📍 Se encontró yt-dlp en: {path}",
|
||||
"error_getting_path": "❌ Error al obtener la ruta de yt-dlp: {error}",
|
||||
"updating_binary": "📦 Actualizando binario de yt-dlp administrado por la app...",
|
||||
"update_failed": "❌ Falló la actualización de yt-dlp. Inténtalo de nuevo o verifica tu conexión a internet.",
|
||||
"binary_updated": "✅ ¡Binario actualizado exitosamente!",
|
||||
"update_failed_stderr": "❌ Falló la actualización de yt-dlp: {error}",
|
||||
"update_timeout": "❌ Tiempo agotado en la actualización de yt-dlp.",
|
||||
"unexpected_error": "❌ Error inesperado durante la actualización: {error}",
|
||||
"update_success": "✅ ¡yt-dlp se ha actualizado exitosamente!",
|
||||
"already_latest": "yt-dlp está actualizado (versión {version})",
|
||||
"network_error": "❌ Error de red durante la actualización: {error}",
|
||||
"general_error": "❌ La actualización falló: {error}",
|
||||
"update_in_progress_title": "Actualización en curso",
|
||||
"update_in_progress_message": "yt-dlp se está actualizando actualmente. Por favor espere un momento."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración de Descarga",
|
||||
"download_path": "Ruta de Descarga",
|
||||
"browse": "Examinar...",
|
||||
"speed_limit": "Límite de Velocidad",
|
||||
"speed_limit_placeholder": "Ninguno",
|
||||
"generic_mode": "Modo genérico",
|
||||
"enable_generic_mode": "Habilitar modo genérico (compatibilidad con sitios que no son YouTube)",
|
||||
"generic_mode_help": "Permite descargar desde Dailymotion, CBC Gem y otros sitios compatibles con yt-dlp.",
|
||||
"auto_update_ytdlp": "Auto-Actualizar yt-dlp",
|
||||
"enable_auto_updates": "Habilitar actualizaciones automáticas de yt-dlp",
|
||||
"update_frequency": "Frecuencia de actualización:",
|
||||
"check_startup": "Verificar en cada inicio (mínimo 1 hora entre verificaciones)",
|
||||
"check_daily": "Verificar diariamente",
|
||||
"check_weekly": "Verificar semanalmente",
|
||||
"check_updates_now": "Actualizar yt-dlp",
|
||||
"update_check_title": "Verificación de Actualizaciones",
|
||||
"could_not_determine_version": "No se pudo determinar la versión actual de yt-dlp.",
|
||||
"update_available_dialog": "¡Actualización disponible!\n\nActual: {current}\nÚltima: {latest}\n\nHaz clic en Aceptar para continuar con la actualización.",
|
||||
"up_to_date_dialog": "¡yt-dlp está actualizado!\n\nVersión actual: {version}",
|
||||
"error_checking_updates": "Error al verificar actualizaciones: {error}",
|
||||
"settings_saved_title": "Configuración Guardada",
|
||||
"settings_saved_message": "¡La configuración de auto-actualización se ha guardado exitosamente!",
|
||||
"error_title": "Error",
|
||||
"failed_save_settings": "Falló al guardar la configuración de auto-actualización.",
|
||||
"error_saving_settings": "Error al guardar la configuración de auto-actualización: {error}",
|
||||
"ytdlp_channel": "Canal de Lanzamiento de yt-dlp",
|
||||
"ytdlp_channel_stable": "Estable (Versiones probadas)",
|
||||
"ytdlp_channel_nightly": "Nightly (Actualizaciones diarias, recomendado por yt-dlp)",
|
||||
"ytdlp_channel_description": "Elige entre versiones estables y nightly. Las versiones nightly se actualizan diariamente con las últimas correcciones y características. Puedes cambiar de canal en cualquier momento.",
|
||||
"ytdlp_switching_channel": "Cambiando al canal {channel}...",
|
||||
"ytdlp_channel_switched": "✅ ¡Cambiado exitosamente al canal {channel}!",
|
||||
"ytdlp_channel_switch_failed": "❌ Error al cambiar de canal: {error}",
|
||||
"ytdlp_current_channel": "Canal actual: {channel}",
|
||||
"app_updates_title": "Actualizaciones de YTSage",
|
||||
"check_app_updates": "Buscar actualizaciones de YTSage al iniciar",
|
||||
"check_beta_updates": "Recibir actualizaciones beta",
|
||||
"auto_update_title": "Configuración de Auto-Actualización",
|
||||
"auto_update_header": "🔄 Configuración de Auto-Actualización",
|
||||
"auto_update_description": "Configura las actualizaciones automáticas de yt-dlp para asegurar que siempre tengas las últimas funciones y correcciones de errores.",
|
||||
"current_status": "Estado Actual",
|
||||
"current_version_label": "Versión actual de yt-dlp: Verificando...",
|
||||
"last_check_label": "Última verificación de actualización: Nunca",
|
||||
"next_check_label": "Próxima verificación: Basado en configuración",
|
||||
"manual_check_button": "🔍 Verificar Actualizaciones Ahora",
|
||||
"update_frequency_group": "Frecuencia de Actualización",
|
||||
"save_settings": "Guardar Configuración",
|
||||
"settings_saved_successfully": "✅ ¡Configuración guardada exitosamente!",
|
||||
"error_saving": "❌ Error al guardar configuración: {error}",
|
||||
"output_format_settings": "Configuración de Formato de Salida",
|
||||
"force_output_format": "Forzar formato de salida al combinar",
|
||||
"preferred_format": "Formato preferido:",
|
||||
"force_format_help": "Cuando está habilitado, los videos combinados se convertirán a su formato preferido. Deshabilitar para dejar que yt-dlp decida automáticamente.",
|
||||
"format_mp4": "MP4 (Más compatible)",
|
||||
"format_webm": "WebM (Moderno, abierto)",
|
||||
"format_mkv": "MKV (Rico en funciones)",
|
||||
"audio_format_settings": "Configuración de formato de audio",
|
||||
"force_audio_format": "Forzar formato de audio para descargas solo de audio",
|
||||
"audio_normalization": "Normalización de audio (EBU R128)",
|
||||
"audio_normalization_help": "Cuando está activado, las pistas de audio se normalizarán. Nota: esto requiere recodificación, por lo que se debe forzar un formato de audio específico (como MP3 o M4A).",
|
||||
"preferred_audio_format": "Formato de audio preferido:",
|
||||
"force_audio_format_help": "Cuando está habilitado, las descargas solo de audio se convertirán a su formato preferido. Esto solo se aplica al descargar formatos de audio.",
|
||||
"audio_format_best": "Mejor (Sin conversión)",
|
||||
"audio_format_aac": "AAC (Bueno para edición)",
|
||||
"audio_format_mp3": "MP3 (Universal)",
|
||||
"audio_format_flac": "FLAC (Sin pérdida)",
|
||||
"audio_format_wav": "WAV (Sin comprimir)",
|
||||
"audio_format_opus": "Opus (Eficiente)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Abierto)",
|
||||
"filename_format": "Formato de nombre de archivo",
|
||||
"filename_format_help": "Variables disponibles: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. Se admite la sintaxis estándar de plantilla de salida de yt-dlp.",
|
||||
"tab_general": "General",
|
||||
"tab_format": "Formato",
|
||||
"tab_file": "Archivo",
|
||||
"concurrent_fragments": "Conexiones simultáneas",
|
||||
"concurrent_fragments_help": "Número de conexiones por descarga. Los valores más altos evitan la limitación, pero pueden causar bloqueos temporales si se establecen demasiado altos. Predeterminado: 1.",
|
||||
"defaults_settings": "Configuración de selección predeterminada",
|
||||
"default_video_quality": "Resolución de video predeterminada (altura):",
|
||||
"default_subtitle_language": "Idioma(s) de subtítulos predeterminado(s):",
|
||||
"defaults_help": "Establece tu altura de video preferida y los idiomas de los subtítulos (separados por comas). Se seleccionarán automáticamente si están disponibles."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Ingresa URL de video o lista de YouTube",
|
||||
"url_placeholder_generic": "Ingresa la URL de un video o una lista de cualquier sitio compatible",
|
||||
"merge_subtitles": "Combinar Subtítulos",
|
||||
"save_thumbnail": "Guardar Miniatura",
|
||||
"save_description": "Guardar Descripción",
|
||||
"embed_chapters": "Incrustar Capítulos",
|
||||
"subtitles_selected": "{count} seleccionados",
|
||||
"all_selected": "Todos seleccionados",
|
||||
"select_videos_all": "Seleccionar Videos... (Todos seleccionados)",
|
||||
"please_enter_url": "Por favor ingresa primero una URL",
|
||||
"error_title": "Error",
|
||||
"cookie_file_selected_title": "Archivo de Cookies Seleccionado",
|
||||
"cookie_file_selected_message": "Archivo de cookies seleccionado: {path}",
|
||||
"browser_cookies_selected_title": "Cookies de Navegador Seleccionadas",
|
||||
"browser_cookies_selected_message": "Las cookies del navegador serán extraídas de: {browser}",
|
||||
"error_no_format_info": "Error: No hay información de formato disponible.",
|
||||
"error_extract_info": "Error: No se pudo extraer la información básica del video. Por favor verifica tu enlace.",
|
||||
"analyzing_preparing": "Preparando solicitud...",
|
||||
"analyzing_extracting_basic": "Extrayendo información básica...",
|
||||
"analyzing_extracting_detailed": "Extrayendo información detallada...",
|
||||
"analyzing_processing_video": "Procesando datos de video...",
|
||||
"analyzing_processing_formats": "Procesando formatos...",
|
||||
"analyzing_loading_thumbnail": "Cargando miniatura...",
|
||||
"analyzing_processing_subtitles": "Procesando subtítulos...",
|
||||
"analyzing_updating_table": "Actualizando tabla de formatos...",
|
||||
"analysis_complete": "¡Análisis completo!",
|
||||
"analyzing_extracting_ytdlp": "Extrayendo información...",
|
||||
"analyzing_fetching_first_video": "Obteniendo formatos del primer video...",
|
||||
"analyzing_processing_data": "Procesando datos...",
|
||||
"analyzing_processing_formats_ytdlp": "Procesando formatos...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Cargando miniatura...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Procesando subtítulos...",
|
||||
"select_subtitles": "Seleccionar Subtítulos...",
|
||||
"sponsorblock_categories": "Categorías SponsorBlock...",
|
||||
"invalid_url_or_enter": "URL inválida o por favor ingresa una URL.",
|
||||
"zero_selected": "0 seleccionados",
|
||||
"analyze_first_tooltip": "Por favor analiza el video primero",
|
||||
"audio_mode_disabled": "No disponible en modo solo audio",
|
||||
"select_subtitles_first": "Por favor selecciona subtítulos primero",
|
||||
"settings_tooltip": "Ruta actual: {path}\nLímite de velocidad: {speed_limit}",
|
||||
"speed_limit_none": "Ninguno",
|
||||
"open_folder_error": "No se pudo abrir la carpeta: {error}",
|
||||
"time_range_set": "Sección establecida: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Patrocinador",
|
||||
"sponsor_desc": "Promoción pagada, referencias pagadas y anuncios directos",
|
||||
"selfpromo": "Autopromoción No Pagada",
|
||||
"selfpromo_desc": "Promoción no pagada del propio contenido del creador",
|
||||
"interaction": "Recordatorio de Interacción",
|
||||
"interaction_desc": "Pedir a los espectadores que den like, se suscriban o sigan en redes sociales",
|
||||
"intro": "Introducción",
|
||||
"intro_desc": "Introducción del video que se puede omitir",
|
||||
"outro": "Outro/Tarjetas Finales",
|
||||
"outro_desc": "Créditos o cuando termina el video",
|
||||
"preview": "Vista Previa/Resumen",
|
||||
"preview_desc": "Resumen rápido de videos anteriores o vista previa de lo que viene",
|
||||
"music_offtopic": "Sección No Musical",
|
||||
"music_offtopic_desc": "Solo para videos musicales. Marca secciones no musicales",
|
||||
"filler": "Relleno/Tangente",
|
||||
"filler_desc": "Escenas tangenciales agregadas solo como relleno o humor"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Canal",
|
||||
"views": "👁 Visualizaciones",
|
||||
"likes": "👍 Me gusta",
|
||||
"upload_date": "📅 Fecha de subida",
|
||||
"duration": "⏱ Duración",
|
||||
"unknown_channel": "Canal desconocido",
|
||||
"unknown_date": "Fecha desconocida",
|
||||
"unknown_title": "Título desconocido"
|
||||
},
|
||||
"command": {
|
||||
"running": "Ejecutando...",
|
||||
"run_command": "Ejecutar Comando"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 seleccionados",
|
||||
"one_selected": "1 categoría seleccionada",
|
||||
"count_selected": "{count} seleccionados"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Listo",
|
||||
"file_exists": "⚠️ El archivo ya existe",
|
||||
"video_file_exists": "⚠️ El archivo de video ya existe",
|
||||
"audio_file_exists": "⚠️ El archivo de audio ya existe",
|
||||
"subtitle_file_exists": "⚠️ El archivo de subtítulos ya existe",
|
||||
"cancelling": "Cancelando descarga...",
|
||||
"thumbnail_saved": "✅ Miniatura guardada: {filename}",
|
||||
"thumbnail_error": "❌ Error de miniatura: {error}",
|
||||
"thumbnail_no_image": "No hay miniatura disponible para guardar"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Error: La lista de reproducción no contiene videos válidos.",
|
||||
"playlist_no_url": "Error: No se pudo obtener la URL del primer video de la lista.",
|
||||
"ytdlp_not_found": "Error: Ejecutable de yt-dlp no encontrado. Por favor instala yt-dlp primero.",
|
||||
"ytdlp_not_found_path": "Error: Ejecutable de yt-dlp no encontrado. Esto podría deberse a una instalación incorrecta o un problema de PATH.",
|
||||
"no_data_returned": "Error: yt-dlp no devolvió datos",
|
||||
"no_format_info": "Error: No hay información de formato disponible.",
|
||||
"analysis_timeout": "Error: Se agotó el tiempo de análisis. Por favor inténtalo de nuevo.",
|
||||
"invalid_speed_limit": "❌ Error: Valor de límite de velocidad inválido en configuración.",
|
||||
"ytdlp_failed": "Error: yt-dlp falló: {error}",
|
||||
"parse_failed": "Error: Falló al analizar salida de yt-dlp: {error}",
|
||||
"analysis_failed": "Error: Análisis falló: {error}",
|
||||
"generic_error": "Error: {error}",
|
||||
"download_failed_return_code_conflict": "La descarga falló con el código de salida {return_code}. Esto puede deberse a un conflicto con varias instalaciones de yt-dlp. Intenta desinstalar cualquier yt-dlp instalado en el sistema (p. ej., mediante snap o apt) y reinicia la aplicación.",
|
||||
"download_failed_return_code": "La descarga falló con el código de salida {return_code}",
|
||||
"direct_command_error": "Error en el comando directo: {error}",
|
||||
"private_video": "Este video podría ser privado. Por favor, utiliza cookies desde las opciones personalizadas."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Actualización disponible",
|
||||
"new_version_available": "¡Una nueva versión de YTSage está disponible!",
|
||||
"current_version_label": "Versión actual:",
|
||||
"latest_version_label": "Última versión:",
|
||||
"changelog": "Registro de cambios",
|
||||
"download_update": "Descargar Actualización",
|
||||
"remind_later": "Recordar Más Tarde"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Lista de reproducción desconocida",
|
||||
"total_videos": "Total de Videos: {count}",
|
||||
"display_format": "Lista de reproducción: {title} | {count} videos",
|
||||
"select_videos_title": "Seleccionar Videos de la Lista de Reproducción",
|
||||
"save_as": "Guardar playlist como",
|
||||
"save_success_title": "Éxito",
|
||||
"saved_successfully": "Playlist guardada correctamente.",
|
||||
"save_error_title": "Error al guardar",
|
||||
"no_videos_to_save": "¡No se han recopilado entradas de la lista de reproducción!",
|
||||
"save_error_msg": "Error al guardar la lista de reproducción."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} seleccionados"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "El archivo ya existe",
|
||||
"message": "El archivo ya existe:\n{filename}",
|
||||
"info": "Este video ya ha sido descargado."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Última verificación: Nunca",
|
||||
"last_check": "Última verificación: {time}",
|
||||
"next_check_disabled": "Próxima verificación: Deshabilitada",
|
||||
"next_check_startup": "Próxima verificación: Al iniciar",
|
||||
"next_check_overdue": "Próxima verificación: Ahora (atrasada)",
|
||||
"next_check": "Próxima verificación: {time}",
|
||||
"next_check_error": "Próxima verificación: Error al calcular",
|
||||
"checking": "🔄 Verificando...",
|
||||
"check_now": "🔍 Verificar actualizaciones ahora",
|
||||
"current_version": "Versión actual de yt-dlp: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "La URL no puede estar vacía",
|
||||
"invalid_format": "Formato de URL no válido",
|
||||
"invalid_scheme": "La URL debe comenzar con http:// o https://",
|
||||
"missing_domain": "URL no válida: falta el nombre de dominio",
|
||||
"unsupported_platform": "YTSage solo admite URLs de YouTube y YouTube Music.\nEl dominio '{domain}' no es compatible.",
|
||||
"invalid_youtu_be": "URL de youtu.be no válida: falta el ID del video"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Este es un video privado. Puede descargarlo iniciando sesión en su cuenta usando cookies.\nVaya a 'Opciones Personalizadas' → 'Iniciar sesión con Cookies' → 'Extraer cookies del navegador' para autenticarse.",
|
||||
"age_restricted": "Este video tiene restricción de edad. Debe iniciar sesión para acceder a él.\nUse 'Opciones Personalizadas' → 'Iniciar sesión con Cookies' para autenticarse con su cuenta.",
|
||||
"geo_blocked": "Este video no está disponible en su región (bloqueado geográficamente).\nPuede necesitar usar una VPN o el video puede estar restringido en su país.",
|
||||
"video_unavailable": "Este video ha sido eliminado o ya no está disponible.\nEl video puede haber sido eliminado por el usuario que lo subió o eliminado debido a violaciones de políticas.",
|
||||
"live_stream": "Esta es una transmisión en vivo que no se puede descargar mientras está activa.\nEspere a que termine la transmisión y luego intente descargar la versión archivada.",
|
||||
"playlist_error": "No se puede acceder a esta lista de reproducción. Puede ser privada, estar eliminada o vacía.\nVerifique si la lista de reproducción existe y es públicamente accesible.",
|
||||
"network_error": "Error de conexión de red. Verifique su conexión a Internet e intente nuevamente.\nSi el problema persiste, el servidor de video puede no estar disponible temporalmente.",
|
||||
"invalid_url": "URL no válida o no compatible. Verifique el enlace e intente nuevamente.\nAsegúrese de usar una URL válida de YouTube, Vimeo u otra plataforma compatible.",
|
||||
"premium_content": "Este contenido requiere YouTube Premium o membresía del canal.\nDebe iniciar sesión con una cuenta que tenga acceso a este contenido.",
|
||||
"copyright_blocked": "Este video está bloqueado debido a reclamaciones de derechos de autor.\nEl propietario del contenido ha restringido el acceso a este video.",
|
||||
"extraction_failed": "Error al extraer información del video. Esto podría ser un problema temporal.\nIntente nuevamente en unos minutos o verifique si el enlace del video es correcto.",
|
||||
"generic_error": "No se pudo extraer la información del video. Verifique su enlace.\nDetalles técnicos: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Historial de Descargas",
|
||||
"clear_all": "Borrar Todo",
|
||||
"clear_confirm_title": "¿Borrar Historial?",
|
||||
"clear_confirm_message": "¿Estás seguro de que quieres borrar todo el historial? Esto no se puede deshacer.",
|
||||
"no_history": "Aún no hay historial",
|
||||
"no_history_description": "Tus descargas aparecerán aquí",
|
||||
"loading": "Cargando historial...",
|
||||
"search_placeholder": "Buscar...",
|
||||
"open_location": "Abrir Ubicación",
|
||||
"redownload": "Descargar de Nuevo",
|
||||
"remove": "Eliminar",
|
||||
"file_not_found": "Archivo no encontrado",
|
||||
"file_not_found_message": "El archivo fue movido o eliminado:\n{path}",
|
||||
"downloaded_on": "Descargado: {date}",
|
||||
"file_size": "Tamaño: {size}",
|
||||
"audio_download": "Audio",
|
||||
"video_download": "Video",
|
||||
"remove_confirm_title": "¿Eliminar?",
|
||||
"remove_confirm_message": "¿Eliminar del historial?\n\n{title}",
|
||||
"item_removed": "Eliminado",
|
||||
"history_cleared": "Historial borrado",
|
||||
"entries_count": "{count} descargas",
|
||||
"one_entry": "1 descarga",
|
||||
"redownload_confirm_title": "¿Descargar de Nuevo?",
|
||||
"redownload_confirm_message": "¿Descargar nuevamente?\n\n{title}",
|
||||
"redownload_started": "Descarga iniciada",
|
||||
"no_url_error": "No se encontró URL en la entrada del historial",
|
||||
"redownload_failed": "No se pudo iniciar la re-descarga: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "Instalación de FFmpeg",
|
||||
"installation_message": "YTSage necesita FFmpeg para procesar videos.\n\nElige una opción de instalación:",
|
||||
"install_button": "Instalar FFmpeg",
|
||||
"manual_guide": "Guía manual",
|
||||
"installation_failed": "La instalación de FFmpeg encontró un problema.",
|
||||
"already_installed": "¡FFmpeg ya está instalado!",
|
||||
"installation_complete": "Instalación completa. Puedes cerrar este diálogo y seguir usando YTSage.",
|
||||
"installing": "Instalando FFmpeg... Por favor espera",
|
||||
"install_success": "¡FFmpeg se instaló correctamente!",
|
||||
"installation_complete_close": "Instalación completa. Ahora puedes cerrar este diálogo y seguir usando YTSage.",
|
||||
"try_manual": "Intenta usar la guía de instalación manual en su lugar."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "Se requiere configurar yt-dlp",
|
||||
"description": "YTSage requiere yt-dlp para descargar videos.<br><br>No se encontró yt-dlp en el directorio local de la app. YTSage necesita configurar yt-dlp para tu sistema {os_name}.<br><br>Elige una opción:",
|
||||
"option_auto": "Descargar automáticamente (recomendado)",
|
||||
"option_manual": "Seleccionar ruta manualmente",
|
||||
"setup_button": "Configurar yt-dlp",
|
||||
"downloading": "Descargando yt-dlp...",
|
||||
"success": "¡yt-dlp se instaló correctamente!",
|
||||
"error": "Error: {error}",
|
||||
"download_failed_title": "Descarga fallida",
|
||||
"download_failed_message": "No se pudo descargar yt-dlp: {error}",
|
||||
"select_executable_title": "Seleccionar ejecutable de yt-dlp",
|
||||
"copied_to": "yt-dlp se copió correctamente a {path}",
|
||||
"setup_error_title": "Error de configuración",
|
||||
"copy_error": "Error al copiar yt-dlp al directorio de la app: {error}",
|
||||
"invalid_executable_title": "Ejecutable inválido",
|
||||
"invalid_executable_message": "El archivo seleccionado no parece ser un ejecutable válido de yt-dlp.",
|
||||
"verify_error": "Error al verificar el ejecutable de yt-dlp: {error}",
|
||||
"setup_failed_title": "Configuración fallida",
|
||||
"setup_failed_message": "No se pudo configurar yt-dlp. Algunas funciones pueden no funcionar correctamente.",
|
||||
"success_dialog_title": "Configuración de yt-dlp",
|
||||
"success_dialog_message": "yt-dlp se configuró correctamente en:\n{path}",
|
||||
"file_filter_windows": "Archivos ejecutables (*.exe)",
|
||||
"file_filter_all": "Todos los archivos (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "Comprobador de Versión FFmpeg",
|
||||
"current_version": "Versión Actual:",
|
||||
"latest_version": "Última Versión:",
|
||||
"status_up_to_date": "✓ Actualizado",
|
||||
"status_update_available": "⚠ Actualización disponible",
|
||||
"status_not_installed": "✗ No instalado",
|
||||
"status_idle": "Haga clic en 'Comprobar Versión' para comenzar",
|
||||
"status_checking": "🔄 Verificando versión...",
|
||||
"check_updates": "Comprobar Versión",
|
||||
"check_failed": "Error al verificar la versión. Por favor, verifique su conexión a internet.",
|
||||
"description": "Compruebe su versión de FFmpeg y compárela con la última versión disponible.",
|
||||
"guide_info": " Para instalar o actualizar FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>haga clic aquí para ver nuestra guía completa de instalación</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Configuración de Deno requerida",
|
||||
"setup_description": "YTSage requiere Deno para ejecutar ciertas funciones.<br><br>No se encontró Deno en el directorio local de la aplicación. YTSage necesita configurar Deno para su sistema {os_name}.<br><br>Haga clic en 'Configurar Deno' para descargar e instalar automáticamente.",
|
||||
"setup_button": "Configurar Deno",
|
||||
"downloading": "Descargando Deno...",
|
||||
"extracting": "Extrayendo Deno...",
|
||||
"verifying": "Verificando Deno...",
|
||||
"success": "¡Deno se instaló correctamente!",
|
||||
"download_failed": "Descarga fallida",
|
||||
"download_error": "Error al descargar Deno: {error}",
|
||||
"verification_failed": "Error en la verificación SHA256. El archivo descargado puede estar dañado o alterado.",
|
||||
"setup_failed": "Error al configurar Deno. Es posible que algunas funciones no funcionen correctamente.",
|
||||
"setup_error": "Error de configuración"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Verificador y Actualizador de Versión de Deno",
|
||||
"description": "Verifique su versión de Deno y actualice a la última versión.",
|
||||
"current_version": "Versión Actual:",
|
||||
"latest_version": "Última Versión:",
|
||||
"status_idle": "Haga clic en 'Buscar Actualizaciones' para comenzar",
|
||||
"status_checking": "🔄 Buscando actualizaciones...",
|
||||
"status_up_to_date": "✓ Actualizado",
|
||||
"status_update_available": "⚠ Actualización disponible",
|
||||
"status_not_installed": "✗ No instalado",
|
||||
"check_updates": "Buscar Actualizaciones",
|
||||
"update_now": "Actualizar Deno",
|
||||
"updating": "🔄 Actualizando Deno...",
|
||||
"update_success": "✅ ¡Deno se ha actualizado correctamente!",
|
||||
"update_failed": "❌ Error en la actualización: {error}",
|
||||
"check_failed": "Error al buscar actualizaciones. Por favor, verifique su conexión a Internet."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Français (French)",
|
||||
"select_language": "Sélectionner la langue :",
|
||||
"current_language": "Langue actuelle : {language}",
|
||||
"restart_required": "Les modifications de langue prendront effet après le redémarrage de l'application.",
|
||||
"english": "Anglais",
|
||||
"spanish": "Espagnol",
|
||||
"portuguese": "Portugais",
|
||||
"russian": "Russe",
|
||||
"chinese": "Chinois",
|
||||
"german": "Allemand",
|
||||
"french": "Français",
|
||||
"hindi": "Hindi",
|
||||
"indonesian": "Indonésien",
|
||||
"turkish": "Turc",
|
||||
"polish": "Polonais",
|
||||
"italian": "Italien",
|
||||
"arabic": "Arabe",
|
||||
"japanese": "Japonais",
|
||||
"help_text": "Sélectionnez votre langue préférée pour l'interface.",
|
||||
"restart_notice": "Le changement de langue prendra effet après le redémarrage de l'application."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Prêt"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Afficher les formats :",
|
||||
"video_format": "Format vidéo :",
|
||||
"audio_format": "Format audio :",
|
||||
"no_formats": "Aucun format disponible",
|
||||
"loading": "Chargement des formats...",
|
||||
"select": "Sélectionner",
|
||||
"quality": "Qualité",
|
||||
"extension": "Extension",
|
||||
"resolution": "Résolution",
|
||||
"file_size": "Taille du fichier",
|
||||
"codec": "Codec",
|
||||
"audio": "Audio",
|
||||
"fps": "IPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "L'audio sera fusionné",
|
||||
"has_audio": "✓ Contient de l'audio",
|
||||
"audio_only": "Audio uniquement",
|
||||
"best_4k": "Meilleure (4K)",
|
||||
"best_2k": "Meilleure (2K)",
|
||||
"high_1080p": "Haute (1080p)",
|
||||
"high_720p": "Haute (720p)",
|
||||
"medium_480p": "Moyenne (480p)",
|
||||
"low_quality": "Qualité faible",
|
||||
"best_audio": "Meilleur audio",
|
||||
"high_audio": "Audio élevé",
|
||||
"medium_audio": "Audio moyen",
|
||||
"low_audio": "Audio faible",
|
||||
"audio_only_resolution": "Audio uniquement"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Réinitialiser",
|
||||
"download": "Télécharger",
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre",
|
||||
"cancel": "Annuler",
|
||||
"browse": "Parcourir",
|
||||
"clear": "Effacer",
|
||||
"ok": "OK",
|
||||
"apply": "Appliquer",
|
||||
"close": "Fermer",
|
||||
"run_command": "Exécuter la commande",
|
||||
"custom_command_help": "Aide",
|
||||
"about": "À propos",
|
||||
"analyze": "Analyser",
|
||||
"paste_url": "Coller l'URL",
|
||||
"select_videos": "Sélectionner les vidéos...",
|
||||
"video": "Vidéo",
|
||||
"audio_only": "Audio uniquement",
|
||||
"custom_options": "Options personnalisées",
|
||||
"trim_video": "Découper la vidéo",
|
||||
"download_settings": "Paramètres de téléchargement",
|
||||
"update": "Mettre à jour yt-dlp",
|
||||
"select_defaults": "Sélectionner par défaut",
|
||||
"select_all": "Tout sélectionner",
|
||||
"deselect_all": "Tout désélectionner",
|
||||
"open_folder": "Ouvrir l'emplacement du dossier",
|
||||
"history": "Historique",
|
||||
"save_playlist": "Enregistrer la playlist sous"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Options personnalisées",
|
||||
"settings": "Paramètres",
|
||||
"select_folder": "Sélectionner le dossier de téléchargement",
|
||||
"sponsorblock_categories": "Catégories SponsorBlock",
|
||||
"sponsorblock_description": "Choisissez les types de segments vidéo à supprimer automatiquement pendant le téléchargement.\nSponsorBlock utilise des données soumises par la communauté pour identifier ces segments.",
|
||||
"select_subtitles": "Sélectionner les sous-titres",
|
||||
"filter_languages_placeholder": "Filtrer les langues (ex: en, fr)...",
|
||||
"no_subtitles_available": "Aucun sous-titre disponible",
|
||||
"matching": "correspondant",
|
||||
"ytdlp_log_title": "Journal yt-dlp",
|
||||
"filter_playlist_placeholder": "Filtrer les vidéos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Se connecter avec des cookies",
|
||||
"custom_command": "Commande personnalisée",
|
||||
"proxy": "Proxy",
|
||||
"language": "Langue",
|
||||
"updater": "Mise à jour"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Choisissez comment fournir les cookies pour l'authentification.\nCeci permet de télécharger des vidéos privées et des fichiers audio de haute qualité.",
|
||||
"cookie_source": "Source des cookies",
|
||||
"use_cookie_file": "Utiliser un fichier de cookies",
|
||||
"extract_from_browser": "Extraire du navigateur",
|
||||
"recommended": "Recommandé",
|
||||
"cookie_file": "Fichier de cookies",
|
||||
"cookie_file_placeholder": "Chemin vers le fichier cookies.txt...",
|
||||
"browser_selection": "Sélection du navigateur",
|
||||
"browser_help": "Sélectionnez le navigateur depuis lequel extraire les cookies :",
|
||||
"browser_label": "Navigateur :",
|
||||
"profile_label": "Profil :",
|
||||
"profile_placeholder": "Par défaut",
|
||||
"browser_extract_message": "Les cookies du navigateur seront extraits lors de l'application",
|
||||
"file_selected_message": "Fichier de cookies sélectionné - Cliquez sur OK pour appliquer",
|
||||
"select_file_title": "Sélectionner le fichier de cookies",
|
||||
"file_filter": "Fichiers de cookies (*.txt *.lwp)",
|
||||
"file_selected_title": "Fichier de cookies appliqué",
|
||||
"file_applied_message": "Fichier de cookies appliqué : {path}",
|
||||
"browser_selected_title": "Cookies du navigateur appliqués",
|
||||
"browser_applied_message": "Les cookies du navigateur seront extraits de : {browser}",
|
||||
"cleared_title": "Cookies effacés",
|
||||
"cleared_message": "Les paramètres des cookies ont été effacés",
|
||||
"active_browser": "✓ Actifs : cookies du navigateur ({browser})",
|
||||
"active_file": "✓ Actif : fichier cookies ({file})",
|
||||
"none_active": "○ Aucun cookie actif",
|
||||
"remember_settings": "Mémoriser les paramètres des cookies au prochain démarrage"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Entrez votre commande yt-dlp personnalisée ci-dessous. L'URL actuelle sera automatiquement ajoutée.<br><br>Pour la liste complète des options et exemples d'utilisation <a href=\"{docs_url}\">cliquez ici pour voir la documentation officielle yt-dlp</a>.<br><br>Note : Le chemin de téléchargement et le modèle de nom de fichier sont gérés automatiquement.",
|
||||
"input_label": "Arguments yt-dlp :",
|
||||
"input_placeholder": "Entrez les arguments yt-dlp ici...\n\nEx : --extract-audio --audio-format mp3",
|
||||
"output_label": "Sortie de la commande :",
|
||||
"output_placeholder": "La sortie de la commande apparaîtra ici...",
|
||||
"command_placeholder": "Entrer une commande yt-dlp personnalisée...",
|
||||
"command_help": "Espaces réservés disponibles :\n{url} - URL de la vidéo\n{output} - Dossier de sortie\n\nExemple : --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Commande complète : {command}",
|
||||
"command_success": "✅ Commande personnalisée exécutée avec succès !",
|
||||
"command_failed": "❌ Commande échouée avec le code de sortie {code}",
|
||||
"command_error": "❌ Erreur lors de l'exécution de la commande personnalisée : {error}",
|
||||
"error_no_url": "❌ Erreur : aucune URL fournie. Veuillez saisir une URL dans la fenêtre principale.",
|
||||
"error_no_command": "❌ Erreur : aucune commande fournie. Veuillez saisir des arguments yt-dlp.",
|
||||
"executing": "🚀 Exécution de la commande yt-dlp personnalisée",
|
||||
"url_label": "📍 URL : {url}",
|
||||
"args_label": "⚙️ Arguments : {command}",
|
||||
"download_path_label": "📁 Chemin de téléchargement : {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Configurer les paramètres proxy pour les téléchargements. Laisser vide pour une connexion directe.",
|
||||
"main_proxy": "Proxy principal",
|
||||
"main_proxy_help": "Serveur proxy principal pour tous les téléchargements. Prend en charge les protocoles HTTP/HTTPS et SOCKS5.",
|
||||
"proxy_url_label": "URL du proxy :",
|
||||
"proxy_url_placeholder": "http://proxy:port ou socks5://proxy:port",
|
||||
"proxy_examples": "Exemples :\n• HTTP : http://proxy.example.com:8080\n• SOCKS5 : socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Proxy de contournement géographique",
|
||||
"geo_proxy_help": "Proxy secondaire spécialement pour contourner les restrictions géographiques.",
|
||||
"geo_proxy_url_label": "URL du proxy de contournement géographique :",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "Effacer le proxy principal",
|
||||
"clear_geo_proxy": "Effacer le proxy géographique",
|
||||
"proxy_url": "URL du proxy",
|
||||
"proxy_placeholder": "http://proxy:port ou socks5://proxy:port",
|
||||
"geo_bypass": "Proxy de contournement géographique (pour les restrictions géographiques)",
|
||||
"geo_bypass_placeholder": "http://proxy:port pour contourner le géo-blocage",
|
||||
"invalid_main_url": "Format d'URL de proxy principal invalide",
|
||||
"invalid_geo_url": "Format d'URL de proxy géographique invalide",
|
||||
"main_configured": "Proxy principal configuré",
|
||||
"geo_configured": "Proxy géographique configuré",
|
||||
"set_title": "Proxy défini",
|
||||
"set_message": "Proxy principal défini et enregistré : {proxy}",
|
||||
"geo_set_title": "Proxy géo défini",
|
||||
"geo_set_message": "Proxy de vérification géographique défini et enregistré : {proxy}",
|
||||
"cleared_title": "Paramètres proxy effacés",
|
||||
"cleared_message": "Tous les paramètres proxy ont été effacés et enregistrés.",
|
||||
"saved_main": "Proxy principal enregistré : {proxy}",
|
||||
"saved_geo": "Proxy géo enregistré : {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Préparation du téléchargement...",
|
||||
"starting": "🚀 Démarrage du téléchargement...",
|
||||
"fetching_info": "🔍 Récupération des informations vidéo...",
|
||||
"preparing_streams": "🎯 Préparation des flux vidéo...",
|
||||
"downloading_audio": "⏬ Téléchargement audio...",
|
||||
"downloading_video": "⏬ Téléchargement vidéo...",
|
||||
"downloading_subtitle": "⏬ Téléchargement des sous-titres...",
|
||||
"downloading": "⏬ Téléchargement...",
|
||||
"completed": "✅ Téléchargement terminé !",
|
||||
"video_completed": "✅ Téléchargement vidéo terminé !",
|
||||
"audio_completed": "✅ Téléchargement audio terminé !",
|
||||
"subtitle_completed": "✅ Téléchargement des sous-titres terminé !",
|
||||
"completed_cleaning": "✅ Téléchargement terminé ! Nettoyage...",
|
||||
"cancelled": "Téléchargement annulé",
|
||||
"processing_playlist": "📋 Traitement des données de la playlist...",
|
||||
"paused": "Téléchargement en pause",
|
||||
"resumed": "Téléchargement repris",
|
||||
"merging_formats": "✨ Post-traitement : Fusion des formats...",
|
||||
"removing_sponsor_segments": "✨ Post-traitement : Suppression des segments sponsorisés...",
|
||||
"speed": "Vitesse",
|
||||
"eta": "Temps restant",
|
||||
"please_enter_url": "Veuillez d'abord entrer une URL.",
|
||||
"please_set_path": "Veuillez d'abord définir un chemin de téléchargement.",
|
||||
"please_enter_url_and_path": "Veuillez entrer une URL et définir un chemin de téléchargement.",
|
||||
"please_select_format": "Veuillez d'abord sélectionner un format.",
|
||||
"downloading_fallback": "⚡ Téléchargement..."
|
||||
},
|
||||
"update": {
|
||||
"title": "Mettre à jour yt-dlp",
|
||||
"checking": "Vérification des mises à jour...",
|
||||
"update_available": "Mise à jour disponible !\nVersion actuelle : {current}\nDernière version : {latest}",
|
||||
"up_to_date": "yt-dlp est à jour (Version {version})",
|
||||
"could_not_determine": "Impossible de déterminer les versions.",
|
||||
"error_comparing": "Erreur lors de la comparaison des versions : {error}",
|
||||
"update_available_failed": "Mise à jour disponible ! (Comparaison échouée)\nActuelle : {current}\nDernière : {latest}",
|
||||
"updating": "Mise à jour...",
|
||||
"initializing": "🚀 Initialisation du processus de mise à jour...",
|
||||
"checking_current": "🔍 Vérification de l'installation actuelle...",
|
||||
"found_at": "📍 yt-dlp trouvé à : {path}",
|
||||
"error_getting_path": "❌ Erreur lors de la récupération du chemin yt-dlp : {error}",
|
||||
"updating_binary": "📦 Mise à jour du binaire yt-dlp géré par l'application...",
|
||||
"update_failed": "❌ Échec de la mise à jour yt-dlp. Veuillez réessayer ou vérifier votre connexion Internet.",
|
||||
"binary_updated": "✅ Binaire mis à jour avec succès !",
|
||||
"update_failed_stderr": "❌ Échec de la mise à jour yt-dlp : {error}",
|
||||
"update_timeout": "❌ Délai d'attente de la mise à jour yt-dlp dépassé.",
|
||||
"unexpected_error": "❌ Erreur inattendue lors de la mise à jour : {error}",
|
||||
"already_up_to_date": "✅ yt-dlp est déjà à jour !",
|
||||
"update_success": "✅ yt-dlp a été mis à jour avec succès !",
|
||||
"already_latest": "yt-dlp est à jour (version {version})",
|
||||
"network_error": "❌ Erreur réseau pendant la mise à jour : {error}",
|
||||
"general_error": "❌ La mise à jour a échoué : {error}",
|
||||
"update_in_progress_title": "Mise à jour en cours",
|
||||
"update_in_progress_message": "yt-dlp est en cours de mise à jour. Veuillez patienter un instant."
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos de YTSage",
|
||||
"version": "Version {version}",
|
||||
"description": "Téléchargeur YouTube moderne avec une interface PySide6 propre.",
|
||||
"author": "Par : {author}",
|
||||
"github": "GitHub : {repo}",
|
||||
"system_info": "Informations système",
|
||||
"loading": "🔄 Chargement des informations système...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Journaux",
|
||||
"logs_tooltip": "Ouvrir le dossier des journaux d'application",
|
||||
"refreshing": "🔄 Actualisation...",
|
||||
"refresh_failed": "Échec de l'actualisation",
|
||||
"refresh_failed_message": "Impossible d'actualiser les informations de version.",
|
||||
"detected": "✓ Détecté",
|
||||
"missing": "✗ Manquant",
|
||||
"not_available": "Non disponible"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Découper la vidéo",
|
||||
"time_range_group": "Plage horaire",
|
||||
"start_time": "Heure de début (HH:MM:SS)",
|
||||
"end_time": "Heure de fin (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Heure de début (HH:MM:SS)",
|
||||
"end_time_placeholder": "Heure de fin (HH:MM:SS)",
|
||||
"force_keyframes": "Forcer les images clés aux points de coupe",
|
||||
"help_text": "Définissez les heures de début et de fin pour découper la vidéo.\nLaissez vide pour télécharger la vidéo complète.",
|
||||
"invalid_format": "Format d'heure invalide. Utilisez le format HH:MM:SS.",
|
||||
"start_after_end": "L'heure de début ne peut pas être après l'heure de fin."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres de téléchargement",
|
||||
"download_path": "Chemin de téléchargement",
|
||||
"browse": "Parcourir...",
|
||||
"speed_limit": "Limite de vitesse",
|
||||
"speed_limit_placeholder": "Aucune",
|
||||
"generic_mode": "Mode générique",
|
||||
"enable_generic_mode": "Activer le mode générique (prise en charge des sites non-YouTube)",
|
||||
"generic_mode_help": "Permet le téléchargement depuis Dailymotion, CBC Gem et d'autres sites pris en charge par yt-dlp.",
|
||||
"auto_update_ytdlp": "Mise à jour automatique de yt-dlp",
|
||||
"enable_auto_updates": "Activer les mises à jour automatiques de yt-dlp",
|
||||
"update_frequency": "Fréquence de mise à jour :",
|
||||
"check_startup": "Vérifier à chaque démarrage (minimum 1 heure entre les vérifications)",
|
||||
"check_daily": "Vérifier quotidiennement",
|
||||
"check_weekly": "Vérifier hebdomadairement",
|
||||
"check_updates_now": "Mettre à jour yt-dlp",
|
||||
"update_check_title": "Vérification de mise à jour",
|
||||
"could_not_determine_version": "Impossible de déterminer la version actuelle de yt-dlp.",
|
||||
"update_available_dialog": "Mise à jour disponible !\n\nActuelle : {current}\nDernière : {latest}\n\nCliquez sur OK pour continuer la mise à jour.",
|
||||
"up_to_date_dialog": "yt-dlp est à jour !\n\nVersion actuelle : {version}",
|
||||
"error_checking_updates": "Erreur lors de la vérification des mises à jour : {error}",
|
||||
"settings_saved_title": "Paramètres sauvegardés",
|
||||
"settings_saved_message": "Les paramètres de mise à jour automatique ont été sauvegardés avec succès !",
|
||||
"error_title": "Erreur",
|
||||
"failed_save_settings": "Échec de la sauvegarde des paramètres de mise à jour automatique.",
|
||||
"error_saving_settings": "Erreur lors de la sauvegarde des paramètres de mise à jour automatique : {error}",
|
||||
"ytdlp_channel": "Canal de version yt-dlp",
|
||||
"ytdlp_channel_stable": "Stable (Versions testées)",
|
||||
"ytdlp_channel_nightly": "Nightly (Mises à jour quotidiennes, recommandé par yt-dlp)",
|
||||
"ytdlp_channel_description": "Choisissez entre les versions stables et nightly. Les versions nightly sont mises à jour quotidiennement avec les dernières corrections et fonctionnalités. Vous pouvez changer de canal à tout moment.",
|
||||
"ytdlp_switching_channel": "Passage au canal {channel}...",
|
||||
"ytdlp_channel_switched": "✅ Passage réussi au canal {channel} !",
|
||||
"ytdlp_channel_switch_failed": "❌ Échec du changement de canal : {error}",
|
||||
"ytdlp_current_channel": "Canal actuel : {channel}",
|
||||
"app_updates_title": "Mises à jour YTSage",
|
||||
"check_app_updates": "Vérifier les mises à jour YTSage au démarrage",
|
||||
"check_beta_updates": "Recevoir les mises à jour bêta",
|
||||
"auto_update_title": "Paramètres de mise à jour automatique",
|
||||
"auto_update_header": "🔄 Paramètres de mise à jour automatique",
|
||||
"auto_update_description": "Configurez les mises à jour automatiques pour yt-dlp afin de vous assurer d'avoir toujours les dernières fonctionnalités et corrections de bogues.",
|
||||
"current_status": "État actuel",
|
||||
"current_version_label": "Version actuelle de yt-dlp : Vérification...",
|
||||
"last_check_label": "Dernière vérification de mise à jour : Jamais",
|
||||
"next_check_label": "Prochaine vérification : Basée sur les paramètres",
|
||||
"manual_check_button": "🔍 Vérifier les mises à jour maintenant",
|
||||
"update_frequency_group": "Fréquence de mise à jour",
|
||||
"save_settings": "Sauvegarder les paramètres",
|
||||
"settings_saved_successfully": "✅ Paramètres sauvegardés avec succès !",
|
||||
"error_saving": "❌ Erreur lors de la sauvegarde des paramètres : {error}",
|
||||
"output_format_settings": "Paramètres du format de sortie",
|
||||
"force_output_format": "Forcer le format de sortie lors de la fusion",
|
||||
"preferred_format": "Format préféré :",
|
||||
"force_format_help": "Lorsqu'activé, les vidéos fusionnées seront converties vers votre format préféré. Désactivez pour laisser yt-dlp décider automatiquement.",
|
||||
"format_mp4": "MP4 (Plus compatible)",
|
||||
"format_webm": "WebM (Moderne, ouvert)",
|
||||
"format_mkv": "MKV (Riche en fonctionnalités)",
|
||||
"audio_format_settings": "Paramètres du format audio",
|
||||
"force_audio_format": "Forcer le format audio pour les téléchargements audio uniquement",
|
||||
"audio_normalization": "Normalisation audio (EBU R128)",
|
||||
"audio_normalization_help": "Lorsqu'il est activé, les pistes audio seront normalisées. Remarque : Cela nécessite un réencodage, un format audio spécifique (comme MP3 ou M4A) doit donc être forcé.",
|
||||
"preferred_audio_format": "Format audio préféré :",
|
||||
"force_audio_format_help": "Lorsqu'activé, les téléchargements audio uniquement seront convertis dans votre format préféré. Cela s'applique uniquement lors du téléchargement de formats audio.",
|
||||
"audio_format_best": "Meilleur (Pas de conversion)",
|
||||
"audio_format_aac": "AAC (Bon pour l'édition)",
|
||||
"audio_format_mp3": "MP3 (Universel)",
|
||||
"audio_format_flac": "FLAC (Sans perte)",
|
||||
"audio_format_wav": "WAV (Non compressé)",
|
||||
"audio_format_opus": "Opus (Efficace)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Ouvert)",
|
||||
"filename_format": "Format du nom de fichier",
|
||||
"filename_format_help": "Variables disponibles : %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. La syntaxe standard des modèles de sortie yt-dlp est prise en charge.",
|
||||
"tab_general": "Général",
|
||||
"tab_format": "Format",
|
||||
"tab_file": "Fichier",
|
||||
"concurrent_fragments": "Connexions simultanées",
|
||||
"concurrent_fragments_help": "Nombre de connexions par téléchargement. Des valeurs plus élevées contournent le bridage mais peuvent entraîner des blocages temporaires si elles sont trop élevées. Par défaut : 1.",
|
||||
"defaults_settings": "Paramètres de sélection par défaut",
|
||||
"default_video_quality": "Résolution vidéo par défaut (hauteur) :",
|
||||
"default_subtitle_language": "Langue(s) des sous-titres par défaut :",
|
||||
"defaults_help": "Définissez votre hauteur de vidéo préférée et les langues des sous-titres (séparées par des virgules). Elles seront sélectionnées automatiquement si elles sont disponibles."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Entrer l'URL de la vidéo ou de la playlist YouTube",
|
||||
"url_placeholder_generic": "Entrez l'URL d'une vidéo ou d'une playlist depuis n'importe quel site pris en charge",
|
||||
"merge_subtitles": "Fusionner les sous-titres",
|
||||
"save_thumbnail": "Sauvegarder la miniature",
|
||||
"save_description": "Sauvegarder la description",
|
||||
"embed_chapters": "Intégrer les chapitres",
|
||||
"subtitles_selected": "{count} sélectionné(s)",
|
||||
"all_selected": "Tous sélectionnés",
|
||||
"select_videos_all": "Sélectionner les vidéos... (Tous sélectionnés)",
|
||||
"please_enter_url": "Veuillez d'abord entrer une URL",
|
||||
"error_title": "Erreur",
|
||||
"cookie_file_selected_title": "Fichier de cookies sélectionné",
|
||||
"cookie_file_selected_message": "Fichier de cookies sélectionné : {path}",
|
||||
"browser_cookies_selected_title": "Cookies de navigateur sélectionnés",
|
||||
"browser_cookies_selected_message": "Les cookies du navigateur seront extraits depuis : {browser}",
|
||||
"error_no_format_info": "Erreur : Aucune information de format disponible.",
|
||||
"error_extract_info": "Erreur : Impossible d'extraire les informations de base de la vidéo. Veuillez vérifier votre lien.",
|
||||
"analyzing_preparing": "Préparation de la requête...",
|
||||
"analyzing_extracting_basic": "Extraction des informations de base...",
|
||||
"analyzing_extracting_detailed": "Extraction des informations détaillées...",
|
||||
"analyzing_processing_video": "Traitement des données vidéo...",
|
||||
"analyzing_processing_formats": "Traitement des formats...",
|
||||
"analyzing_loading_thumbnail": "Chargement de la miniature...",
|
||||
"analyzing_processing_subtitles": "Traitement des sous-titres...",
|
||||
"analyzing_updating_table": "Mise à jour du tableau des formats...",
|
||||
"analysis_complete": "Analyse terminée !",
|
||||
"analyzing_extracting_ytdlp": "Extraction d'informations...",
|
||||
"analyzing_fetching_first_video": "Récupération des formats pour la première vidéo...",
|
||||
"analyzing_processing_data": "Traitement des données...",
|
||||
"analyzing_processing_formats_ytdlp": "Traitement des formats...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Chargement de la miniature...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Traitement des sous-titres...",
|
||||
"select_subtitles": "Sélectionner les sous-titres...",
|
||||
"sponsorblock_categories": "Catégories SponsorBlock...",
|
||||
"invalid_url_or_enter": "URL invalide ou veuillez entrer une URL.",
|
||||
"zero_selected": "0 sélectionné",
|
||||
"analyze_first_tooltip": "Veuillez d'abord analyser la vidéo",
|
||||
"audio_mode_disabled": "Non disponible en mode audio uniquement",
|
||||
"select_subtitles_first": "Veuillez d'abord sélectionner les sous-titres",
|
||||
"settings_tooltip": "Chemin actuel : {path}\nLimite de vitesse : {speed_limit}",
|
||||
"speed_limit_none": "Aucune",
|
||||
"open_folder_error": "Impossible d’ouvrir le dossier : {error}",
|
||||
"time_range_set": "Section définie : {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Sponsor",
|
||||
"sponsor_desc": "Publicité payante, recommandations payantes et publicité directe",
|
||||
"selfpromo": "Auto-promotion non payante",
|
||||
"selfpromo_desc": "Publicité non payante pour le contenu propre du créateur",
|
||||
"interaction": "Rappel d'interaction",
|
||||
"interaction_desc": "Les spectateurs sont invités à aimer, s'abonner ou suivre sur les réseaux sociaux",
|
||||
"intro": "Intro",
|
||||
"intro_desc": "Introduction de la vidéo qui peut être ignorée",
|
||||
"outro": "Outro/Cartes de fin",
|
||||
"outro_desc": "Crédits ou fin de la vidéo",
|
||||
"preview": "Aperçu/Récapitulatif",
|
||||
"preview_desc": "Bref récapitulatif des vidéos précédentes ou aperçu du contenu à venir",
|
||||
"music_offtopic": "Section hors-musique",
|
||||
"music_offtopic_desc": "Uniquement pour les vidéos musicales. Marque les sections non-musicales",
|
||||
"filler": "Digression de remplissage",
|
||||
"filler_desc": "Scènes de diversion ajoutées uniquement comme remplissage ou pour l'humour"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Chaîne",
|
||||
"views": "👁 Vues",
|
||||
"likes": "👍 J'aime",
|
||||
"upload_date": "📅 Date de mise en ligne",
|
||||
"duration": "⏱ Durée",
|
||||
"unknown_channel": "Chaîne inconnue",
|
||||
"unknown_date": "Date inconnue",
|
||||
"unknown_title": "Titre inconnu"
|
||||
},
|
||||
"command": {
|
||||
"running": "En cours...",
|
||||
"run_command": "Exécuter la commande"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 sélectionné",
|
||||
"one_selected": "1 catégorie sélectionnée",
|
||||
"count_selected": "{count} sélectionné(s)"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Prêt",
|
||||
"file_exists": "⚠️ Le fichier existe déjà",
|
||||
"video_file_exists": "⚠️ Le fichier vidéo existe déjà",
|
||||
"audio_file_exists": "⚠️ Le fichier audio existe déjà",
|
||||
"subtitle_file_exists": "⚠️ Le fichier de sous-titres existe déjà",
|
||||
"cancelling": "Annulation du téléchargement...",
|
||||
"thumbnail_saved": "✅ Miniature enregistrée : {filename}",
|
||||
"thumbnail_error": "❌ Erreur de miniature : {error}",
|
||||
"thumbnail_no_image": "Aucune miniature disponible à enregistrer"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Erreur : La playlist ne contient aucune vidéo valide.",
|
||||
"playlist_no_url": "Erreur : Impossible d'obtenir l'URL de la première vidéo de la playlist.",
|
||||
"ytdlp_not_found": "Erreur : Exécutable yt-dlp introuvable. Veuillez d'abord installer yt-dlp.",
|
||||
"ytdlp_not_found_path": "Erreur : Exécutable yt-dlp introuvable. Cela pourrait être dû à une installation incorrecte ou à un problème de PATH.",
|
||||
"no_data_returned": "Erreur : Aucune donnée renvoyée par yt-dlp",
|
||||
"no_format_info": "Erreur : Aucune information de format disponible.",
|
||||
"analysis_timeout": "Erreur : Délai d'analyse dépassé. Veuillez réessayer.",
|
||||
"invalid_speed_limit": "❌ Erreur : Valeur de limite de vitesse invalide définie dans les paramètres.",
|
||||
"ytdlp_failed": "Erreur : yt-dlp a échoué : {error}",
|
||||
"parse_failed": "Erreur : Échec de l'analyse de la sortie yt-dlp : {error}",
|
||||
"analysis_failed": "Erreur : Échec de l'analyse : {error}",
|
||||
"generic_error": "Erreur : {error}",
|
||||
"download_failed_return_code_conflict": "Téléchargement échoué avec le code de retour {return_code}. Cela peut être dû à un conflit entre plusieurs installations de yt-dlp. Essayez de désinstaller toute version installée au niveau du système (par ex. via snap ou apt) puis redémarrez l’application.",
|
||||
"download_failed_return_code": "Téléchargement échoué avec le code de retour {return_code}",
|
||||
"direct_command_error": "Erreur dans la commande directe : {error}",
|
||||
"private_video": "Cette vidéo pourrait être privée. Veuillez utiliser des cookies depuis les options personnalisées."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Mise à jour disponible",
|
||||
"new_version_available": "Une nouvelle version de YTSage est disponible !",
|
||||
"current_version_label": "Version actuelle :",
|
||||
"latest_version_label": "Dernière version :",
|
||||
"changelog": "Journal des modifications",
|
||||
"download_update": "Télécharger la mise à jour",
|
||||
"remind_later": "Me le rappeler plus tard"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Playlist inconnue",
|
||||
"total_videos": "Total des vidéos : {count}",
|
||||
"display_format": "Playlist : {title} | {count} vidéos",
|
||||
"select_videos_title": "Sélectionner les Vidéos de la Playlist",
|
||||
"save_as": "Enregistrer la playlist sous",
|
||||
"save_success_title": "Succès",
|
||||
"saved_successfully": "Playlist enregistrée avec succès.",
|
||||
"save_error_title": "Erreur d'enregistrement",
|
||||
"no_videos_to_save": "Aucune vidéo de la playlist n'a été récupérée !",
|
||||
"save_error_msg": "Échec de l'enregistrement de la playlist."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} sélectionné(s)"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "Le fichier existe déjà",
|
||||
"message": "Le fichier existe déjà :\n{filename}",
|
||||
"info": "Cette vidéo a déjà été téléchargée."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Dernière vérification : Jamais",
|
||||
"last_check": "Dernière vérification : {time}",
|
||||
"next_check_disabled": "Prochaine vérification : Désactivée",
|
||||
"next_check_startup": "Prochaine vérification : Au démarrage",
|
||||
"next_check_overdue": "Prochaine vérification : Maintenant (en retard)",
|
||||
"next_check": "Prochaine vérification : {time}",
|
||||
"next_check_error": "Prochaine vérification : Erreur de calcul",
|
||||
"checking": "🔄 Vérification...",
|
||||
"check_now": "🔍 Vérifier les mises à jour maintenant",
|
||||
"current_version": "Version actuelle de yt-dlp : {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "L'URL ne peut pas être vide",
|
||||
"invalid_format": "Format d'URL invalide",
|
||||
"invalid_scheme": "L'URL doit commencer par http:// ou https://",
|
||||
"missing_domain": "URL invalide : nom de domaine manquant",
|
||||
"unsupported_platform": "YTSage ne prend en charge que les URLs YouTube et YouTube Music.\nLe domaine '{domain}' n'est pas pris en charge.",
|
||||
"invalid_youtu_be": "URL youtu.be invalide : ID de vidéo manquant"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Ceci est une vidéo privée. Vous pouvez la télécharger en vous connectant à votre compte avec des cookies.\nAllez dans 'Options Personnalisées' → 'Se connecter avec des Cookies' → 'Extraire les cookies du navigateur' pour vous authentifier.",
|
||||
"age_restricted": "Cette vidéo est soumise à une restriction d'âge. Vous devez être connecté pour y accéder.\nUtilisez 'Options Personnalisées' → 'Se connecter avec des Cookies' pour vous authentifier avec votre compte.",
|
||||
"geo_blocked": "Cette vidéo n'est pas disponible dans votre région (géo-bloquée).\nVous pourriez avoir besoin d'utiliser un VPN ou la vidéo pourrait être restreinte dans votre pays.",
|
||||
"video_unavailable": "Cette vidéo a été supprimée ou n'est plus disponible.\nLa vidéo a peut-être été supprimée par l'uploader ou retirée en raison de violations de politique.",
|
||||
"live_stream": "Ceci est un flux en direct qui ne peut pas être téléchargé pendant qu'il est actif.\nAttendez la fin du flux, puis essayez de télécharger la version archivée.",
|
||||
"playlist_error": "Impossible d'accéder à cette playlist. Elle peut être privée, supprimée ou vide.\nVérifiez si la playlist existe et est accessible publiquement.",
|
||||
"network_error": "Erreur de connexion réseau. Veuillez vérifier votre connexion Internet et réessayer.\nSi le problème persiste, le serveur vidéo pourrait être temporairement indisponible.",
|
||||
"invalid_url": "URL invalide ou non prise en charge. Veuillez vérifier le lien et réessayer.\nAssurez-vous d'utiliser une URL YouTube, Vimeo ou autre plateforme prise en charge valide.",
|
||||
"premium_content": "Ce contenu nécessite YouTube Premium ou l'adhésion à la chaîne.\nVous devez être connecté avec un compte ayant accès à ce contenu.",
|
||||
"copyright_blocked": "Cette vidéo est bloquée en raison de réclamations pour droits d'auteur.\nLe propriétaire du contenu a restreint l'accès à cette vidéo.",
|
||||
"extraction_failed": "Échec de l'extraction des informations vidéo. Cela pourrait être un problème temporaire.\nVeuillez réessayer dans quelques minutes ou vérifier si le lien vidéo est correct.",
|
||||
"generic_error": "Impossible d'extraire les informations vidéo. Veuillez vérifier votre lien.\nDétails techniques : {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Historique des Téléchargements",
|
||||
"clear_all": "Tout Effacer",
|
||||
"clear_confirm_title": "Effacer l'Historique?",
|
||||
"clear_confirm_message": "Êtes-vous sûr? Cela ne peut pas être annulé.",
|
||||
"no_history": "Pas encore d'historique",
|
||||
"no_history_description": "Vos téléchargements apparaîtront ici",
|
||||
"loading": "Chargement de l’historique...",
|
||||
"search_placeholder": "Rechercher...",
|
||||
"open_location": "Ouvrir l'Emplacement",
|
||||
"redownload": "Télécharger à Nouveau",
|
||||
"remove": "Supprimer",
|
||||
"file_not_found": "Fichier non trouvé",
|
||||
"file_not_found_message": "Le fichier a été déplacé ou supprimé:\n{path}",
|
||||
"downloaded_on": "Téléchargé: {date}",
|
||||
"file_size": "Taille: {size}",
|
||||
"audio_download": "Audio",
|
||||
"video_download": "Vidéo",
|
||||
"remove_confirm_title": "Supprimer?",
|
||||
"remove_confirm_message": "Supprimer de l'historique?\n\n{title}",
|
||||
"item_removed": "Supprimé",
|
||||
"history_cleared": "Historique effacé",
|
||||
"entries_count": "{count} téléchargements",
|
||||
"one_entry": "1 téléchargement",
|
||||
"redownload_confirm_title": "Télécharger à Nouveau?",
|
||||
"redownload_confirm_message": "Télécharger à nouveau?\n\n{title}",
|
||||
"redownload_started": "Téléchargement démarré",
|
||||
"no_url_error": "Aucune URL trouvée dans l’entrée d’historique",
|
||||
"redownload_failed": "Impossible de relancer le téléchargement : {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "Installation de FFmpeg",
|
||||
"installation_message": "YTSage a besoin de FFmpeg pour traiter les vidéos.\n\nChoisissez une option d’installation ci‑dessous :",
|
||||
"install_button": "Installer FFmpeg",
|
||||
"manual_guide": "Guide manuel",
|
||||
"installation_failed": "L’installation de FFmpeg a rencontré un problème.",
|
||||
"already_installed": "FFmpeg est déjà installé !",
|
||||
"installation_complete": "Installation terminée. Vous pouvez fermer cette boîte de dialogue et continuer à utiliser YTSage.",
|
||||
"installing": "Installation de FFmpeg... Veuillez patienter",
|
||||
"install_success": "FFmpeg a été installé avec succès !",
|
||||
"installation_complete_close": "Installation terminée. Vous pouvez maintenant fermer cette boîte de dialogue et continuer à utiliser YTSage.",
|
||||
"try_manual": "Veuillez essayer d’utiliser le guide d’installation manuel à la place."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "Configuration de yt-dlp requise",
|
||||
"description": "YTSage nécessite yt-dlp pour télécharger des vidéos.<br><br>yt-dlp est introuvable dans le répertoire local de l’application. YTSage doit configurer yt-dlp pour votre système {os_name}.<br><br>Veuillez choisir une option ci‑dessous :",
|
||||
"option_auto": "Télécharger automatiquement (recommandé)",
|
||||
"option_manual": "Sélectionner le chemin manuellement",
|
||||
"setup_button": "Configurer yt-dlp",
|
||||
"downloading": "Téléchargement de yt-dlp...",
|
||||
"success": "yt-dlp a été installé avec succès !",
|
||||
"error": "Erreur : {error}",
|
||||
"download_failed_title": "Téléchargement échoué",
|
||||
"download_failed_message": "Échec du téléchargement de yt-dlp : {error}",
|
||||
"select_executable_title": "Sélectionner l’exécutable yt-dlp",
|
||||
"copied_to": "yt-dlp copié avec succès vers {path}",
|
||||
"setup_error_title": "Erreur de configuration",
|
||||
"copy_error": "Erreur lors de la copie de yt-dlp dans le répertoire de l’application : {error}",
|
||||
"invalid_executable_title": "Exécutable invalide",
|
||||
"invalid_executable_message": "Le fichier sélectionné ne semble pas être un exécutable yt-dlp valide.",
|
||||
"verify_error": "Erreur lors de la vérification de l’exécutable yt-dlp : {error}",
|
||||
"setup_failed_title": "Configuration échouée",
|
||||
"setup_failed_message": "La configuration de yt-dlp a échoué. Certaines fonctionnalités peuvent ne pas fonctionner correctement.",
|
||||
"success_dialog_title": "Configuration de yt-dlp",
|
||||
"success_dialog_message": "yt-dlp a été configuré avec succès à l’emplacement :\n{path}",
|
||||
"file_filter_windows": "Fichiers exécutables (*.exe)",
|
||||
"file_filter_all": "Tous les fichiers (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "Vérificateur de Version FFmpeg",
|
||||
"current_version": "Version Actuelle:",
|
||||
"latest_version": "Dernière Version:",
|
||||
"status_up_to_date": "✓ À jour",
|
||||
"status_update_available": "⚠ Mise à jour disponible",
|
||||
"status_not_installed": "✗ Non installé",
|
||||
"status_idle": "Cliquez sur 'Vérifier la Version' pour commencer",
|
||||
"status_checking": "🔄 Vérification de la version...",
|
||||
"check_updates": "Vérifier la Version",
|
||||
"check_failed": "Échec de la vérification de la version. Vérifiez votre connexion Internet.",
|
||||
"description": "Vérifiez votre version de FFmpeg et comparez-la avec la dernière version disponible.",
|
||||
"guide_info": " Pour installer ou mettre à jour FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>cliquez ici pour consulter notre guide d'installation complet</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Configuration de Deno requise",
|
||||
"setup_description": "YTSage nécessite Deno pour exécuter certaines fonctionnalités.<br><br>Deno n'a pas été trouvé dans le répertoire local de l'application. YTSage doit configurer Deno pour votre système {os_name}.<br><br>Cliquez sur 'Configurer Deno' pour télécharger et installer automatiquement.",
|
||||
"setup_button": "Configurer Deno",
|
||||
"downloading": "Téléchargement de Deno...",
|
||||
"extracting": "Extraction de Deno...",
|
||||
"verifying": "Vérification de Deno...",
|
||||
"success": "Deno a été installé avec succès !",
|
||||
"download_failed": "Échec du téléchargement",
|
||||
"download_error": "Échec du téléchargement de Deno : {error}",
|
||||
"verification_failed": "Échec de la vérification SHA256. Le fichier téléchargé peut être corrompu ou altéré.",
|
||||
"setup_failed": "Échec de la configuration de Deno. Certaines fonctionnalités peuvent ne pas fonctionner correctement.",
|
||||
"setup_error": "Erreur de configuration"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Vérificateur et Mise à Jour de Version Deno",
|
||||
"description": "Vérifiez votre version de Deno et mettez à jour vers la dernière version.",
|
||||
"current_version": "Version Actuelle :",
|
||||
"latest_version": "Dernière Version :",
|
||||
"status_idle": "Cliquez sur 'Vérifier les Mises à Jour' pour commencer",
|
||||
"status_checking": "🔄 Vérification des mises à jour...",
|
||||
"status_up_to_date": "✓ À jour",
|
||||
"status_update_available": "⚠ Mise à jour disponible",
|
||||
"status_not_installed": "✗ Non installé",
|
||||
"check_updates": "Vérifier les Mises à Jour",
|
||||
"update_now": "Mettre à Jour Deno",
|
||||
"updating": "🔄 Mise à jour de Deno...",
|
||||
"update_success": "✅ Deno a été mis à jour avec succès !",
|
||||
"update_failed": "❌ Échec de la mise à jour : {error}",
|
||||
"check_failed": "Échec de la vérification des mises à jour. Veuillez vérifier votre connexion Internet."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "हिन्दी (Hindi)",
|
||||
"select_language": "भाषा चुनें:",
|
||||
"current_language": "वर्तमान भाषा: {language}",
|
||||
"restart_required": "भाषा परिवर्तन एप्लिकेशन पुनरारंभ के बाद प्रभावी होगा।",
|
||||
"english": "अंग्रेजी",
|
||||
"spanish": "स्पेनिश",
|
||||
"portuguese": "पुर्तगाली",
|
||||
"russian": "रूसी",
|
||||
"chinese": "चीनी",
|
||||
"german": "जर्मन",
|
||||
"french": "फ्रेंच",
|
||||
"hindi": "हिन्दी",
|
||||
"indonesian": "इंडोनेशियाई",
|
||||
"turkish": "तुर्की",
|
||||
"polish": "पोलिश",
|
||||
"italian": "इतालवी",
|
||||
"arabic": "अरबी",
|
||||
"japanese": "जापानी",
|
||||
"help_text": "इंटरफेस के लिए अपनी पसंदीदा भाषा चुनें।",
|
||||
"restart_notice": "भाषा परिवर्तन एप्लिकेशन पुनरारंभ के बाद प्रभावी होगा।"
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "तैयार"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "प्रारूप दिखाएं:",
|
||||
"video_format": "वीडियो प्रारूप:",
|
||||
"audio_format": "ऑडियो प्रारूप:",
|
||||
"no_formats": "कोई प्रारूप उपलब्ध नहीं",
|
||||
"loading": "प्रारूप लोड हो रहे हैं...",
|
||||
"select": "चुनें",
|
||||
"quality": "गुणवत्ता",
|
||||
"extension": "एक्सटेंशन",
|
||||
"resolution": "रिज़ॉल्यूशन",
|
||||
"file_size": "फ़ाइल आकार",
|
||||
"codec": "कोडेक",
|
||||
"audio": "ऑडियो",
|
||||
"fps": "फ्रेम दर",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "ऑडियो मर्ज किया जाएगा",
|
||||
"has_audio": "✓ ऑडियो है",
|
||||
"audio_only": "केवल ऑडियो",
|
||||
"best_4k": "सर्वोत्तम (4K)",
|
||||
"best_2k": "सर्वोत्तम (2K)",
|
||||
"high_1080p": "उच्च (1080p)",
|
||||
"high_720p": "उच्च (720p)",
|
||||
"medium_480p": "मध्यम (480p)",
|
||||
"low_quality": "निम्न गुणवत्ता",
|
||||
"best_audio": "सर्वोत्तम ऑडियो",
|
||||
"high_audio": "उच्च ऑडियो",
|
||||
"medium_audio": "मध्यम ऑडियो",
|
||||
"low_audio": "निम्न ऑडियो",
|
||||
"audio_only_resolution": "केवल ऑडियो"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "रीसेट",
|
||||
"download": "डाउनलोड",
|
||||
"pause": "रोकें",
|
||||
"resume": "जारी रखें",
|
||||
"cancel": "रद्द करें",
|
||||
"browse": "ब्राउज़",
|
||||
"clear": "साफ़ करें",
|
||||
"ok": "ठीक है",
|
||||
"apply": "लागू करें",
|
||||
"close": "बंद करें",
|
||||
"run_command": "कमांड चलाएं",
|
||||
"custom_command_help": "सहायता",
|
||||
"about": "के बारे में",
|
||||
"analyze": "विश्लेषण",
|
||||
"paste_url": "URL पेस्ट करें",
|
||||
"select_videos": "वीडियो चुनें...",
|
||||
"video": "वीडियो",
|
||||
"audio_only": "केवल ऑडियो",
|
||||
"custom_options": "कस्टम विकल्प",
|
||||
"trim_video": "वीडियो ट्रिम करें",
|
||||
"download_settings": "डाउनलोड सेटिंग्स",
|
||||
"update": "yt-dlp अपडेट करें",
|
||||
"select_defaults": "डिफ़ॉल्ट चुनें",
|
||||
"select_all": "सभी चुनें",
|
||||
"deselect_all": "सभी को अचयनित करें",
|
||||
"open_folder": "फ़ोल्डर स्थान खोलें",
|
||||
"history": "इतिहास",
|
||||
"save_playlist": "प्लेलिस्ट को इस रूप में सहेजें"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "कस्टम विकल्प",
|
||||
"settings": "सेटिंग्स",
|
||||
"select_folder": "डाउनलोड फ़ोल्डर चुनें",
|
||||
"sponsorblock_categories": "SponsorBlock श्रेणियां",
|
||||
"sponsorblock_description": "डाउनलोड के दौरान अपने आप हटाए जाने वाले वीडियो सेगमेंट के प्रकार चुनें।\nSponsorBlock इन सेगमेंट की पहचान के लिए समुदाय द्वारा प्रस्तुत डेटा का उपयोग करता है।",
|
||||
"select_subtitles": "उपशीर्षک चुनें",
|
||||
"filter_languages_placeholder": "भाषाएं फ़िल्टर करें (जैसे: hi, en)...",
|
||||
"no_subtitles_available": "कोई उपशीर्षक उपलब्ध नहीं",
|
||||
"matching": "मेल खाता",
|
||||
"ytdlp_log_title": "yt-dlp लॉग",
|
||||
"filter_playlist_placeholder": "वीडियो फ़िल्टर करें..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "कुकीज़ के साथ लॉगिन",
|
||||
"custom_command": "कस्टम कमांड",
|
||||
"proxy": "प्रॉक्सी",
|
||||
"language": "भाषा",
|
||||
"updater": "अपडेटर"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "प्रमाणीकरण के लिए कुकीज़ प्रदान करने का तरीका चुनें।\nयह निजी वीडियो और उच्च गुणवत्ता वाली ऑडियो फ़ाइलें डाउनलोड करने की अनुमति देता है।",
|
||||
"cookie_source": "कुकी स्रोत",
|
||||
"use_cookie_file": "कुकी फ़ाइल का उपयोग करें",
|
||||
"extract_from_browser": "ब्राउज़र से निकालें",
|
||||
"recommended": "अनुशंसित",
|
||||
"cookie_file": "कुकी फ़ाइल",
|
||||
"cookie_file_placeholder": "cookies.txt फ़ाइल का पथ...",
|
||||
"browser_selection": "ब्राउज़र चयन",
|
||||
"browser_help": "वह ब्राउज़र चुनें जिससे कुकीज़ निकालनी हैं:",
|
||||
"browser_label": "ब्राउज़र:",
|
||||
"profile_label": "प्रोफ़ाइल:",
|
||||
"profile_placeholder": "डिफ़ॉल्ट",
|
||||
"browser_extract_message": "लागू करने पर ब्राउज़र कुकीज़ निकाली जाएंगी",
|
||||
"file_selected_message": "कुकी फ़ाइल चुनी गई - लागू करने के लिए OK दबाएं",
|
||||
"select_file_title": "कुकी फ़ाइल चुनें",
|
||||
"file_filter": "कुकी फ़ाइलें (*.txt *.lwp)",
|
||||
"file_selected_title": "कुकी फ़ाइल लागू की गई",
|
||||
"file_applied_message": "कुकी फ़ाइल लागू की गई: {path}",
|
||||
"browser_selected_title": "ब्राउज़र कुकीज़ लागू की गईं",
|
||||
"browser_applied_message": "ब्राउज़र कुकीज़ निकाली जाएंगी: {browser}",
|
||||
"cleared_title": "कुकीज़ साफ़ की गईं",
|
||||
"cleared_message": "कुकी सेटिंग्स साफ़ कर दी गई हैं",
|
||||
"active_browser": "✓ सक्रिय: ब्राउज़र कुकीज़ ({browser})",
|
||||
"active_file": "✓ सक्रिय: कुकी फ़ाइल ({file})",
|
||||
"none_active": "○ कोई कुकी सक्रिय नहीं",
|
||||
"remember_settings": "अगले स्टार्टअप पर कुकी सेटिंग्स याद रखें"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "नीचे अपना कस्टम yt-dlp कमांड दर्ज करें। वर्तमान URL स्वचालित रूप से जोड़ा जाएगा।<br><br>विकल्पों की पूरी सूची और उपयोग के उदाहरणों के लिए <a href=\"{docs_url}\">आधिकारिक yt-dlp दस्तावेज़ देखने के लिए यहाँ क्लिक करें</a>।<br><br>नोट: डाउनलोड पथ और फ़ाइलनाम टेम्प्लेट स्वचालित रूप से संभाले जाते हैं।",
|
||||
"input_label": "yt-dlp आर्गुमेंट्स:",
|
||||
"input_placeholder": "यहाँ yt-dlp आर्गुमेंट्स दर्ज करें...\n\nउदाहरण: --extract-audio --audio-format mp3",
|
||||
"output_label": "कमांड आउटपुट:",
|
||||
"output_placeholder": "कमांड आउटपुट यहाँ दिखाई देगा...",
|
||||
"command_placeholder": "कस्टम yt-dlp कमांड दर्ज करें...",
|
||||
"command_help": "उपलब्ध प्लेसहोल्डर:\n{url} - वीडियो URL\n{output} - आउटपुट फ़ोल्डर\n\nउदाहरण: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 पूरा कमांड: {command}",
|
||||
"command_success": "✅ कस्टम कमांड सफलतापूर्वक निष्पादित!",
|
||||
"command_failed": "❌ कमांड असफल, एग्जिट कोड {code}",
|
||||
"command_error": "❌ कस्टम कमांड निष्पादित करने में त्रुटि: {error}",
|
||||
"error_no_url": "❌ त्रुटि: कोई URL नहीं दिया गया। कृपया मुख्य विंडो में URL दर्ज करें।",
|
||||
"error_no_command": "❌ त्रुटि: कोई कमांड नहीं दिया गया। कृपया yt-dlp आर्गुमेंट्स दर्ज करें।",
|
||||
"executing": "🚀 कस्टम yt-dlp कमांड चल रहा है",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ आर्गुमेंट्स: {command}",
|
||||
"download_path_label": "📁 डाउनलोड पथ: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "डाउनलोड के लिए प्रॉक्सी सेटिंग्स कॉन्फ़िगर करें। प्रत्यक्ष कनेक्शन के लिए खाली छोड़ें।",
|
||||
"main_proxy": "मुख्य प्रॉक्सी",
|
||||
"main_proxy_help": "सभी डाउनलोड के लिए प्राथमिक प्रॉक्सी सर्वर। HTTP/HTTPS और SOCKS5 प्रोटोकॉल समर्थित।",
|
||||
"proxy_url_label": "प्रॉक्सी URL:",
|
||||
"proxy_url_placeholder": "http://proxy:port या socks5://proxy:port",
|
||||
"proxy_examples": "उदाहरण:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "भू-बाईपास प्रॉक्सी",
|
||||
"geo_proxy_help": "भौगोलिक प्रतिबंधों को बायपास करने के लिए द्वितीयक प्रॉक्सी।",
|
||||
"geo_proxy_url_label": "भू-बाईपास प्रॉक्सी URL:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "मुख्य प्रॉक्सी साफ़ करें",
|
||||
"clear_geo_proxy": "भू-प्रॉक्सी साफ़ करें",
|
||||
"proxy_url": "प्रॉक्सी URL",
|
||||
"proxy_placeholder": "http://proxy:port या socks5://proxy:port",
|
||||
"geo_bypass": "भू-बाईपास प्रॉक्सी (भौगोलिक प्रतिबंधों के लिए)",
|
||||
"geo_bypass_placeholder": "http://proxy:port भू-ब्लॉकिंग बायपास के लिए",
|
||||
"invalid_main_url": "अमान्य मुख्य प्रॉक्सी URL प्रारूप",
|
||||
"invalid_geo_url": "अमान्य भू-प्रॉक्सी URL प्रारूप",
|
||||
"main_configured": "मुख्य प्रॉक्सी कॉन्फ़िगर की गई",
|
||||
"geo_configured": "भू-प्रॉक्सी कॉन्फ़िगर की गई",
|
||||
"set_title": "प्रॉक्सी सेट",
|
||||
"set_message": "मुख्य प्रॉक्सी सेट और सेव किया गया: {proxy}",
|
||||
"geo_set_title": "जियो प्रॉक्सी सेट",
|
||||
"geo_set_message": "जियो‑वेरिफिकेशन प्रॉक्सी सेट और सेव किया गया: {proxy}",
|
||||
"cleared_title": "प्रॉक्सी सेटिंग्स साफ़ की गईं",
|
||||
"cleared_message": "सभी प्रॉक्सी सेटिंग्स साफ़ कर सेव कर दी गईं।",
|
||||
"saved_main": "सहेजा गया मुख्य प्रॉक्सी: {proxy}",
|
||||
"saved_geo": "सहेजा गया जियो प्रॉक्सी: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "डाउनलोड तैयार हो रहा है...",
|
||||
"starting": "🚀 डाउनलोड शुरू हो रहा है...",
|
||||
"fetching_info": "🔍 वीडियो जानकारी प्राप्त कर रहे हैं...",
|
||||
"preparing_streams": "🎯 वीडियो स्ट्रीम तैयार हो रही है...",
|
||||
"downloading_audio": "⏬ ऑडियो डाउनलोड हो रहा है...",
|
||||
"downloading_video": "⏬ वीडियो डाउनलोड हो रहा है...",
|
||||
"downloading_subtitle": "⏬ उपशीर्षक डाउनलोड हो रहे हैं...",
|
||||
"downloading": "⏬ डाउनलोड हो रहा है...",
|
||||
"completed": "✅ डाउनलोड पूर्ण!",
|
||||
"video_completed": "✅ वीडियो डाउनलोड पूर्ण!",
|
||||
"audio_completed": "✅ ऑडियो डाउनलोड पूर्ण!",
|
||||
"subtitle_completed": "✅ उपशीर्षक डाउनलोड पूर्ण!",
|
||||
"completed_cleaning": "✅ डाउनलोड पूर्ण! साफ़ कर रहे हैं...",
|
||||
"cancelled": "डाउनलोड रद्द किया गया",
|
||||
"processing_playlist": "📋 प्लेलिस्ट डेटा प्रोसेस हो रहा है...",
|
||||
"paused": "डाउनलोड रोका गया",
|
||||
"resumed": "डाउनलोड जारी रखा गया",
|
||||
"merging_formats": "✨ पोस्ट-प्रोसेसिंग: फॉर्मेट मर्ज हो रहे हैं...",
|
||||
"removing_sponsor_segments": "✨ पोस्ट-प्रोसेसिंग: स्पॉन्सर सेगमेंट हटाए जा रहे हैं...",
|
||||
"speed": "गति",
|
||||
"eta": "शेष समय",
|
||||
"please_enter_url": "कृपया पहले URL दर्ज करें।",
|
||||
"please_set_path": "कृपया पहले डाउनलोड पथ सेट करें।",
|
||||
"please_enter_url_and_path": "कृपया URL दर्ज करें और डाउनलोड पथ सेट करें।",
|
||||
"please_select_format": "कृपया पहले प्रारूप चुनें।",
|
||||
"downloading_fallback": "⚡ डाउनलोड हो रहा है..."
|
||||
},
|
||||
"update": {
|
||||
"title": "yt-dlp अपडेट करें",
|
||||
"checking": "अपडेट की जांच हो रही है...",
|
||||
"update_available": "अपडेट उपलब्ध!\nवर्तमान संस्करण: {current}\nनवीनतम संस्करण: {latest}",
|
||||
"up_to_date": "yt-dlp अप टू डेट है (संस्करण {version})",
|
||||
"could_not_determine": "संस्करण निर्धारित नहीं हो सके।",
|
||||
"error_comparing": "संस्करण तुलना में त्रुटि: {error}",
|
||||
"update_available_failed": "अपडेट उपलब्ध! (तुलना असफल)\nवर्तमान: {current}\nनवीनतम: {latest}",
|
||||
"updating": "अपडेट हो रहा है...",
|
||||
"initializing": "🚀 अपडेट प्रक्रिया प्रारंभ हो रही है...",
|
||||
"checking_current": "🔍 वर्तमान इंस्टॉलेशन की जांच हो रही है...",
|
||||
"found_at": "📍 yt-dlp मिला: {path}",
|
||||
"error_getting_path": "❌ yt-dlp पथ प्राप्त करने में त्रुटि: {error}",
|
||||
"updating_binary": "📦 ऐप-प्रबंधित yt-dlp बाइनरी अपडेट हो रही है...",
|
||||
"update_failed": "❌ yt-dlp अपडेट असफल। कृपया पुनः प्रयास करें या अपना इंटरनेट कनेक्शन जांचें।",
|
||||
"binary_updated": "✅ बाइनरी सफलतापूर्वक अपडेट हुई!",
|
||||
"update_failed_stderr": "❌ yt-dlp अपडेट असफल: {error}",
|
||||
"update_timeout": "❌ yt-dlp अपडेट टाइमआउट।",
|
||||
"unexpected_error": "❌ अपडेट के दौरान अनपेक्षित त्रुटि: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp पहले से अप टू डेट है!",
|
||||
"update_success": "✅ yt-dlp सफलतापूर्वक अपडेट किया गया है!",
|
||||
"already_latest": "yt-dlp अप टू डेट है (संस्करण {version})",
|
||||
"network_error": "❌ अपडेट के दौरान नेटवर्क त्रुटि: {error}",
|
||||
"general_error": "❌ अपडेट असफल: {error}",
|
||||
"update_in_progress_title": "अपडेट जारी है",
|
||||
"update_in_progress_message": "yt-dlp अभी अपडेट हो रहा है। कृपया कुछ देर प्रतीक्षा करें।"
|
||||
},
|
||||
"about": {
|
||||
"title": "YTSage के बारे में",
|
||||
"version": "संस्करण {version}",
|
||||
"description": "साफ PySide6 इंटरफेस के साथ आधुनिक YouTube डाउनलोडर।",
|
||||
"author": "द्वारा: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "सिस्टम जानकारी",
|
||||
"loading": "🔄 सिस्टम जानकारी लोड हो रही है...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 लॉग्स",
|
||||
"logs_tooltip": "एप्लिकेशन लॉग फ़ोल्डर खोलें",
|
||||
"refreshing": "🔄 रिफ्रेश हो रहा है...",
|
||||
"refresh_failed": "रिफ्रेश असफल",
|
||||
"refresh_failed_message": "संस्करण जानकारी रिफ्रेश करने में असमर्थ।",
|
||||
"detected": "✓ पता चला",
|
||||
"missing": "✗ गुम",
|
||||
"not_available": "उपलब्ध नहीं"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "वीडियो ट्रिम करें",
|
||||
"time_range_group": "समय सीमा",
|
||||
"start_time": "प्रारंभ समय (HH:MM:SS)",
|
||||
"end_time": "समाप्ति समय (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "प्रारंभ समय (HH:MM:SS)",
|
||||
"end_time_placeholder": "समाप्ति समय (HH:MM:SS)",
|
||||
"force_keyframes": "कट पॉइंट्स पर कीफ्रेम्स को बाध्य करें",
|
||||
"help_text": "वीडियो ट्रिम करने के लिए प्रारंभ और समाप्ति समय सेट करें।\nपूरा वीडियो डाउनलोड करने के लिए खाली छोड़ें।",
|
||||
"invalid_format": "अमान्य समय प्रारूप। HH:MM:SS प्रारूप का उपयोग करें।",
|
||||
"start_after_end": "प्रारंभ समय समाप्ति समय के बाद नहीं हो सकता।"
|
||||
},
|
||||
"settings": {
|
||||
"title": "डाउनलोड सेटिंग्स",
|
||||
"download_path": "डाउनलोड पथ",
|
||||
"browse": "ब्राउज़ करें...",
|
||||
"speed_limit": "गति सीमा",
|
||||
"speed_limit_placeholder": "कोई नहीं",
|
||||
"generic_mode": "जेनेरिक मोड",
|
||||
"enable_generic_mode": "जेनेरिक मोड सक्षम करें (गैर-YouTube साइटों के लिए समर्थन)",
|
||||
"generic_mode_help": "Dailymotion, CBC Gem और yt-dlp द्वारा समर्थित अन्य साइटों से डाउनलोड की अनुमति देता है।",
|
||||
"auto_update_ytdlp": "yt-dlp स्वचालित अपडेट",
|
||||
"enable_auto_updates": "yt-dlp स्वचालित अपडेट सक्षम करें",
|
||||
"update_frequency": "अपडेट आवृत्ति:",
|
||||
"check_startup": "हर स्टार्टअप पर जांचें (जांच के बीच कम से कम 1 घंटा)",
|
||||
"check_daily": "दैनिक जांचें",
|
||||
"check_weekly": "साप्ताहिक जांचें",
|
||||
"check_updates_now": "yt-dlp अपडेट करें",
|
||||
"update_check_title": "अपडेट जांच",
|
||||
"could_not_determine_version": "वर्तमान yt-dlp संस्करण निर्धारित नहीं हो सका।",
|
||||
"update_available_dialog": "अपडेट उपलब्ध!\n\nवर्तमान: {current}\nनवीनतम: {latest}\n\nअपडेट जारी रखने के लिए OK पर क्लिक करें।",
|
||||
"up_to_date_dialog": "yt-dlp अप टू डेट है!\n\nवर्तमान संस्करण: {version}",
|
||||
"error_checking_updates": "अपडेट जांचने में त्रुटि: {error}",
|
||||
"settings_saved_title": "सेटिंग्स सेव की गईं",
|
||||
"settings_saved_message": "स्वचालित अपडेट सेटिंग्स सफलतापूर्वक सेव हुईं!",
|
||||
"error_title": "त्रुटि",
|
||||
"failed_save_settings": "स्वचालित अपडेट सेटिंग्स सेव करना असफल।",
|
||||
"error_saving_settings": "स्वचालित अपडेट सेटिंग्स सेव करने में त्रुटि: {error}",
|
||||
"ytdlp_channel": "yt-dlp रिलीज़ चैनल",
|
||||
"ytdlp_channel_stable": "स्थिर (परीक्षित रिलीज़)",
|
||||
"ytdlp_channel_nightly": "नाइटली (दैनिक अपडेट, yt-dlp द्वारा अनुशंसित)",
|
||||
"ytdlp_channel_description": "स्थिर और नाइटली रिलीज़ के बीच चुनें। नाइटली बिल्ड नवीनतम फिक्स और सुविधाओं के साथ दैनिक अपडेट होते हैं। आप किसी भी समय चैनल बदल सकते हैं।",
|
||||
"ytdlp_switching_channel": "{channel} चैनल पर स्विच किया जा रहा है...",
|
||||
"ytdlp_channel_switched": "✅ सफलतापूर्वक {channel} चैनल पर स्विच किया गया!",
|
||||
"ytdlp_channel_switch_failed": "❌ चैनल स्विच करना विफल: {error}",
|
||||
"ytdlp_current_channel": "वर्तमान चैनल: {channel}",
|
||||
"app_updates_title": "YTSage अपडेट",
|
||||
"check_app_updates": "स्टार्टअप पर YTSage अपडेट जांचें",
|
||||
"check_beta_updates": "बीटा अपडेट प्राप्त करें",
|
||||
"auto_update_title": "स्वचालित अपडेट सेटिंग्स",
|
||||
"auto_update_header": "🔄 स्वचालित अपडेट सेटिंग्स",
|
||||
"auto_update_description": "yt-dlp के लिए स्वचालित अपडेट कॉन्फ़िगर करें ताकि आपके पास हमेशा नवीनतम सुविधाएं और बग फिक्स हों।",
|
||||
"current_status": "वर्तमान स्थिति",
|
||||
"current_version_label": "वर्तमान yt-dlp संस्करण: जांच रहे हैं...",
|
||||
"last_check_label": "अंतिम अपडेट जांच: कभी नहीं",
|
||||
"next_check_label": "अगली जांच: सेटिंग्स के आधार पर",
|
||||
"manual_check_button": "🔍 अभी अपडेट की जांच करें",
|
||||
"update_frequency_group": "अपडेट आवृत्ति",
|
||||
"save_settings": "सेटिंग्स सेव करें",
|
||||
"settings_saved_successfully": "✅ सेटिंग्स सफलतापूर्वक सेव हुईं!",
|
||||
"error_saving": "❌ सेटिंग्स सेव करने में त्रुटि: {error}",
|
||||
"output_format_settings": "आउटपुट प्रारूप सेटिंग्स",
|
||||
"force_output_format": "मर्ज करते समय आउटपुट प्रारूप बाध्य करें",
|
||||
"preferred_format": "पसंदीदा प्रारूप:",
|
||||
"force_format_help": "सक्षम होने पर, मर्ज किए गए वीडियो आपके पसंदीदा प्रारूप में परिवर्तित हो जाएंगे। yt-dlp को स्वचालित रूप से निर्णय लेने देने के लिए अक्षम करें।",
|
||||
"format_mp4": "MP4 (सबसे संगत)",
|
||||
"format_webm": "WebM (आधुनिक, खुला)",
|
||||
"format_mkv": "MKV (सुविधा समृद्ध)",
|
||||
"audio_format_settings": "ऑडियो प्रारूप सेटिंग्स",
|
||||
"force_audio_format": "केवल ऑडियो डाउनलोड के लिए ऑडियो प्रारूप लागू करें",
|
||||
"audio_normalization": "ऑडियो सामान्यीकरण (EBU R128)",
|
||||
"audio_normalization_help": "सक्षम होने पर, ऑडियो ट्रैक सामान्य किए जाएंगे। ध्यान दें: इसके लिए री-एनकोडिंग की आवश्यकता होती है, इसलिए एक विशिष्ट ऑडियो प्रारूप (जैसे MP3 या M4A) को बाध्य किया जाना चाहिए।",
|
||||
"preferred_audio_format": "पसंदीदा ऑडियो प्रारूप:",
|
||||
"force_audio_format_help": "सक्षम होने पर, केवल ऑडियो डाउनलोड आपके पसंदीदा प्रारूप में परिवर्तित हो जाएंगे। यह केवल ऑडियो प्रारूप डाउनलोड करते समय लागू होता है।",
|
||||
"audio_format_best": "सर्वोत्तम (कोई परिवर्तन नहीं)",
|
||||
"audio_format_aac": "AAC (संपादन के लिए अच्छा)",
|
||||
"audio_format_mp3": "MP3 (सार्वभौमिक)",
|
||||
"audio_format_flac": "FLAC (हानिरहित)",
|
||||
"audio_format_wav": "WAV (असंकुचित)",
|
||||
"audio_format_opus": "Opus (कुशल)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (खुला)",
|
||||
"filename_format": "आउटपुट फ़ाइलनाम प्रारूप",
|
||||
"filename_format_help": "उपलब्ध चर: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. मानक yt-dlp आउटपुट टेम्प्लेट सिंटैक्स समर्थित है.",
|
||||
"tab_general": "सामान्य",
|
||||
"tab_format": "प्रारूप",
|
||||
"tab_file": "फ़ाइल",
|
||||
"concurrent_fragments": "एक साथ कनेक्शन",
|
||||
"concurrent_fragments_help": "प्रति डाउनलोड कनेक्शन की संख्या। उच्च मान थ्रॉटलिंग को बायपास करते हैं लेकिन बहुत अधिक सेट होने पर अस्थायी ब्लॉक का कारण बन सकते हैं। डिफ़ॉल्ट: 1.",
|
||||
"defaults_settings": "डिफ़ॉल्ट चयन सेटिंग्स",
|
||||
"default_video_quality": "डिफ़ॉल्ट वीडियो रिज़ॉल्यूशन (ऊंचाई):",
|
||||
"default_subtitle_language": "डिफ़ॉल्ट उपशीर्षक भाषा(ए):",
|
||||
"defaults_help": "अपनी पसंदीदा वीडियो ऊंचाई और उपशीर्षक भाषाएं (कॉमा से अलग) सेट करें। उपलब्ध होने पर वे स्वतः चयनित हो जाएंगी।"
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "YouTube वीडियो या प्लेलिस्ट URL दर्ज करें",
|
||||
"url_placeholder_generic": "किसी भी समर्थित साइट से वीडियो या प्लेलिस्ट URL दर्ज करें",
|
||||
"merge_subtitles": "उपशीर्षक मर्ज करें",
|
||||
"save_thumbnail": "थंबनेल सेव करें",
|
||||
"save_description": "विवरण सेव करें",
|
||||
"embed_chapters": "चैप्टर एम्बेड करें",
|
||||
"subtitles_selected": "{count} चुना गया",
|
||||
"all_selected": "सभी चुने गए",
|
||||
"select_videos_all": "वीडियो चुनें... (सभी चुने गए)",
|
||||
"please_enter_url": "कृपया पहले URL दर्ज करें",
|
||||
"error_title": "त्रुटि",
|
||||
"cookie_file_selected_title": "कुकी फ़ाइल चुनी गई",
|
||||
"cookie_file_selected_message": "कुकी फ़ाइल चुनी गई: {path}",
|
||||
"browser_cookies_selected_title": "ब्राउज़र कुकीज़ चुनी गईं",
|
||||
"browser_cookies_selected_message": "ब्राउज़र कुकीज़ निकाली जाएंगी: {browser}",
|
||||
"error_no_format_info": "त्रुटि: कोई प्रारूप जानकारी उपलब्ध नहीं।",
|
||||
"error_extract_info": "त्रुटि: बुनियादी वीडियो जानकारी निकालने में असमर्थ। कृपया अपना लिंक जांचें।",
|
||||
"analyzing_preparing": "अनुरोध तैयार हो रहा है...",
|
||||
"analyzing_extracting_basic": "बुनियादी जानकारी निकाली जा रही है...",
|
||||
"analyzing_extracting_detailed": "विस्तृत जानकारी निकाली जा रही है...",
|
||||
"analyzing_processing_video": "वीडियो डेटा प्रोसेस हो रहा है...",
|
||||
"analyzing_processing_formats": "प्रारूप प्रोसेस हो रहे हैं...",
|
||||
"analyzing_loading_thumbnail": "थंबनेल लोड हो रहा है...",
|
||||
"analyzing_processing_subtitles": "उपशीर्षक प्रोसेस हो रहे हैं...",
|
||||
"analyzing_updating_table": "प्रारूप तालिका अपडेट हो रही है...",
|
||||
"analysis_complete": "विश्लेषण पूर्ण!",
|
||||
"analyzing_extracting_ytdlp": "जानकारी निकाली जा रही है...",
|
||||
"analyzing_fetching_first_video": "पहले वीडियो के लिए प्रारूप प्राप्त कर रहा है...",
|
||||
"analyzing_processing_data": "डेटा प्रोसेस हो रहा है...",
|
||||
"analyzing_processing_formats_ytdlp": "प्रारूप प्रोसेस हो रहे हैं...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "थंबनेल लोड हो रहा है...",
|
||||
"analyzing_processing_subtitles_ytdlp": "उपशीर्षक प्रोसेस हो रहे हैं...",
|
||||
"select_subtitles": "उपशीर्षक चुनें...",
|
||||
"sponsorblock_categories": "SponsorBlock श्रेणियां...",
|
||||
"invalid_url_or_enter": "अमान्य URL या कृपया URL दर्ज करें।",
|
||||
"zero_selected": "0 चयनित",
|
||||
"analyze_first_tooltip": "कृपया पहले वीडियो का विश्लेषण करें",
|
||||
"audio_mode_disabled": "केवल ऑडियो मोड में उपलब्ध नहीं",
|
||||
"select_subtitles_first": "कृपया पहले उपशीर्षक चुनें",
|
||||
"settings_tooltip": "वर्तमान पथ: {path}\nस्पीड लिमिट: {speed_limit}",
|
||||
"speed_limit_none": "कोई नहीं",
|
||||
"open_folder_error": "फ़ोल्डर नहीं खुल सका: {error}",
|
||||
"time_range_set": "सेक्शन सेट: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "प्रायोजक",
|
||||
"sponsor_desc": "पेड विज्ञापन, पेड सिफारिशें और प्रत्यक्ष विज्ञापन",
|
||||
"selfpromo": "अवैतनिक/स्व-प्रचार",
|
||||
"selfpromo_desc": "निर्माता की अपनी सामग्री के लिए अवैतनिक प्रचार",
|
||||
"interaction": "इंटरैक्शन रिमाइंडर",
|
||||
"interaction_desc": "दर्शकों से लाइक, सब्स्क्राइब या सोशल मीडिया फॉलो करने के लिए कहा जाता है",
|
||||
"intro": "परिचय",
|
||||
"intro_desc": "वीडियो परिचय जिसे छोड़ा जा सकता है",
|
||||
"outro": "आउट्रो/एंड कार्ड",
|
||||
"outro_desc": "क्रेडिट या जब वीडियो समाप्त होता है",
|
||||
"preview": "पूर्वावलोकन/सारांश",
|
||||
"preview_desc": "पिछले वीडियो का संक्षिप्त सारांश या आगामी सामग्री का पूर्वावलोकन",
|
||||
"music_offtopic": "गैर-संगीत खंड",
|
||||
"music_offtopic_desc": "केवल संगीत वीडियो के लिए। गैर-संगीत खंडों को चिह्नित करता है",
|
||||
"filler": "फिलर टैंजेंट",
|
||||
"filler_desc": "केवल फिलर या हास्य के लिए जोड़े गए विचलन दृश्य"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "चैनल",
|
||||
"views": "👁 दृश्य",
|
||||
"likes": "👍 पसंद",
|
||||
"upload_date": "📅 अपलोड दिनांक",
|
||||
"duration": "⏱ अवधि",
|
||||
"unknown_channel": "अज्ञात चैनल",
|
||||
"unknown_date": "अज्ञात दिनांक",
|
||||
"unknown_title": "अज्ञात शीर्षक"
|
||||
},
|
||||
"command": {
|
||||
"running": "चल रहा है...",
|
||||
"run_command": "कमांड चलाएं"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 चुना गया",
|
||||
"one_selected": "1 श्रेणी चुनी गई",
|
||||
"count_selected": "{count} चुने गए"
|
||||
},
|
||||
"status": {
|
||||
"ready": "तैयार",
|
||||
"file_exists": "⚠️ फ़ाइल पहले से मौजूद है",
|
||||
"video_file_exists": "⚠️ वीडियो फ़ाइल पहले से मौजूद है",
|
||||
"audio_file_exists": "⚠️ ऑडियो फ़ाइल पहले से मौजूद है",
|
||||
"subtitle_file_exists": "⚠️ उपशीर्षक फ़ाइल पहले से मौजूद है",
|
||||
"cancelling": "डाउनलोड रद्द हो रहा है...",
|
||||
"thumbnail_saved": "✅ थंबनेल सहेजा गया: {filename}",
|
||||
"thumbnail_error": "❌ थंबनेल त्रुटि: {error}",
|
||||
"thumbnail_no_image": "सहेजने के लिए कोई थंबनेल उपलब्ध नहीं"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "त्रुटि: प्लेलिस्ट में कोई वैध वीडियो नहीं है।",
|
||||
"playlist_no_url": "त्रुटि: प्लेलिस्ट के पहले वीडियो के लिए URL प्राप्त नहीं कर सके।",
|
||||
"ytdlp_not_found": "त्रुटि: yt-dlp executable नहीं मिला। कृपया पहले yt-dlp इंस्टॉल करें।",
|
||||
"ytdlp_not_found_path": "त्रुटि: yt-dlp executable नहीं मिला। यह अनुचित इंस्टॉलेशन या PATH समस्या के कारण हो सकता है।",
|
||||
"no_data_returned": "त्रुटि: yt-dlp से कोई डेटा वापस नहीं आया",
|
||||
"no_format_info": "त्रुटि: कोई प्रारूप जानकारी उपलब्ध नहीं।",
|
||||
"analysis_timeout": "त्रुटि: विश्लेषण टाइमआउट। कृपया पुनः प्रयास करें।",
|
||||
"invalid_speed_limit": "❌ त्रुटि: सेटिंग्स में अमान्य गति सीमा मान।",
|
||||
"ytdlp_failed": "त्रुटि: yt-dlp असफल: {error}",
|
||||
"parse_failed": "त्रुटि: yt-dlp आउटपुट पार्स करने में विफल: {error}",
|
||||
"analysis_failed": "त्रुटि: विश्लेषण विफल: {error}",
|
||||
"generic_error": "त्रुटि: {error}",
|
||||
"download_failed_return_code_conflict": "डाउनलोड {return_code} रिटर्न कोड के साथ विफल हुआ। यह कई yt-dlp इंस्टॉलेशन के टकराव के कारण हो सकता है। किसी भी सिस्टम-इंस्टॉल्ड yt-dlp (जैसे snap या apt) को हटाकर ऐप को रीस्टार्ट करें।",
|
||||
"download_failed_return_code": "डाउनलोड {return_code} रिटर्न कोड के साथ विफल हुआ",
|
||||
"direct_command_error": "सीधे कमांड में त्रुटि: {error}",
|
||||
"private_video": "यह वीडियो निजी हो सकता है। कृपया कस्टम विकल्पों से कुकीज़ का उपयोग करें।"
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "अपडेट उपलब्ध",
|
||||
"new_version_available": "YTSage का नया संस्करण उपलब्ध है!",
|
||||
"current_version_label": "वर्तमान संस्करण:",
|
||||
"latest_version_label": "नवीनतम संस्करण:",
|
||||
"changelog": "परिवर्तन सूची",
|
||||
"download_update": "अपडेट डाउनलोड करें",
|
||||
"remind_later": "बाद में याद दिलाएं"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "अज्ञात प्लेलिस्ट",
|
||||
"total_videos": "कुल वीडियो: {count}",
|
||||
"display_format": "प्लेलिस्ट: {title} | {count} वीडियो",
|
||||
"select_videos_title": "प्लेलिस्ट वीडियो चुनें",
|
||||
"save_as": "प्लेलिस्ट को इस रूप में सहेजें",
|
||||
"save_success_title": "सफलता",
|
||||
"saved_successfully": "प्लेलिस्ट सफलतापूर्वक सहेजी गई।",
|
||||
"save_error_title": "सहेजने में त्रुटि",
|
||||
"no_videos_to_save": "कोई प्लेलिस्ट प्रविष्टियाँ एकत्रित नहीं हुई!",
|
||||
"save_error_msg": "प्लेलिस्ट सहेजने में विफल।"
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} चुना गया"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "फ़ाइल पहले से मौजूद है",
|
||||
"message": "फ़ाइल पहले से मौजूद है:\n{filename}",
|
||||
"info": "यह वीडियो पहले ही डाउनलोड किया जा चुका है।"
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "अंतिम अपडेट जांच: कभी नहीं",
|
||||
"last_check": "अंतिम अपडेट जांच: {time}",
|
||||
"next_check_disabled": "अगली जांच: अक्षम",
|
||||
"next_check_startup": "अगली जांच: स्टार्टअप पर",
|
||||
"next_check_overdue": "अगली जांच: अभी (विलंबित)",
|
||||
"next_check": "अगली जांच: {time}",
|
||||
"next_check_error": "अगली जांच: गणना त्रुटि",
|
||||
"checking": "🔄 जांच रहे हैं...",
|
||||
"check_now": "🔍 अभी अपडेट की जांच करें",
|
||||
"current_version": "वर्तमान yt-dlp संस्करण: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL खाली नहीं हो सकता",
|
||||
"invalid_format": "अमान्य URL प्रारूप",
|
||||
"invalid_scheme": "URL http:// या https:// से शुरू होना चाहिए",
|
||||
"missing_domain": "अमान्य URL: डोमेन नाम गायब है",
|
||||
"unsupported_platform": "YTSage केवल YouTube और YouTube Music URLs का समर्थन करता है।\nडोमेन '{domain}' समर्थित नहीं है।",
|
||||
"invalid_youtu_be": "अमान्य youtu.be URL: वीडियो ID गायब है"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "यह एक निजी वीडियो है। आप कुकीज़ का उपयोग करके अपने खाते में लॉग इन करके इसे डाउनलोड कर सकते हैं।\nप्रमाणित करने के लिए 'कस्टम विकल्प' → 'कुकीज़ के साथ लॉगिन' → 'ब्राउज़र से कुकीज़ निकालें' पर जाएं।",
|
||||
"age_restricted": "यह वीडियो आयु-प्रतिबंधित है। इसे एक्सेस करने के लिए आपको लॉग इन करना होगा।\nअपने खाते से प्रमाणित करने के लिए 'कस्टम विकल्प' → 'कुकीज़ के साथ लॉगिन' का उपयोग करें।",
|
||||
"geo_blocked": "यह वीडियो आपके क्षेत्र में उपलब्ध नहीं है (भू-अवरोधित)।\nआपको VPN का उपयोग करने की आवश्यकता हो सकती है या वीडियो आपके देश में प्रतिबंधित हो सकता है।",
|
||||
"video_unavailable": "यह वीडियो हटा दिया गया है या अब उपलब्ध नहीं है।\nवीडियो को अपलोडर द्वारा हटाया जा सकता है या नीति उल्लंघन के कारण हटाया जा सकता है।",
|
||||
"live_stream": "यह एक लाइव स्ट्रीम है जिसे सक्रिय रहते हुए डाउनलोड नहीं किया जा सकता।\nस्ट्रीम समाप्त होने तक प्रतीक्षा करें, फिर संग्रहीत संस्करण डाउनलोड करने का प्रयास करें।",
|
||||
"playlist_error": "इस प्लेलिस्ट तक पहुंचने में असमर्थ। यह निजी, हटाई गई या खाली हो सकती है।\nजांचें कि प्लेलिस्ट मौजूद है और सार्वजनिक रूप से सुलभ है।",
|
||||
"network_error": "नेटवर्क कनेक्शन त्रुटि। कृपया अपना इंटरनेट कनेक्शन जांचें और पुनः प्रयास करें।\nयदि समस्या बनी रहती है, तो वीडियो सर्वर अस्थायी रूप से अनुपलब्ध हो सकता है।",
|
||||
"invalid_url": "अमान्य या असमर्थित URL। कृपया लिंक जांचें और पुनः प्रयास करें।\nसुनिश्चित करें कि आप एक मान्य YouTube, Vimeo, या अन्य समर्थित प्लेटफ़ॉर्म URL का उपयोग कर रहे हैं।",
|
||||
"premium_content": "इस सामग्री के लिए YouTube Premium या चैनल सदस्यता की आवश्यकता है।\nआपको ऐसे खाते से लॉग इन करना होगा जिसके पास इस सामग्री तक पहुंच है।",
|
||||
"copyright_blocked": "कॉपीराइट दावों के कारण यह वीडियो अवरुद्ध है।\nसामग्री स्वामी ने इस वीडियो तक पहुंच प्रतिबंधित कर दी है।",
|
||||
"extraction_failed": "वीडियो जानकारी निकालने में विफल। यह एक अस्थायी समस्या हो सकती है।\nकृपया कुछ मिनट में पुनः प्रयास करें, या जांचें कि वीडियो लिंक सही है या नहीं।",
|
||||
"generic_error": "वीडियो जानकारी निकालने में विफल। कृपया अपना लिंक जांचें।\nतकनीकी विवरण: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "डाउनलोड इतिहास",
|
||||
"clear_all": "सभी साफ़ करें",
|
||||
"clear_confirm_title": "इतिहास साफ़ करें?",
|
||||
"clear_confirm_message": "क्या आप निश्चित हैं? इसे पूर्ववत नहीं किया जा सकता।",
|
||||
"no_history": "अभी तक कोई इतिहास नहीं",
|
||||
"no_history_description": "आपके डाउनलोड यहां दिखाई देंगे",
|
||||
"loading": "इतिहास लोड हो रहा है...",
|
||||
"search_placeholder": "खोजें...",
|
||||
"open_location": "स्थान खोलें",
|
||||
"redownload": "फिर से डाउनलोड करें",
|
||||
"remove": "हटाएं",
|
||||
"file_not_found": "फ़ाइल नहीं मिली",
|
||||
"file_not_found_message": "फ़ाइल स्थानांतरित या हटाई गई:\n{path}",
|
||||
"downloaded_on": "डाउनलोड किया: {date}",
|
||||
"file_size": "आकार: {size}",
|
||||
"audio_download": "ऑडियो",
|
||||
"video_download": "वीडियो",
|
||||
"remove_confirm_title": "हटाएं?",
|
||||
"remove_confirm_message": "इतिहास से हटाएं?\n\n{title}",
|
||||
"item_removed": "हटाया गया",
|
||||
"history_cleared": "इतिहास साफ़ किया गया",
|
||||
"entries_count": "{count} डाउनलोड",
|
||||
"one_entry": "1 डाउनलोड",
|
||||
"redownload_confirm_title": "फिर से डाउनलोड करें?",
|
||||
"redownload_confirm_message": "फिर से डाउनलोड करें?\n\n{title}",
|
||||
"redownload_started": "डाउनलोड शुरू हुआ",
|
||||
"no_url_error": "हिस्ट्री एंट्री में कोई URL नहीं मिला",
|
||||
"redownload_failed": "रीडाउनलोड शुरू करने में विफल: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "FFmpeg इंस्टॉलेशन",
|
||||
"installation_message": "वीडियो प्रोसेस करने के लिए YTSage को FFmpeg चाहिए।\n\nनीचे एक इंस्टॉलेशन विकल्प चुनें:",
|
||||
"install_button": "FFmpeg इंस्टॉल करें",
|
||||
"manual_guide": "मैन्युअल गाइड",
|
||||
"installation_failed": "FFmpeg इंस्टॉलेशन में समस्या आई।",
|
||||
"already_installed": "FFmpeg पहले से इंस्टॉल है!",
|
||||
"installation_complete": "इंस्टॉलेशन पूरा हुआ। आप इस डायलॉग को बंद कर YTSage उपयोग कर सकते हैं।",
|
||||
"installing": "FFmpeg इंस्टॉल हो रहा है... कृपया प्रतीक्षा करें",
|
||||
"install_success": "FFmpeg सफलतापूर्वक इंस्टॉल हो गया!",
|
||||
"installation_complete_close": "इंस्टॉलेशन पूरा हुआ। अब आप इस डायलॉग को बंद कर YTSage उपयोग कर सकते हैं।",
|
||||
"try_manual": "कृपया इसके बजाय मैन्युअल इंस्टॉलेशन गाइड का उपयोग करें।"
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "yt-dlp सेटअप आवश्यक",
|
||||
"description": "वीडियो डाउनलोड करने के लिए YTSage को yt-dlp चाहिए।<br><br>ऐप की लोकल डायरेक्टरी में yt-dlp नहीं मिला। YTSage को आपके {os_name} सिस्टम के लिए yt-dlp सेटअप करना होगा।<br><br>कृपया नीचे एक विकल्प चुनें:",
|
||||
"option_auto": "अपने आप डाउनलोड करें (सिफ़ारिश की गई)",
|
||||
"option_manual": "पाथ मैन्युअली चुनें",
|
||||
"setup_button": "yt-dlp सेटअप",
|
||||
"downloading": "yt-dlp डाउनलोड हो रहा है...",
|
||||
"success": "yt-dlp सफलतापूर्वक इंस्टॉल हो गया!",
|
||||
"error": "त्रुटि: {error}",
|
||||
"download_failed_title": "डाउनलोड विफल",
|
||||
"download_failed_message": "yt-dlp डाउनलोड नहीं हो सका: {error}",
|
||||
"select_executable_title": "yt-dlp executable चुनें",
|
||||
"copied_to": "yt-dlp को {path} पर सफलतापूर्वक कॉपी किया गया",
|
||||
"setup_error_title": "सेटअप त्रुटि",
|
||||
"copy_error": "yt-dlp को ऐप डायरेक्टरी में कॉपी करने में त्रुटि: {error}",
|
||||
"invalid_executable_title": "अमान्य executable",
|
||||
"invalid_executable_message": "चुनी गई फ़ाइल वैध yt-dlp executable नहीं लगती।",
|
||||
"verify_error": "yt-dlp executable सत्यापित करने में त्रुटि: {error}",
|
||||
"setup_failed_title": "सेटअप विफल",
|
||||
"setup_failed_message": "yt-dlp सेटअप विफल रहा। कुछ फीचर सही से काम नहीं कर सकते।",
|
||||
"success_dialog_title": "yt-dlp सेटअप",
|
||||
"success_dialog_message": "yt-dlp सफलतापूर्वक इस स्थान पर कॉन्फ़िगर हुआ है:\n{path}",
|
||||
"file_filter_windows": "Executable फ़ाइलें (*.exe)",
|
||||
"file_filter_all": "सभी फ़ाइलें (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "FFmpeg संस्करण जांचकर्ता",
|
||||
"current_version": "वर्तमान संस्करण:",
|
||||
"latest_version": "नवीनतम संस्करण:",
|
||||
"status_up_to_date": "✓ अप टू डेट",
|
||||
"status_update_available": "⚠ अपडेट उपलब्ध",
|
||||
"status_not_installed": "✗ इंस्टॉल नहीं है",
|
||||
"status_idle": "शुरू करने के लिए 'संस्करण जांचें' पर क्लिक करें",
|
||||
"status_checking": "🔄 संस्करण जांच रहे हैं...",
|
||||
"check_updates": "संस्करण जांचें",
|
||||
"check_failed": "संस्करण जांच विफल रही। कृपया अपना इंटरनेट कनेक्शन जांचें।",
|
||||
"description": "अपने FFmpeg संस्करण की जांच करें और इसे नवीनतम उपलब्ध संस्करण से तुलना करें।",
|
||||
"guide_info": " FFmpeg को इंस्टॉल या अपडेट करने के लिए, <a href='https://github.com/oop7/ffmpeg-install-guide'>हमारी व्यापक इंस्टॉलेशन गाइड देखने के लिए यहां क्लिक करें</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Deno सेटअप आवश्यक",
|
||||
"setup_description": "YTSage को कुछ विशेषताओं को चलाने के लिए Deno की आवश्यकता है।<br><br>Deno एप की स्थानीय निर्देशिका में नहीं मिला। YTSage को आपकी {os_name} प्रणाली के लिए Deno सेटअप करने की आवश्यकता है।<br><br>स्वचालित रूप से डाउनलोड और इंस्टॉल करने के लिए 'Deno सेटअप करें' पर क्लिक करें।",
|
||||
"setup_button": "Deno सेटअप करें",
|
||||
"downloading": "Deno डाउनलोड हो रहा है...",
|
||||
"extracting": "Deno निकाला जा रहा है...",
|
||||
"verifying": "Deno की जांच हो रही है...",
|
||||
"success": "Deno सफलतापूर्वक स्थापित हो गया!",
|
||||
"download_failed": "डाउनलोड विफल",
|
||||
"download_error": "Deno डाउनलोड करने में विफल: {error}",
|
||||
"verification_failed": "SHA256 सत्यापन विफल। डाउनलोड की गई फ़ाइल दूषित या छेड़छाड़ की गई हो सकती है।",
|
||||
"setup_failed": "Deno सेटअप करने में विफल। कुछ विशेषताएं सही ढंग से काम नहीं कर सकती हैं।",
|
||||
"setup_error": "सेटअप त्रुटि"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Deno संस्करण जांचकर्ता और अपडेटर",
|
||||
"description": "अपने Deno संस्करण की जांच करें और नवीनतम संस्करण में अपडेट करें।",
|
||||
"current_version": "वर्तमान संस्करण:",
|
||||
"latest_version": "नवीनतम संस्करण:",
|
||||
"status_idle": "शुरू करने के लिए 'अपडेट की जांच करें' पर क्लिक करें",
|
||||
"status_checking": "🔄 अपडेट की जांच हो रही है...",
|
||||
"status_up_to_date": "✓ अपडेट है",
|
||||
"status_update_available": "⚠ अपडेट उपलब्ध है",
|
||||
"status_not_installed": "✗ स्थापित नहीं है",
|
||||
"check_updates": "अपडेट की जांच करें",
|
||||
"update_now": "Deno अपडेट करें",
|
||||
"updating": "🔄 Deno अपडेट हो रहा है...",
|
||||
"update_success": "✅ Deno सफलतापूर्वक अपडेट हो गया है!",
|
||||
"update_failed": "❌ अपडेट विफल: {error}",
|
||||
"check_failed": "अपडेट की जांच विफल। कृपया अपना इंटरनेट कनेक्शन जांचें।"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Bahasa Indonesia (Indonesian)",
|
||||
"select_language": "Pilih Bahasa:",
|
||||
"current_language": "Bahasa saat ini: {language}",
|
||||
"restart_required": "Perubahan bahasa akan berlaku setelah memulai ulang aplikasi.",
|
||||
"english": "Bahasa Inggris",
|
||||
"spanish": "Bahasa Spanyol",
|
||||
"portuguese": "Bahasa Portugis",
|
||||
"russian": "Bahasa Rusia",
|
||||
"chinese": "Bahasa Mandarin",
|
||||
"german": "Bahasa Jerman",
|
||||
"french": "Bahasa Prancis",
|
||||
"hindi": "Bahasa Hindi",
|
||||
"indonesian": "Bahasa Indonesia",
|
||||
"turkish": "Bahasa Turki",
|
||||
"polish": "Bahasa Polandia",
|
||||
"italian": "Bahasa Italia",
|
||||
"arabic": "Bahasa Arab",
|
||||
"japanese": "Bahasa Jepang",
|
||||
"help_text": "Pilih bahasa yang diinginkan untuk antarmuka.",
|
||||
"restart_notice": "Perubahan bahasa akan berlaku setelah memulai ulang aplikasi."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Siap"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Tampilkan format:",
|
||||
"video_format": "Format video:",
|
||||
"audio_format": "Format audio:",
|
||||
"no_formats": "Tidak ada format yang tersedia",
|
||||
"loading": "Memuat format...",
|
||||
"select": "Pilih",
|
||||
"quality": "Kualitas",
|
||||
"extension": "Ekstensi",
|
||||
"resolution": "Resolusi",
|
||||
"file_size": "Ukuran file",
|
||||
"codec": "Codec",
|
||||
"audio": "Audio",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Audio akan digabungkan",
|
||||
"has_audio": "✓ Memiliki audio",
|
||||
"audio_only": "Audio saja",
|
||||
"best_4k": "Terbaik (4K)",
|
||||
"best_2k": "Terbaik (2K)",
|
||||
"high_1080p": "Tinggi (1080p)",
|
||||
"high_720p": "Tinggi (720p)",
|
||||
"medium_480p": "Sedang (480p)",
|
||||
"low_quality": "Kualitas rendah",
|
||||
"best_audio": "Audio terbaik",
|
||||
"high_audio": "Audio tinggi",
|
||||
"medium_audio": "Audio sedang",
|
||||
"low_audio": "Audio rendah",
|
||||
"audio_only_resolution": "Audio saja"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Atur Ulang",
|
||||
"download": "Unduh",
|
||||
"pause": "Jeda",
|
||||
"resume": "Lanjutkan",
|
||||
"cancel": "Batal",
|
||||
"browse": "Jelajahi",
|
||||
"clear": "Bersihkan",
|
||||
"ok": "OK",
|
||||
"apply": "Terapkan",
|
||||
"close": "Tutup",
|
||||
"run_command": "Jalankan perintah",
|
||||
"custom_command_help": "Bantuan",
|
||||
"about": "Tentang",
|
||||
"analyze": "Analisis",
|
||||
"paste_url": "Tempel URL",
|
||||
"select_videos": "Pilih video...",
|
||||
"video": "Video",
|
||||
"audio_only": "Audio saja",
|
||||
"custom_options": "Opsi khusus",
|
||||
"trim_video": "Potong video",
|
||||
"download_settings": "Pengaturan unduhan",
|
||||
"update": "Perbarui yt-dlp",
|
||||
"select_defaults": "Pilih default",
|
||||
"select_all": "Pilih semua",
|
||||
"deselect_all": "Batalkan pilihan semua",
|
||||
"open_folder": "Buka lokasi folder",
|
||||
"history": "Riwayat",
|
||||
"save_playlist": "Simpan Playlist Sebagai"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Opsi khusus",
|
||||
"settings": "Pengaturan",
|
||||
"select_folder": "Pilih folder unduhan",
|
||||
"sponsorblock_categories": "Kategori SponsorBlock",
|
||||
"sponsorblock_description": "Pilih jenis segmen video yang akan dihapus secara otomatis selama pengunduhan.\nSponsorBlock menggunakan data yang dikirimkan komunitas untuk mengidentifikasi segmen ini.",
|
||||
"select_subtitles": "Pilih subtitle",
|
||||
"filter_languages_placeholder": "Filter bahasa (misalnya: id, en)...",
|
||||
"no_subtitles_available": "Tidak ada subtitle yang tersedia",
|
||||
"matching": "yang cocok",
|
||||
"ytdlp_log_title": "Log yt-dlp",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Masuk dengan cookies",
|
||||
"custom_command": "Perintah khusus",
|
||||
"proxy": "Proxy",
|
||||
"language": "Bahasa",
|
||||
"updater": "Pembaruan"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Pilih cara memberikan cookies untuk autentikasi.\nIni memungkinkan pengunduhan video pribadi dan file audio berkualitas tinggi.",
|
||||
"cookie_source": "Sumber cookie",
|
||||
"use_cookie_file": "Gunakan file cookie",
|
||||
"extract_from_browser": "Ekstrak dari browser",
|
||||
"recommended": "Direkomendasikan",
|
||||
"cookie_file": "File cookie",
|
||||
"cookie_file_placeholder": "Jalur ke file cookies.txt...",
|
||||
"browser_selection": "Pilihan browser",
|
||||
"browser_help": "Pilih browser untuk mengekstrak cookies:",
|
||||
"browser_label": "Browser:",
|
||||
"profile_label": "Profil:",
|
||||
"profile_placeholder": "Default",
|
||||
"browser_extract_message": "Cookies browser akan diekstrak saat diterapkan",
|
||||
"file_selected_message": "File cookie dipilih - Klik OK untuk menerapkan",
|
||||
"select_file_title": "Pilih file cookie",
|
||||
"file_filter": "File cookie (*.txt *.lwp)",
|
||||
"file_selected_title": "File Cookie Diterapkan",
|
||||
"file_applied_message": "File cookie diterapkan: {path}",
|
||||
"browser_selected_title": "Cookies Browser Diterapkan",
|
||||
"browser_applied_message": "Cookies browser akan diekstrak dari: {browser}",
|
||||
"cleared_title": "Cookies Dihapus",
|
||||
"cleared_message": "Pengaturan cookie telah dihapus",
|
||||
"active_browser": "✓ Aktif: cookie browser ({browser})",
|
||||
"active_file": "✓ Aktif: file cookie ({file})",
|
||||
"none_active": "○ Tidak ada cookie aktif",
|
||||
"remember_settings": "Ingat pengaturan cookie pada startup berikutnya"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Masukkan perintah yt-dlp khusus Anda di bawah. URL saat ini akan ditambahkan secara otomatis.<br><br>Untuk daftar lengkap opsi dan contoh penggunaan <a href=\"{docs_url}\">klik di sini untuk melihat dokumentasi resmi yt-dlp</a>.<br><br>Catatan: Jalur unduhan dan template nama file ditangani secara otomatis.",
|
||||
"input_label": "Argumen yt-dlp:",
|
||||
"input_placeholder": "Masukkan argumen yt-dlp di sini...\n\nContoh: --extract-audio --audio-format mp3",
|
||||
"output_label": "Output perintah:",
|
||||
"output_placeholder": "Output perintah akan muncul di sini...",
|
||||
"command_placeholder": "Masukkan perintah yt-dlp khusus...",
|
||||
"command_help": "Placeholder yang tersedia:\n{url} - URL video\n{output} - Folder output\n\nContoh: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Perintah lengkap: {command}",
|
||||
"command_success": "✅ Perintah khusus berhasil dijalankan!",
|
||||
"command_failed": "❌ Perintah gagal dengan kode keluar {code}",
|
||||
"command_error": "❌ Kesalahan menjalankan perintah khusus: {error}",
|
||||
"error_no_url": "❌ Error: Tidak ada URL yang diberikan. Masukkan URL di jendela utama.",
|
||||
"error_no_command": "❌ Error: Tidak ada perintah yang diberikan. Masukkan argumen yt-dlp.",
|
||||
"executing": "🚀 Menjalankan perintah yt-dlp kustom",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Argumen: {command}",
|
||||
"download_path_label": "📁 Lokasi unduhan: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Konfigurasi pengaturan proxy untuk unduhan. Biarkan kosong untuk koneksi langsung.",
|
||||
"main_proxy": "Proxy utama",
|
||||
"main_proxy_help": "Server proxy utama untuk semua unduhan. Mendukung protokol HTTP/HTTPS dan SOCKS5.",
|
||||
"proxy_url_label": "URL Proxy:",
|
||||
"proxy_url_placeholder": "http://proxy:port atau socks5://proxy:port",
|
||||
"proxy_examples": "Contoh:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Proxy bypass geografis",
|
||||
"geo_proxy_help": "Proxy sekunder khusus untuk mem-bypass pembatasan geografis.",
|
||||
"geo_proxy_url_label": "URL proxy bypass geografis:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "Hapus proxy utama",
|
||||
"clear_geo_proxy": "Hapus proxy geografis",
|
||||
"proxy_url": "URL Proxy",
|
||||
"proxy_placeholder": "http://proxy:port atau socks5://proxy:port",
|
||||
"geo_bypass": "Proxy bypass geografis (untuk pembatasan geografis)",
|
||||
"geo_bypass_placeholder": "http://proxy:port untuk mem-bypass geo-blocking",
|
||||
"invalid_main_url": "Format URL proxy utama tidak valid",
|
||||
"invalid_geo_url": "Format URL proxy geografis tidak valid",
|
||||
"main_configured": "Proxy utama dikonfigurasi",
|
||||
"geo_configured": "Proxy geografis dikonfigurasi",
|
||||
"set_title": "Proxy diatur",
|
||||
"set_message": "Proxy utama diatur dan disimpan: {proxy}",
|
||||
"geo_set_title": "Proxy geo diatur",
|
||||
"geo_set_message": "Proxy verifikasi geo diatur dan disimpan: {proxy}",
|
||||
"cleared_title": "Pengaturan proxy dibersihkan",
|
||||
"cleared_message": "Semua pengaturan proxy telah dibersihkan dan disimpan.",
|
||||
"saved_main": "Proxy utama tersimpan: {proxy}",
|
||||
"saved_geo": "Proxy geo tersimpan: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Mempersiapkan unduhan...",
|
||||
"starting": "🚀 Memulai unduhan...",
|
||||
"fetching_info": "🔍 Mengambil informasi video...",
|
||||
"preparing_streams": "🎯 Mempersiapkan aliran video...",
|
||||
"downloading_audio": "⏬ Mengunduh audio...",
|
||||
"downloading_video": "⏬ Mengunduh video...",
|
||||
"downloading_subtitle": "⏬ Mengunduh subtitle...",
|
||||
"downloading": "⏬ Mengunduh...",
|
||||
"completed": "✅ Unduhan selesai!",
|
||||
"video_completed": "✅ Unduhan video selesai!",
|
||||
"audio_completed": "✅ Unduhan audio selesai!",
|
||||
"subtitle_completed": "✅ Unduhan subtitle selesai!",
|
||||
"completed_cleaning": "✅ Unduhan selesai! Membersihkan...",
|
||||
"cancelled": "Unduhan dibatalkan",
|
||||
"processing_playlist": "📋 Memproses data playlist...",
|
||||
"paused": "Unduhan dijeda",
|
||||
"resumed": "Unduhan dilanjutkan",
|
||||
"merging_formats": "✨ Pasca-pemrosesan: Menggabungkan format...",
|
||||
"removing_sponsor_segments": "✨ Pasca-pemrosesan: Menghapus segmen sponsor...",
|
||||
"speed": "Kecepatan",
|
||||
"eta": "Waktu tersisa",
|
||||
"please_enter_url": "Silakan masukkan URL terlebih dahulu.",
|
||||
"please_set_path": "Silakan atur jalur unduhan terlebih dahulu.",
|
||||
"please_enter_url_and_path": "Silakan masukkan URL dan atur jalur unduhan.",
|
||||
"please_select_format": "Silakan pilih format terlebih dahulu.",
|
||||
"downloading_fallback": "⚡ Mengunduh..."
|
||||
},
|
||||
"update": {
|
||||
"title": "Perbarui yt-dlp",
|
||||
"checking": "Memeriksa pembaruan...",
|
||||
"update_available": "Pembaruan tersedia!\nVersi saat ini: {current}\nVersi terbaru: {latest}",
|
||||
"up_to_date": "yt-dlp sudah terbaru (Versi {version})",
|
||||
"could_not_determine": "Tidak dapat menentukan versi.",
|
||||
"error_comparing": "Kesalahan membandingkan versi: {error}",
|
||||
"update_available_failed": "Pembaruan tersedia! (Perbandingan gagal)\nSaat ini: {current}\nTerbaru: {latest}",
|
||||
"updating": "Memperbarui...",
|
||||
"initializing": "🚀 Menginisialisasi proses pembaruan...",
|
||||
"checking_current": "🔍 Memeriksa instalasi saat ini...",
|
||||
"found_at": "📍 yt-dlp ditemukan di: {path}",
|
||||
"error_getting_path": "❌ Kesalahan mendapatkan jalur yt-dlp: {error}",
|
||||
"updating_binary": "📦 Memperbarui binary yt-dlp yang dikelola aplikasi...",
|
||||
"update_failed": "❌ Pembaruan yt-dlp gagal. Silakan coba lagi atau periksa koneksi internet Anda.",
|
||||
"binary_updated": "✅ Binary berhasil diperbarui!",
|
||||
"update_failed_stderr": "❌ Pembaruan yt-dlp gagal: {error}",
|
||||
"update_timeout": "❌ Timeout pembaruan yt-dlp.",
|
||||
"unexpected_error": "❌ Kesalahan tak terduga selama pembaruan: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp sudah terbaru!",
|
||||
"update_success": "✅ yt-dlp telah berhasil diperbarui!",
|
||||
"already_latest": "yt-dlp sudah terbaru (versi {version})",
|
||||
"network_error": "❌ Kesalahan jaringan selama pembaruan: {error}",
|
||||
"general_error": "❌ Pembaruan gagal: {error}",
|
||||
"update_in_progress_title": "Pembaruan Sedang Berlangsung",
|
||||
"update_in_progress_message": "yt-dlp sedang diperbarui. Mohon tunggu sebentar."
|
||||
},
|
||||
"about": {
|
||||
"title": "Tentang YTSage",
|
||||
"version": "Versi {version}",
|
||||
"description": "Pengunduh YouTube modern dengan antarmuka PySide6 yang bersih.",
|
||||
"author": "Oleh: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Informasi sistem",
|
||||
"loading": "🔄 Memuat informasi sistem...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Log",
|
||||
"logs_tooltip": "Buka folder log aplikasi",
|
||||
"refreshing": "🔄 Menyegarkan...",
|
||||
"refresh_failed": "Gagal menyegarkan",
|
||||
"refresh_failed_message": "Tidak dapat menyegarkan informasi versi.",
|
||||
"detected": "✓ Terdeteksi",
|
||||
"missing": "✗ Hilang",
|
||||
"not_available": "Tidak tersedia"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Potong video",
|
||||
"time_range_group": "Rentang waktu",
|
||||
"start_time": "Waktu mulai (HH:MM:SS)",
|
||||
"end_time": "Waktu selesai (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Waktu mulai (HH:MM:SS)",
|
||||
"end_time_placeholder": "Waktu selesai (HH:MM:SS)",
|
||||
"force_keyframes": "Paksa keyframe pada titik potong",
|
||||
"help_text": "Atur waktu mulai dan selesai untuk memotong video.\nBiarkan kosong untuk mengunduh video penuh.",
|
||||
"invalid_format": "Format waktu tidak valid. Gunakan format HH:MM:SS.",
|
||||
"start_after_end": "Waktu mulai tidak boleh setelah waktu selesai."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Pengaturan unduhan",
|
||||
"download_path": "Jalur unduhan",
|
||||
"browse": "Jelajahi...",
|
||||
"speed_limit": "Batas kecepatan",
|
||||
"speed_limit_placeholder": "Tidak ada",
|
||||
"generic_mode": "Mode generik",
|
||||
"enable_generic_mode": "Aktifkan mode generik (mendukung situs non-YouTube)",
|
||||
"generic_mode_help": "Memungkinkan pengunduhan dari Dailymotion, CBC Gem, dan situs lain yang didukung oleh yt-dlp.",
|
||||
"auto_update_ytdlp": "Pembaruan otomatis yt-dlp",
|
||||
"enable_auto_updates": "Aktifkan pembaruan otomatis yt-dlp",
|
||||
"update_frequency": "Frekuensi pembaruan:",
|
||||
"check_startup": "Periksa setiap startup (minimal 1 jam antara pemeriksaan)",
|
||||
"check_daily": "Periksa harian",
|
||||
"check_weekly": "Periksa mingguan",
|
||||
"check_updates_now": "Perbarui yt-dlp",
|
||||
"update_check_title": "Pemeriksaan pembaruan",
|
||||
"could_not_determine_version": "Tidak dapat menentukan versi yt-dlp saat ini.",
|
||||
"update_available_dialog": "Pembaruan tersedia!\n\nSaat ini: {current}\nTerbaru: {latest}\n\nKlik OK untuk melanjutkan pembaruan.",
|
||||
"up_to_date_dialog": "yt-dlp sudah terbaru!\n\nVersi saat ini: {version}",
|
||||
"error_checking_updates": "Kesalahan memeriksa pembaruan: {error}",
|
||||
"settings_saved_title": "Pengaturan disimpan",
|
||||
"settings_saved_message": "Pengaturan pembaruan otomatis berhasil disimpan!",
|
||||
"error_title": "Kesalahan",
|
||||
"failed_save_settings": "Gagal menyimpan pengaturan pembaruan otomatis.",
|
||||
"error_saving_settings": "Kesalahan menyimpan pengaturan pembaruan otomatis: {error}",
|
||||
"ytdlp_channel": "Saluran Rilis yt-dlp",
|
||||
"ytdlp_channel_stable": "Stabil (Rilis yang diuji)",
|
||||
"ytdlp_channel_nightly": "Nightly (Pembaruan harian, direkomendasikan oleh yt-dlp)",
|
||||
"ytdlp_channel_description": "Pilih antara rilis stabil dan nightly. Build nightly diperbarui setiap hari dengan perbaikan dan fitur terbaru. Anda dapat berganti saluran kapan saja.",
|
||||
"ytdlp_switching_channel": "Beralih ke saluran {channel}...",
|
||||
"ytdlp_channel_switched": "✅ Berhasil beralih ke saluran {channel}!",
|
||||
"ytdlp_channel_switch_failed": "❌ Gagal beralih saluran: {error}",
|
||||
"ytdlp_current_channel": "Saluran saat ini: {channel}",
|
||||
"app_updates_title": "Pembaruan YTSage",
|
||||
"check_app_updates": "Periksa pembaruan YTSage saat startup",
|
||||
"check_beta_updates": "Terima Pembaruan Beta",
|
||||
"auto_update_title": "Pengaturan pembaruan otomatis",
|
||||
"auto_update_header": "🔄 Pengaturan pembaruan otomatis",
|
||||
"auto_update_description": "Konfigurasi pembaruan otomatis untuk yt-dlp untuk memastikan Anda selalu memiliki fitur dan perbaikan bug terbaru.",
|
||||
"current_status": "Status saat ini",
|
||||
"current_version_label": "Versi yt-dlp saat ini: Memeriksa...",
|
||||
"last_check_label": "Pemeriksaan pembaruan terakhir: Tidak pernah",
|
||||
"next_check_label": "Pemeriksaan berikutnya: Berdasarkan pengaturan",
|
||||
"manual_check_button": "🔍 Periksa pembaruan sekarang",
|
||||
"update_frequency_group": "Frekuensi pembaruan",
|
||||
"save_settings": "Simpan pengaturan",
|
||||
"settings_saved_successfully": "✅ Pengaturan berhasil disimpan!",
|
||||
"error_saving": "❌ Kesalahan menyimpan pengaturan: {error}",
|
||||
"output_format_settings": "Pengaturan Format Output",
|
||||
"force_output_format": "Paksa format output saat menggabungkan",
|
||||
"preferred_format": "Format yang disukai:",
|
||||
"force_format_help": "Jika diaktifkan, video yang digabungkan akan dikonversi ke format pilihan Anda. Nonaktifkan untuk membiarkan yt-dlp memutuskan secara otomatis.",
|
||||
"format_mp4": "MP4 (Paling kompatibel)",
|
||||
"format_webm": "WebM (Modern, terbuka)",
|
||||
"format_mkv": "MKV (Kaya fitur)",
|
||||
"audio_format_settings": "Pengaturan Format Audio",
|
||||
"force_audio_format": "Paksa format audio untuk unduhan khusus audio",
|
||||
"audio_normalization": "Normalisasi Audio (EBU R128)",
|
||||
"audio_normalization_help": "Jika diaktifkan, trek audio akan dinormalisasi. Catatan: Ini memerlukan pengkodean ulang, jadi format audio tertentu (seperti MP3 atau M4A) harus dipaksakan.",
|
||||
"preferred_audio_format": "Format audio pilihan:",
|
||||
"force_audio_format_help": "Saat diaktifkan, unduhan khusus audio akan dikonversi ke format pilihan Anda. Ini hanya berlaku saat mengunduh format audio.",
|
||||
"audio_format_best": "Terbaik (Tanpa konversi)",
|
||||
"audio_format_aac": "AAC (Bagus untuk editing)",
|
||||
"audio_format_mp3": "MP3 (Universal)",
|
||||
"audio_format_flac": "FLAC (Lossless)",
|
||||
"audio_format_wav": "WAV (Tidak terkompresi)",
|
||||
"audio_format_opus": "Opus (Efisien)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Terbuka)",
|
||||
"filename_format": "Format Nama File Keluaran",
|
||||
"filename_format_help": "Variabel yang tersedia: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. Sintaks templat keluaran standar yt-dlp didukung.",
|
||||
"tab_general": "Umum",
|
||||
"tab_format": "Format",
|
||||
"tab_file": "Berkas",
|
||||
"concurrent_fragments": "Koneksi Simultan",
|
||||
"concurrent_fragments_help": "Jumlah koneksi per unduhan. Nilai yang lebih tinggi melewati pembatasan tetapi dapat menyebabkan pemblokiran sementara jika diatur terlalu tinggi. Default: 1.",
|
||||
"defaults_settings": "Pengaturan Pilihan Default",
|
||||
"default_video_quality": "Resolusi Video Default (Tinggi):",
|
||||
"default_subtitle_language": "Bahasa Subtitel Default:",
|
||||
"defaults_help": "Atur tinggi video dan bahasa subtitel pilihan Anda (pisahkan dengan koma). Mereka akan dipilih otomatis jika tersedia."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Masukkan URL video atau playlist YouTube",
|
||||
"url_placeholder_generic": "Masukkan URL video atau playlist dari situs apa pun yang didukung",
|
||||
"merge_subtitles": "Gabungkan subtitle",
|
||||
"save_thumbnail": "Simpan thumbnail",
|
||||
"save_description": "Simpan deskripsi",
|
||||
"embed_chapters": "Sematkan bab",
|
||||
"subtitles_selected": "{count} dipilih",
|
||||
"all_selected": "Semua dipilih",
|
||||
"select_videos_all": "Pilih video... (Semua dipilih)",
|
||||
"please_enter_url": "Silakan masukkan URL terlebih dahulu",
|
||||
"error_title": "Kesalahan",
|
||||
"cookie_file_selected_title": "File cookie dipilih",
|
||||
"cookie_file_selected_message": "File cookie dipilih: {path}",
|
||||
"browser_cookies_selected_title": "Cookies browser dipilih",
|
||||
"browser_cookies_selected_message": "Cookies browser akan diekstrak dari: {browser}",
|
||||
"error_no_format_info": "Kesalahan: Tidak ada informasi format yang tersedia.",
|
||||
"error_extract_info": "Kesalahan: Tidak dapat mengekstrak informasi video dasar. Silakan periksa tautan Anda.",
|
||||
"analyzing_preparing": "Mempersiapkan permintaan...",
|
||||
"analyzing_extracting_basic": "Mengekstrak informasi dasar...",
|
||||
"analyzing_extracting_detailed": "Mengekstrak informasi detail...",
|
||||
"analyzing_processing_video": "Memproses data video...",
|
||||
"analyzing_processing_formats": "Memproses format...",
|
||||
"analyzing_loading_thumbnail": "Memuat thumbnail...",
|
||||
"analyzing_processing_subtitles": "Memproses subtitle...",
|
||||
"analyzing_updating_table": "Memperbarui tabel format...",
|
||||
"analysis_complete": "Analisis selesai!",
|
||||
"analyzing_extracting_ytdlp": "Mengekstrak informasi...",
|
||||
"analyzing_fetching_first_video": "Mengambil format untuk video pertama...",
|
||||
"analyzing_processing_data": "Memproses data...",
|
||||
"analyzing_processing_formats_ytdlp": "Memproses format...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Memuat thumbnail...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Memproses subtitle...",
|
||||
"select_subtitles": "Pilih subtitle...",
|
||||
"sponsorblock_categories": "Kategori SponsorBlock...",
|
||||
"invalid_url_or_enter": "URL tidak valid atau silakan masukkan URL.",
|
||||
"zero_selected": "0 dipilih",
|
||||
"analyze_first_tooltip": "Silakan analisis video terlebih dahulu",
|
||||
"audio_mode_disabled": "Tidak tersedia dalam mode audio saja",
|
||||
"select_subtitles_first": "Silakan pilih subtitle terlebih dahulu",
|
||||
"settings_tooltip": "Path saat ini: {path}\nBatas kecepatan: {speed_limit}",
|
||||
"speed_limit_none": "Tidak ada",
|
||||
"open_folder_error": "Tidak dapat membuka folder: {error}",
|
||||
"time_range_set": "Bagian disetel: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Sponsor",
|
||||
"sponsor_desc": "Iklan berbayar, rekomendasi berbayar dan iklan langsung",
|
||||
"selfpromo": "Promosi diri tidak berbayar",
|
||||
"selfpromo_desc": "Iklan tidak berbayar untuk konten kreator sendiri",
|
||||
"interaction": "Pengingat interaksi",
|
||||
"interaction_desc": "Pemirsa diminta untuk menyukai, berlangganan atau mengikuti media sosial",
|
||||
"intro": "Intro",
|
||||
"intro_desc": "Intro video yang dapat dilewati",
|
||||
"outro": "Outro/Kartu akhir",
|
||||
"outro_desc": "Kredit atau saat video berakhir",
|
||||
"preview": "Pratinjau/Ringkasan",
|
||||
"preview_desc": "Ringkasan singkat video sebelumnya atau pratinjau konten mendatang",
|
||||
"music_offtopic": "Bagian non-musik",
|
||||
"music_offtopic_desc": "Hanya untuk video musik. Menandai bagian non-musik",
|
||||
"filler": "Tangensial pengisi",
|
||||
"filler_desc": "Adegan tangensial yang ditambahkan hanya sebagai pengisi atau untuk humor"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Saluran",
|
||||
"views": "👁 Tayangan",
|
||||
"likes": "👍 Suka",
|
||||
"upload_date": "📅 Tanggal upload",
|
||||
"duration": "⏱ Durasi",
|
||||
"unknown_channel": "Saluran tidak diketahui",
|
||||
"unknown_date": "Tanggal tidak diketahui",
|
||||
"unknown_title": "Judul tidak diketahui"
|
||||
},
|
||||
"command": {
|
||||
"running": "Berjalan...",
|
||||
"run_command": "Jalankan perintah"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 dipilih",
|
||||
"one_selected": "1 kategori dipilih",
|
||||
"count_selected": "{count} dipilih"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Siap",
|
||||
"file_exists": "⚠️ File sudah ada",
|
||||
"video_file_exists": "⚠️ File video sudah ada",
|
||||
"audio_file_exists": "⚠️ File audio sudah ada",
|
||||
"subtitle_file_exists": "⚠️ File subtitle sudah ada",
|
||||
"cancelling": "Membatalkan unduhan...",
|
||||
"thumbnail_saved": "✅ Thumbnail disimpan: {filename}",
|
||||
"thumbnail_error": "❌ Kesalahan thumbnail: {error}",
|
||||
"thumbnail_no_image": "Tidak ada thumbnail yang tersedia untuk disimpan"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Kesalahan: Playlist tidak berisi video yang valid.",
|
||||
"playlist_no_url": "Kesalahan: Tidak dapat mendapatkan URL untuk video playlist pertama.",
|
||||
"ytdlp_not_found": "Kesalahan: Executable yt-dlp tidak ditemukan. Silakan instal yt-dlp terlebih dahulu.",
|
||||
"ytdlp_not_found_path": "Kesalahan: Executable yt-dlp tidak ditemukan. Ini bisa karena instalasi yang tidak tepat atau masalah PATH.",
|
||||
"no_data_returned": "Kesalahan: Tidak ada data yang dikembalikan dari yt-dlp",
|
||||
"no_format_info": "Kesalahan: Tidak ada informasi format yang tersedia.",
|
||||
"analysis_timeout": "Kesalahan: Analisis timeout. Silakan coba lagi.",
|
||||
"invalid_speed_limit": "❌ Kesalahan: Nilai batas kecepatan tidak valid yang diatur dalam pengaturan.",
|
||||
"ytdlp_failed": "Kesalahan: yt-dlp gagal: {error}",
|
||||
"parse_failed": "Kesalahan: Gagal mengurai output yt-dlp: {error}",
|
||||
"analysis_failed": "Kesalahan: Analisis gagal: {error}",
|
||||
"generic_error": "Kesalahan: {error}",
|
||||
"download_failed_return_code_conflict": "Unduhan gagal dengan kode pengembalian {return_code}. Ini mungkin karena konflik dengan beberapa instalasi yt-dlp. Coba uninstall yt-dlp yang terpasang di sistem (mis. melalui snap atau apt) lalu mulai ulang aplikasi.",
|
||||
"download_failed_return_code": "Unduhan gagal dengan kode pengembalian {return_code}",
|
||||
"direct_command_error": "Error pada perintah langsung: {error}",
|
||||
"private_video": "Video ini mungkin bersifat pribadi. Silakan gunakan cookie dari opsi kustom."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Pembaruan Tersedia",
|
||||
"new_version_available": "Versi baru YTSage tersedia!",
|
||||
"current_version_label": "Versi saat ini:",
|
||||
"latest_version_label": "Versi terbaru:",
|
||||
"changelog": "Catatan perubahan",
|
||||
"download_update": "Unduh Pembaruan",
|
||||
"remind_later": "Ingatkan Nanti"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Playlist Tidak Dikenal",
|
||||
"total_videos": "Total Video: {count}",
|
||||
"display_format": "Playlist: {title} | {count} video",
|
||||
"select_videos_title": "Pilih Video Playlist",
|
||||
"save_as": "Simpan Playlist Sebagai",
|
||||
"save_success_title": "Berhasil",
|
||||
"saved_successfully": "Playlist berhasil disimpan.",
|
||||
"save_error_title": "Gagal Menyimpan",
|
||||
"no_videos_to_save": "Tidak ada entri playlist yang dikumpulkan!",
|
||||
"save_error_msg": "Gagal menyimpan playlist."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} dipilih"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "File Sudah Ada",
|
||||
"message": "File sudah ada:\n{filename}",
|
||||
"info": "Video ini sudah pernah diunduh."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Pemeriksaan terakhir: Tidak pernah",
|
||||
"last_check": "Pemeriksaan terakhir: {time}",
|
||||
"next_check_disabled": "Pemeriksaan berikutnya: Dinonaktifkan",
|
||||
"next_check_startup": "Pemeriksaan berikutnya: Saat startup",
|
||||
"next_check_overdue": "Pemeriksaan berikutnya: Sekarang (terlambat)",
|
||||
"next_check": "Pemeriksaan berikutnya: {time}",
|
||||
"next_check_error": "Pemeriksaan berikutnya: Kesalahan perhitungan",
|
||||
"checking": "🔄 Memeriksa...",
|
||||
"check_now": "🔍 Periksa pembaruan sekarang",
|
||||
"current_version": "Versi yt-dlp saat ini: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL tidak boleh kosong",
|
||||
"invalid_format": "Format URL tidak valid",
|
||||
"invalid_scheme": "URL harus dimulai dengan http:// atau https://",
|
||||
"missing_domain": "URL tidak valid: nama domain hilang",
|
||||
"unsupported_platform": "YTSage hanya mendukung URL YouTube dan YouTube Music.\nDomain '{domain}' tidak didukung.",
|
||||
"invalid_youtu_be": "URL youtu.be tidak valid: ID video hilang"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Ini adalah video privat. Anda dapat mengunduhnya dengan masuk ke akun Anda menggunakan cookie.\nBuka 'Opsi Kustom' → 'Masuk dengan Cookie' → 'Ekstrak cookie dari browser' untuk autentikasi.",
|
||||
"age_restricted": "Video ini dibatasi usia. Anda perlu masuk untuk mengaksesnya.\nGunakan 'Opsi Kustom' → 'Masuk dengan Cookie' untuk autentikasi dengan akun Anda.",
|
||||
"geo_blocked": "Video ini tidak tersedia di wilayah Anda (diblokir geografis).\nAnda mungkin perlu menggunakan VPN atau video mungkin dibatasi di negara Anda.",
|
||||
"video_unavailable": "Video ini telah dihapus atau tidak lagi tersedia.\nVideo mungkin telah dihapus oleh pengunggah atau dihapus karena pelanggaran kebijakan.",
|
||||
"live_stream": "Ini adalah siaran langsung yang tidak dapat diunduh saat aktif.\nTunggu hingga siaran berakhir, lalu coba unduh versi arsip.",
|
||||
"playlist_error": "Tidak dapat mengakses playlist ini. Mungkin privat, dihapus, atau kosong.\nPeriksa apakah playlist ada dan dapat diakses publik.",
|
||||
"network_error": "Kesalahan koneksi jaringan. Silakan periksa koneksi internet Anda dan coba lagi.\nJika masalah berlanjut, server video mungkin tidak tersedia sementara.",
|
||||
"invalid_url": "URL tidak valid atau tidak didukung. Silakan periksa tautan dan coba lagi.\nPastikan Anda menggunakan URL YouTube, Vimeo, atau platform didukung lainnya yang valid.",
|
||||
"premium_content": "Konten ini memerlukan YouTube Premium atau keanggotaan channel.\nAnda perlu masuk dengan akun yang memiliki akses ke konten ini.",
|
||||
"copyright_blocked": "Video ini diblokir karena klaim hak cipta.\nPemilik konten telah membatasi akses ke video ini.",
|
||||
"extraction_failed": "Gagal mengekstrak informasi video. Ini mungkin masalah sementara.\nSilakan coba lagi dalam beberapa menit, atau periksa apakah tautan video benar.",
|
||||
"generic_error": "Tidak dapat mengekstrak informasi video. Silakan periksa tautan Anda.\nDetail teknis: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Riwayat Unduhan",
|
||||
"clear_all": "Hapus Semua",
|
||||
"clear_confirm_title": "Hapus Riwayat?",
|
||||
"clear_confirm_message": "Apakah Anda yakin? Ini tidak dapat dibatalkan.",
|
||||
"no_history": "Belum ada riwayat",
|
||||
"no_history_description": "Unduhan Anda akan muncul di sini",
|
||||
"loading": "Memuat riwayat...",
|
||||
"search_placeholder": "Cari...",
|
||||
"open_location": "Buka Lokasi",
|
||||
"redownload": "Unduh Lagi",
|
||||
"remove": "Hapus",
|
||||
"file_not_found": "File tidak ditemukan",
|
||||
"file_not_found_message": "File dipindahkan atau dihapus:\n{path}",
|
||||
"downloaded_on": "Diunduh: {date}",
|
||||
"file_size": "Ukuran: {size}",
|
||||
"audio_download": "Audio",
|
||||
"video_download": "Video",
|
||||
"remove_confirm_title": "Hapus?",
|
||||
"remove_confirm_message": "Hapus dari riwayat?\n\n{title}",
|
||||
"item_removed": "Dihapus",
|
||||
"history_cleared": "Riwayat dihapus",
|
||||
"entries_count": "{count} unduhan",
|
||||
"one_entry": "1 unduhan",
|
||||
"redownload_confirm_title": "Unduh Lagi?",
|
||||
"redownload_confirm_message": "Unduh lagi?\n\n{title}",
|
||||
"redownload_started": "Unduhan dimulai",
|
||||
"no_url_error": "Tidak ada URL pada entri riwayat",
|
||||
"redownload_failed": "Gagal memulai unduh ulang: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "Instalasi FFmpeg",
|
||||
"installation_message": "YTSage membutuhkan FFmpeg untuk memproses video.\n\nPilih opsi instalasi di bawah:",
|
||||
"install_button": "Instal FFmpeg",
|
||||
"manual_guide": "Panduan manual",
|
||||
"installation_failed": "Instalasi FFmpeg mengalami masalah.",
|
||||
"already_installed": "FFmpeg sudah terinstal!",
|
||||
"installation_complete": "Instalasi selesai. Anda dapat menutup dialog ini dan melanjutkan menggunakan YTSage.",
|
||||
"installing": "Menginstal FFmpeg... Harap tunggu",
|
||||
"install_success": "FFmpeg berhasil diinstal!",
|
||||
"installation_complete_close": "Instalasi selesai. Anda sekarang dapat menutup dialog ini dan melanjutkan menggunakan YTSage.",
|
||||
"try_manual": "Silakan coba gunakan panduan instalasi manual sebagai gantinya."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "Pengaturan yt-dlp diperlukan",
|
||||
"description": "YTSage memerlukan yt-dlp untuk mengunduh video.<br><br>yt-dlp tidak ditemukan di direktori lokal aplikasi. YTSage perlu menyiapkan yt-dlp untuk sistem {os_name} Anda.<br><br>Silakan pilih opsi di bawah:",
|
||||
"option_auto": "Unduh otomatis (disarankan)",
|
||||
"option_manual": "Pilih path secara manual",
|
||||
"setup_button": "Siapkan yt-dlp",
|
||||
"downloading": "Mengunduh yt-dlp...",
|
||||
"success": "yt-dlp berhasil diinstal!",
|
||||
"error": "Error: {error}",
|
||||
"download_failed_title": "Unduhan gagal",
|
||||
"download_failed_message": "Gagal mengunduh yt-dlp: {error}",
|
||||
"select_executable_title": "Pilih executable yt-dlp",
|
||||
"copied_to": "yt-dlp berhasil disalin ke {path}",
|
||||
"setup_error_title": "Kesalahan pengaturan",
|
||||
"copy_error": "Kesalahan menyalin yt-dlp ke direktori aplikasi: {error}",
|
||||
"invalid_executable_title": "Executable tidak valid",
|
||||
"invalid_executable_message": "File yang dipilih tidak tampak sebagai executable yt-dlp yang valid.",
|
||||
"verify_error": "Kesalahan memverifikasi executable yt-dlp: {error}",
|
||||
"setup_failed_title": "Pengaturan gagal",
|
||||
"setup_failed_message": "Gagal menyiapkan yt-dlp. Beberapa fitur mungkin tidak berfungsi dengan benar.",
|
||||
"success_dialog_title": "Pengaturan yt-dlp",
|
||||
"success_dialog_message": "yt-dlp berhasil dikonfigurasi di:\n{path}",
|
||||
"file_filter_windows": "File executable (*.exe)",
|
||||
"file_filter_all": "Semua file (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "Pemeriksa Versi FFmpeg",
|
||||
"current_version": "Versi Saat Ini:",
|
||||
"latest_version": "Versi Terbaru:",
|
||||
"status_up_to_date": "✓ Terbaru",
|
||||
"status_update_available": "⚠ Pembaruan tersedia",
|
||||
"status_not_installed": "✗ Tidak terinstal",
|
||||
"status_idle": "Klik 'Periksa Versi' untuk memulai",
|
||||
"status_checking": "🔄 Memeriksa versi...",
|
||||
"check_updates": "Periksa Versi",
|
||||
"check_failed": "Gagal memeriksa versi. Periksa koneksi internet Anda.",
|
||||
"description": "Periksa versi FFmpeg Anda dan bandingkan dengan versi terbaru yang tersedia.",
|
||||
"guide_info": " Untuk menginstal atau memperbarui FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>klik di sini untuk melihat panduan instalasi lengkap kami</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Pengaturan Deno Diperlukan",
|
||||
"setup_description": "YTSage memerlukan Deno untuk menjalankan fitur tertentu.<br><br>Deno tidak ditemukan di direktori lokal aplikasi. YTSage perlu mengatur Deno untuk sistem {os_name} Anda.<br><br>Klik 'Atur Deno' untuk mengunduh dan menginstal secara otomatis.",
|
||||
"setup_button": "Atur Deno",
|
||||
"downloading": "Mengunduh Deno...",
|
||||
"extracting": "Mengekstrak Deno...",
|
||||
"verifying": "Memverifikasi Deno...",
|
||||
"success": "Deno berhasil diinstal!",
|
||||
"download_failed": "Unduhan Gagal",
|
||||
"download_error": "Gagal mengunduh Deno: {error}",
|
||||
"verification_failed": "Verifikasi SHA256 gagal. File yang diunduh mungkin rusak atau dimanipulasi.",
|
||||
"setup_failed": "Gagal mengatur Deno. Beberapa fitur mungkin tidak berfungsi dengan benar.",
|
||||
"setup_error": "Kesalahan Pengaturan"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Pemeriksa & Pembaruan Versi Deno",
|
||||
"description": "Periksa versi Deno Anda dan perbarui ke rilis terbaru.",
|
||||
"current_version": "Versi Saat Ini:",
|
||||
"latest_version": "Versi Terbaru:",
|
||||
"status_idle": "Klik 'Periksa Pembaruan' untuk memulai",
|
||||
"status_checking": "🔄 Memeriksa pembaruan...",
|
||||
"status_up_to_date": "✓ Terbaru",
|
||||
"status_update_available": "⚠ Pembaruan tersedia",
|
||||
"status_not_installed": "✗ Tidak terinstal",
|
||||
"check_updates": "Periksa Pembaruan",
|
||||
"update_now": "Perbarui Deno",
|
||||
"updating": "🔄 Memperbarui Deno...",
|
||||
"update_success": "✅ Deno telah berhasil diperbarui!",
|
||||
"update_failed": "❌ Pembaruan gagal: {error}",
|
||||
"check_failed": "Gagal memeriksa pembaruan. Harap periksa koneksi internet Anda."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Italiano (Italian)",
|
||||
"select_language": "Seleziona lingua:",
|
||||
"current_language": "Lingua attuale: {language}",
|
||||
"restart_required": "Il cambio di lingua avrà effetto dopo il riavvio dell'applicazione.",
|
||||
"english": "Inglese",
|
||||
"spanish": "Spagnolo",
|
||||
"portuguese": "Portoghese",
|
||||
"russian": "Russo",
|
||||
"chinese": "Cinese",
|
||||
"german": "Tedesco",
|
||||
"french": "Francese",
|
||||
"hindi": "Hindi",
|
||||
"indonesian": "Indonesiano",
|
||||
"turkish": "Turco",
|
||||
"polish": "Polacco",
|
||||
"italian": "Italiano",
|
||||
"arabic": "Arabo",
|
||||
"japanese": "Giapponese",
|
||||
"help_text": "Seleziona la lingua preferita per l'interfaccia.",
|
||||
"restart_notice": "Il cambio di lingua avrà effetto dopo il riavvio dell'applicazione."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Pronto"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Mostra formati:",
|
||||
"video_format": "Formato video:",
|
||||
"audio_format": "Formato audio:",
|
||||
"no_formats": "Nessun formato disponibile",
|
||||
"loading": "Caricamento formati...",
|
||||
"select": "Seleziona",
|
||||
"quality": "Qualità",
|
||||
"extension": "Estensione",
|
||||
"resolution": "Risoluzione",
|
||||
"file_size": "Dimensione file",
|
||||
"codec": "Codec",
|
||||
"audio": "Audio",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "L'audio verrà unito",
|
||||
"has_audio": "✓ Contiene audio",
|
||||
"audio_only": "Solo audio",
|
||||
"best_4k": "Migliore (4K)",
|
||||
"best_2k": "Migliore (2K)",
|
||||
"high_1080p": "Alta (1080p)",
|
||||
"high_720p": "Alta (720p)",
|
||||
"medium_480p": "Media (480p)",
|
||||
"low_quality": "Bassa qualità",
|
||||
"best_audio": "Miglior audio",
|
||||
"high_audio": "Audio alto",
|
||||
"medium_audio": "Audio medio",
|
||||
"low_audio": "Audio basso",
|
||||
"audio_only_resolution": "Solo audio"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Reimposta",
|
||||
"download": "Scarica",
|
||||
"pause": "Pausa",
|
||||
"resume": "Riprendi",
|
||||
"cancel": "Annulla",
|
||||
"browse": "Sfoglia",
|
||||
"clear": "Cancella",
|
||||
"ok": "OK",
|
||||
"apply": "Applica",
|
||||
"close": "Chiudi",
|
||||
"run_command": "Esegui comando",
|
||||
"custom_command_help": "Aiuto",
|
||||
"about": "Informazioni",
|
||||
"analyze": "Analizza",
|
||||
"paste_url": "Incolla URL",
|
||||
"select_videos": "Seleziona video...",
|
||||
"video": "Video",
|
||||
"audio_only": "Solo audio",
|
||||
"custom_options": "Opzioni personalizzate",
|
||||
"trim_video": "Taglia video",
|
||||
"download_settings": "Impostazioni download",
|
||||
"update": "Aggiorna yt-dlp",
|
||||
"select_defaults": "Seleziona predefiniti",
|
||||
"select_all": "Seleziona tutto",
|
||||
"deselect_all": "Deseleziona tutto",
|
||||
"open_folder": "Apri posizione cartella",
|
||||
"history": "Cronologia",
|
||||
"save_playlist": "Salva playlist come"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Opzioni personalizzate",
|
||||
"settings": "Impostazioni",
|
||||
"select_folder": "Seleziona cartella di download",
|
||||
"sponsorblock_categories": "Categorie SponsorBlock",
|
||||
"sponsorblock_description": "Seleziona i tipi di segmenti video da rimuovere automaticamente durante il download.\nSponsorBlock utilizza dati inviati dalla comunità per identificare questi segmenti.",
|
||||
"select_subtitles": "Seleziona sottotitoli",
|
||||
"filter_languages_placeholder": "Filtra lingue (es: it, en)...",
|
||||
"no_subtitles_available": "Nessun sottotitolo disponibile",
|
||||
"matching": "corrispondenti",
|
||||
"ytdlp_log_title": "Registro yt-dlp",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Accedi con i cookie",
|
||||
"custom_command": "Comando personalizzato",
|
||||
"proxy": "Proxy",
|
||||
"language": "Lingua",
|
||||
"updater": "Aggiornamento"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Seleziona un metodo per fornire i cookie per l'autenticazione.\nCiò consente di scaricare video privati e file audio di alta qualità.",
|
||||
"cookie_source": "Fonte cookie",
|
||||
"use_cookie_file": "Usa file cookie",
|
||||
"extract_from_browser": "Estrai dal browser",
|
||||
"recommended": "Consigliato",
|
||||
"cookie_file": "File cookie",
|
||||
"cookie_file_placeholder": "Percorso al file cookies.txt...",
|
||||
"browser_selection": "Selezione browser",
|
||||
"browser_help": "Seleziona il browser da cui estrarre i cookie:",
|
||||
"browser_label": "Browser:",
|
||||
"profile_label": "Profilo:",
|
||||
"profile_placeholder": "Predefinito",
|
||||
"browser_extract_message": "I cookie del browser verranno estratti dopo l'applicazione",
|
||||
"file_selected_message": "File cookie selezionato - Clicca OK per applicare",
|
||||
"select_file_title": "Seleziona file cookie",
|
||||
"file_filter": "File cookie (*.txt *.lwp)",
|
||||
"file_selected_title": "File Cookie Applicato",
|
||||
"file_applied_message": "File cookie applicato: {path}",
|
||||
"browser_selected_title": "Cookie del Browser Applicati",
|
||||
"browser_applied_message": "I cookie del browser verranno estratti da: {browser}",
|
||||
"cleared_title": "Cookie Cancellati",
|
||||
"cleared_message": "Le impostazioni dei cookie sono state cancellate",
|
||||
"active_browser": "✓ Attivi: cookie del browser ({browser})",
|
||||
"active_file": "✓ Attivo: file cookie ({file})",
|
||||
"none_active": "○ Nessun cookie attivo",
|
||||
"remember_settings": "Ricorda le impostazioni dei cookie al prossimo avvio"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Inserisci un comando yt-dlp personalizzato qui sotto. L'URL corrente verrà aggiunto automaticamente.<br><br>Per l'elenco completo delle opzioni ed esempi di utilizzo <a href=\"{docs_url}\">clicca qui per vedere la documentazione ufficiale yt-dlp</a>.<br><br>Nota: Il percorso di download e il modello del nome file sono gestiti automaticamente.",
|
||||
"input_label": "Argomenti yt-dlp:",
|
||||
"input_placeholder": "Inserisci gli argomenti yt-dlp qui...\n\nEsempio: --extract-audio --audio-format mp3",
|
||||
"output_label": "Output comando:",
|
||||
"output_placeholder": "L'output del comando apparirà qui...",
|
||||
"command_placeholder": "Inserisci comando yt-dlp personalizzato...",
|
||||
"command_help": "Placeholder disponibili:\n{url} - URL del video\n{output} - Cartella di output\n\nEsempio: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Comando completo: {command}",
|
||||
"command_success": "✅ Comando personalizzato eseguito con successo!",
|
||||
"command_failed": "❌ Comando fallito con codice di uscita {code}",
|
||||
"command_error": "❌ Errore nell'esecuzione del comando personalizzato: {error}",
|
||||
"error_no_url": "❌ Errore: nessun URL fornito. Inserisci un URL nella finestra principale.",
|
||||
"error_no_command": "❌ Errore: nessun comando fornito. Inserisci gli argomenti di yt-dlp.",
|
||||
"executing": "🚀 Esecuzione del comando yt-dlp personalizzato",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Argomenti: {command}",
|
||||
"download_path_label": "📁 Percorso di download: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Configura le impostazioni proxy per i download. Lascia vuoto per connessione diretta.",
|
||||
"main_proxy": "Proxy principale",
|
||||
"main_proxy_help": "Server proxy principale per tutti i download. Supporta protocolli HTTP/HTTPS e SOCKS5.",
|
||||
"proxy_url_label": "URL Proxy:",
|
||||
"proxy_url_placeholder": "http://proxy:porta o socks5://proxy:porta",
|
||||
"proxy_examples": "Esempi:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Proxy geo-bypass",
|
||||
"geo_proxy_help": "Proxy aggiuntivo specificamente per aggirare le restrizioni geografiche.",
|
||||
"geo_proxy_url_label": "URL proxy geo-bypass:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:porta",
|
||||
"clear_main_proxy": "Cancella proxy principale",
|
||||
"clear_geo_proxy": "Cancella proxy geo",
|
||||
"proxy_url": "URL Proxy",
|
||||
"proxy_placeholder": "http://proxy:porta o socks5://proxy:porta",
|
||||
"geo_bypass": "Proxy geo-bypass (per restrizioni geografiche)",
|
||||
"geo_bypass_placeholder": "http://proxy:porta per aggirare il geo-blocking",
|
||||
"invalid_main_url": "Formato URL proxy principale non valido",
|
||||
"invalid_geo_url": "Formato URL proxy geo non valido",
|
||||
"main_configured": "Proxy principale configurato",
|
||||
"geo_configured": "Proxy geo configurato",
|
||||
"set_title": "Proxy impostato",
|
||||
"set_message": "Proxy principale impostato e salvato: {proxy}",
|
||||
"geo_set_title": "Proxy geo impostato",
|
||||
"geo_set_message": "Proxy di verifica geo impostato e salvato: {proxy}",
|
||||
"cleared_title": "Impostazioni proxy cancellate",
|
||||
"cleared_message": "Tutte le impostazioni proxy sono state cancellate e salvate.",
|
||||
"saved_main": "Proxy principale salvato: {proxy}",
|
||||
"saved_geo": "Proxy geo salvato: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Preparazione download...",
|
||||
"starting": "🚀 Avvio download...",
|
||||
"fetching_info": "🔍 Recupero informazioni video...",
|
||||
"preparing_streams": "🎯 Preparazione stream video...",
|
||||
"downloading_audio": "⏬ Download audio...",
|
||||
"downloading_video": "⏬ Download video...",
|
||||
"downloading_subtitle": "⏬ Download sottotitoli...",
|
||||
"downloading": "⏬ Download...",
|
||||
"completed": "✅ Download completato!",
|
||||
"video_completed": "✅ Download video completato!",
|
||||
"audio_completed": "✅ Download audio completato!",
|
||||
"subtitle_completed": "✅ Download sottotitoli completato!",
|
||||
"completed_cleaning": "✅ Download completato! Pulizia in corso...",
|
||||
"cancelled": "Download annullato",
|
||||
"processing_playlist": "📋 Elaborazione dati playlist...",
|
||||
"paused": "Download in pausa",
|
||||
"resumed": "Download ripreso",
|
||||
"merging_formats": "✨ Post-elaborazione: Unione formati...",
|
||||
"removing_sponsor_segments": "✨ Post-elaborazione: Rimozione segmenti sponsorizzati...",
|
||||
"speed": "Velocità",
|
||||
"eta": "Tempo rimanente",
|
||||
"please_enter_url": "Inserisci prima un URL.",
|
||||
"please_set_path": "Imposta prima un percorso di download.",
|
||||
"please_enter_url_and_path": "Inserisci un URL e imposta un percorso di download.",
|
||||
"please_select_format": "Seleziona prima un formato.",
|
||||
"downloading_fallback": "⚡ Download in corso..."
|
||||
},
|
||||
"update": {
|
||||
"title": "Aggiorna yt-dlp",
|
||||
"checking": "Controllo aggiornamenti...",
|
||||
"update_available": "Aggiornamento disponibile!\nVersione corrente: {current}\nUltima versione: {latest}",
|
||||
"up_to_date": "yt-dlp è aggiornato (Versione {version})",
|
||||
"could_not_determine": "Impossibile determinare la versione.",
|
||||
"error_comparing": "Errore nel confronto versioni: {error}",
|
||||
"update_available_failed": "Aggiornamento disponibile! (Confronto fallito)\nCorrente: {current}\nUltima: {latest}",
|
||||
"updating": "Aggiornamento...",
|
||||
"initializing": "🚀 Inizializzazione processo di aggiornamento...",
|
||||
"checking_current": "🔍 Controllo installazione corrente...",
|
||||
"found_at": "📍 yt-dlp trovato in: {path}",
|
||||
"error_getting_path": "❌ Errore nel recupero del percorso yt-dlp: {error}",
|
||||
"updating_binary": "📦 Aggiornamento binario yt-dlp gestito dall'app...",
|
||||
"update_failed": "❌ Aggiornamento yt-dlp fallito. Riprova o controlla la connessione internet.",
|
||||
"binary_updated": "✅ Binario aggiornato con successo!",
|
||||
"update_failed_stderr": "❌ Aggiornamento yt-dlp fallito: {error}",
|
||||
"update_timeout": "❌ Timeout aggiornamento yt-dlp.",
|
||||
"unexpected_error": "❌ Errore inaspettato durante l'aggiornamento: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp è già aggiornato!",
|
||||
"update_success": "✅ yt-dlp è stato aggiornato con successo!",
|
||||
"already_latest": "yt-dlp è aggiornato (versione {version})",
|
||||
"network_error": "❌ Errore di rete durante l'aggiornamento: {error}",
|
||||
"general_error": "❌ Aggiornamento fallito: {error}",
|
||||
"update_in_progress_title": "Aggiornamento in corso",
|
||||
"update_in_progress_message": "yt-dlp si sta aggiornando. Attendere prego."
|
||||
},
|
||||
"about": {
|
||||
"title": "Informazioni su YTSage",
|
||||
"version": "Versione {version}",
|
||||
"description": "Downloader YouTube moderno con interfaccia PySide6 pulita.",
|
||||
"author": "Autore: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Informazioni sistema",
|
||||
"loading": "🔄 Caricamento informazioni sistema...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Log",
|
||||
"logs_tooltip": "Apri cartella log applicazione",
|
||||
"refreshing": "🔄 Aggiornamento...",
|
||||
"refresh_failed": "Aggiornamento fallito",
|
||||
"refresh_failed_message": "Impossibile aggiornare le informazioni sulla versione.",
|
||||
"detected": "✓ Rilevato",
|
||||
"missing": "✗ Mancante",
|
||||
"not_available": "Non disponibile"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Taglia video",
|
||||
"time_range_group": "Intervallo di tempo",
|
||||
"start_time": "Tempo di inizio (HH:MM:SS)",
|
||||
"end_time": "Tempo di fine (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Tempo di inizio (HH:MM:SS)",
|
||||
"end_time_placeholder": "Tempo di fine (HH:MM:SS)",
|
||||
"force_keyframes": "Forza keyframe ai punti di taglio",
|
||||
"help_text": "Imposta i tempi di inizio e fine per tagliare il video.\nLascia vuoto per scaricare il video completo.",
|
||||
"invalid_format": "Formato tempo non valido. Usa il formato HH:MM:SS.",
|
||||
"start_after_end": "Il tempo di inizio non può essere dopo il tempo di fine."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni download",
|
||||
"download_path": "Percorso download",
|
||||
"browse": "Sfoglia...",
|
||||
"speed_limit": "Limite velocità",
|
||||
"speed_limit_placeholder": "Nessuno",
|
||||
"generic_mode": "Modalità generica",
|
||||
"enable_generic_mode": "Abilita modalità generica (supporta siti non YouTube)",
|
||||
"generic_mode_help": "Consente il download da Dailymotion, CBC Gem e altri siti supportati da yt-dlp.",
|
||||
"auto_update_ytdlp": "Aggiornamenti automatici yt-dlp",
|
||||
"enable_auto_updates": "Abilita aggiornamenti automatici yt-dlp",
|
||||
"update_frequency": "Frequenza aggiornamenti:",
|
||||
"check_startup": "Controlla ad ogni avvio (minimo 1 ora tra i controlli)",
|
||||
"check_daily": "Controlla giornalmente",
|
||||
"check_weekly": "Controlla settimanalmente",
|
||||
"check_updates_now": "Aggiorna yt-dlp",
|
||||
"update_check_title": "Controllo aggiornamenti",
|
||||
"could_not_determine_version": "Impossibile determinare la versione corrente di yt-dlp.",
|
||||
"update_available_dialog": "Aggiornamento disponibile!\n\nCorrente: {current}\nUltima: {latest}\n\nClicca su OK per procedere con l'aggiornamento.",
|
||||
"up_to_date_dialog": "yt-dlp è aggiornato!\n\nVersione corrente: {version}",
|
||||
"error_checking_updates": "Errore nel controllo aggiornamenti: {error}",
|
||||
"settings_saved_title": "Impostazioni salvate",
|
||||
"settings_saved_message": "Le impostazioni di aggiornamento automatico sono state salvate con successo!",
|
||||
"error_title": "Errore",
|
||||
"failed_save_settings": "Impossibile salvare le impostazioni di aggiornamento automatico.",
|
||||
"error_saving_settings": "Errore nel salvataggio delle impostazioni di aggiornamento automatico: {error}",
|
||||
"ytdlp_channel": "Canale di rilascio yt-dlp",
|
||||
"ytdlp_channel_stable": "Stabile (Rilasci testati)",
|
||||
"ytdlp_channel_nightly": "Nightly (Aggiornamenti giornalieri, consigliato da yt-dlp)",
|
||||
"ytdlp_channel_description": "Scegli tra rilasci stabili e nightly. Le build nightly vengono aggiornate quotidianamente con le ultime correzioni e funzionalità. Puoi cambiare canale in qualsiasi momento.",
|
||||
"ytdlp_switching_channel": "Passaggio al canale {channel}...",
|
||||
"ytdlp_channel_switched": "✅ Passaggio al canale {channel} riuscito!",
|
||||
"ytdlp_channel_switch_failed": "❌ Impossibile cambiare canale: {error}",
|
||||
"ytdlp_current_channel": "Canale attuale: {channel}",
|
||||
"app_updates_title": "Aggiornamenti YTSage",
|
||||
"check_app_updates": "Controlla aggiornamenti YTSage all'avvio",
|
||||
"check_beta_updates": "Ricevi aggiornamenti beta",
|
||||
"auto_update_title": "Impostazioni aggiornamenti automatici",
|
||||
"auto_update_header": "🔄 Impostazioni aggiornamenti automatici",
|
||||
"auto_update_description": "Configura gli aggiornamenti automatici per yt-dlp per garantire le ultime funzionalità e correzioni bug.",
|
||||
"current_status": "Stato attuale",
|
||||
"current_version_label": "Versione corrente yt-dlp: Controllo...",
|
||||
"last_check_label": "Ultimo controllo aggiornamenti: Mai",
|
||||
"next_check_label": "Prossimo controllo: Basato sulle impostazioni",
|
||||
"manual_check_button": "🔍 Controlla aggiornamenti ora",
|
||||
"update_frequency_group": "Frequenza aggiornamenti",
|
||||
"save_settings": "Salva impostazioni",
|
||||
"settings_saved_successfully": "✅ Impostazioni salvate con successo!",
|
||||
"error_saving": "❌ Errore nel salvataggio impostazioni: {error}",
|
||||
"output_format_settings": "Impostazioni formato output",
|
||||
"force_output_format": "Forza formato output durante la fusione",
|
||||
"preferred_format": "Formato preferito:",
|
||||
"force_format_help": "Quando attivato, i video uniti verranno convertiti nel formato preferito. Disattiva per lasciare che yt-dlp decida automaticamente.",
|
||||
"format_mp4": "MP4 (Più compatibile)",
|
||||
"format_webm": "WebM (Moderno, aperto)",
|
||||
"format_mkv": "MKV (Ricco di funzionalità)",
|
||||
"audio_format_settings": "Impostazioni formato audio",
|
||||
"force_audio_format": "Forza formato audio per download solo audio",
|
||||
"audio_normalization": "Normalizzazione audio (EBU R128)",
|
||||
"audio_normalization_help": "Se abilitato, le tracce audio verranno normalizzate. Nota: ciò richiede la ricodifica, quindi deve essere forzato un formato audio specifico (come MP3 o M4A).",
|
||||
"preferred_audio_format": "Formato audio preferito:",
|
||||
"force_audio_format_help": "Quando abilitato, i download solo audio verranno convertiti nel formato preferito. Questo si applica solo durante il download di formati audio.",
|
||||
"audio_format_best": "Migliore (Nessuna conversione)",
|
||||
"audio_format_aac": "AAC (Buono per l'editing)",
|
||||
"audio_format_mp3": "MP3 (Universale)",
|
||||
"audio_format_flac": "FLAC (Senza perdita)",
|
||||
"audio_format_wav": "WAV (Non compresso)",
|
||||
"audio_format_opus": "Opus (Efficiente)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Aperto)",
|
||||
"filename_format": "Formato nome file output",
|
||||
"filename_format_help": "Variabili disponibili: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. È supportata la sintassi standard del modello di output di yt-dlp.",
|
||||
"tab_general": "Generale",
|
||||
"tab_format": "Formato",
|
||||
"tab_file": "File",
|
||||
"concurrent_fragments": "Connessioni simultanee",
|
||||
"concurrent_fragments_help": "Numero di connessioni per download. Valori più alti aggirano il throttling ma possono causare blocchi temporanei se impostati troppo alti. Predefinito: 1.",
|
||||
"defaults_settings": "Impostazioni di selezione predefinite",
|
||||
"default_video_quality": "Risoluzione video predefinita (altezza):",
|
||||
"default_subtitle_language": "Lingua/e dei sottotitoli predefinita/e:",
|
||||
"defaults_help": "Imposta l'altezza del video preferita e le lingue dei sottotitoli (separate da virgole). Verranno selezionate automaticamente se disponibili."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Inserisci URL video YouTube o playlist",
|
||||
"url_placeholder_generic": "Inserisci l'URL di un video o di una playlist da qualsiasi sito supportato",
|
||||
"merge_subtitles": "Unisci sottotitoli",
|
||||
"save_thumbnail": "Salva miniatura",
|
||||
"save_description": "Salva descrizione",
|
||||
"embed_chapters": "Incorpora capitoli",
|
||||
"subtitles_selected": "{count} selezionati",
|
||||
"all_selected": "Tutti selezionati",
|
||||
"select_videos_all": "Seleziona video... (Tutti selezionati)",
|
||||
"please_enter_url": "Inserisci prima un URL",
|
||||
"error_title": "Errore",
|
||||
"cookie_file_selected_title": "File cookie selezionato",
|
||||
"cookie_file_selected_message": "File cookie selezionato: {path}",
|
||||
"browser_cookies_selected_title": "Cookie browser selezionati",
|
||||
"browser_cookies_selected_message": "I cookie del browser verranno estratti da: {browser}",
|
||||
"error_no_format_info": "Errore: Nessuna informazione formato disponibile.",
|
||||
"error_extract_info": "Errore: Impossibile estrarre informazioni base del video. Controlla il tuo link.",
|
||||
"analyzing_preparing": "Preparazione richiesta...",
|
||||
"analyzing_extracting_basic": "Estrazione informazioni base...",
|
||||
"analyzing_extracting_detailed": "Estrazione informazioni dettagliate...",
|
||||
"analyzing_processing_video": "Elaborazione dati video...",
|
||||
"analyzing_processing_formats": "Elaborazione formati...",
|
||||
"analyzing_loading_thumbnail": "Caricamento miniatura...",
|
||||
"analyzing_processing_subtitles": "Elaborazione sottotitoli...",
|
||||
"analyzing_updating_table": "Aggiornamento tabella formati...",
|
||||
"analysis_complete": "Analisi completata!",
|
||||
"analyzing_extracting_ytdlp": "Estrazione informazioni...",
|
||||
"analyzing_fetching_first_video": "Recupero dei formati per il primo video...",
|
||||
"analyzing_processing_data": "Elaborazione dati...",
|
||||
"analyzing_processing_formats_ytdlp": "Elaborazione formati...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Caricamento miniatura...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Elaborazione sottotitoli...",
|
||||
"select_subtitles": "Seleziona sottotitoli...",
|
||||
"sponsorblock_categories": "Categorie SponsorBlock...",
|
||||
"invalid_url_or_enter": "URL non valido o inserisci un URL.",
|
||||
"zero_selected": "0 selezionati",
|
||||
"analyze_first_tooltip": "Si prega di analizzare prima il video",
|
||||
"audio_mode_disabled": "Non disponibile in modalità solo audio",
|
||||
"select_subtitles_first": "Si prega di selezionare prima i sottotitoli",
|
||||
"settings_tooltip": "Percorso corrente: {path}\nLimite velocità: {speed_limit}",
|
||||
"speed_limit_none": "Nessuno",
|
||||
"open_folder_error": "Impossibile aprire la cartella: {error}",
|
||||
"time_range_set": "Sezione impostata: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Sponsor",
|
||||
"sponsor_desc": "Pubblicità a pagamento, raccomandazioni a pagamento e pubblicità dirette",
|
||||
"selfpromo": "Non remunerata/Autopromozione",
|
||||
"selfpromo_desc": "Promozione non remunerata dei propri contenuti del creatore",
|
||||
"interaction": "Promemoria interazione",
|
||||
"interaction_desc": "Gli spettatori vengono invitati a mettere mi piace, iscriversi o seguire sui social media",
|
||||
"intro": "Intro",
|
||||
"intro_desc": "Introduzione al video che può essere saltata",
|
||||
"outro": "Outro/Schede finali",
|
||||
"outro_desc": "Titoli di coda o quando il video finisce",
|
||||
"preview": "Anteprima/Ricapitolazione",
|
||||
"preview_desc": "Breve ricapitolazione di video precedenti o anteprima di contenuti futuri",
|
||||
"music_offtopic": "Sezione non musicale",
|
||||
"music_offtopic_desc": "Solo per video musicali. Indica sezioni non musicali",
|
||||
"filler": "Riempitivo tangenziale",
|
||||
"filler_desc": "Scene tangenziali aggiunte solo come riempitivo o per umorismo"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Canale",
|
||||
"views": "👁 Visualizzazioni",
|
||||
"likes": "👍 Mi piace",
|
||||
"upload_date": "📅 Data di caricamento",
|
||||
"duration": "⏱ Durata",
|
||||
"unknown_channel": "Canale sconosciuto",
|
||||
"unknown_date": "Data sconosciuta",
|
||||
"unknown_title": "Titolo sconosciuto"
|
||||
},
|
||||
"command": {
|
||||
"running": "Esecuzione...",
|
||||
"run_command": "Esegui comando"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 selezionati",
|
||||
"one_selected": "1 categoria selezionata",
|
||||
"count_selected": "{count} selezionati"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Pronto",
|
||||
"file_exists": "⚠️ File già esistente",
|
||||
"video_file_exists": "⚠️ File video già esistente",
|
||||
"audio_file_exists": "⚠️ File audio già esistente",
|
||||
"subtitle_file_exists": "⚠️ File sottotitoli già esistente",
|
||||
"cancelling": "Annullamento download...",
|
||||
"thumbnail_saved": "✅ Miniatura salvata: {filename}",
|
||||
"thumbnail_error": "❌ Errore miniatura: {error}",
|
||||
"thumbnail_no_image": "Nessuna miniatura disponibile da salvare"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Errore: La playlist non contiene video validi.",
|
||||
"playlist_no_url": "Errore: Impossibile ottenere l'URL del primo video della playlist.",
|
||||
"ytdlp_not_found": "Errore: Eseguibile yt-dlp non trovato. Installare prima yt-dlp.",
|
||||
"ytdlp_not_found_path": "Errore: Eseguibile yt-dlp non trovato. Ciò potrebbe essere dovuto a un'installazione errata o a un problema di PATH.",
|
||||
"no_data_returned": "Errore: Nessun dato restituito da yt-dlp",
|
||||
"no_format_info": "Errore: Nessuna informazione formato disponibile.",
|
||||
"analysis_timeout": "Errore: Timeout analisi. Riprovare.",
|
||||
"invalid_speed_limit": "❌ Errore: Valore limite velocità non valido impostato nelle impostazioni.",
|
||||
"ytdlp_failed": "Errore: yt-dlp fallito: {error}",
|
||||
"parse_failed": "Errore: Impossibile analizzare l'output di yt-dlp: {error}",
|
||||
"analysis_failed": "Errore: Analisi fallita: {error}",
|
||||
"generic_error": "Errore: {error}",
|
||||
"download_failed_return_code_conflict": "Download non riuscito con codice di uscita {return_code}. Potrebbe essere dovuto a un conflitto tra più installazioni di yt-dlp. Prova a disinstallare eventuali yt-dlp installati a livello di sistema (es. snap o apt) e riavvia l’app.",
|
||||
"download_failed_return_code": "Download non riuscito con codice di uscita {return_code}",
|
||||
"direct_command_error": "Errore nel comando diretto: {error}",
|
||||
"private_video": "Questo video potrebbe essere privato. Si prega di utilizzare i cookie dalle opzioni personalizzate."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Aggiornamento disponibile",
|
||||
"new_version_available": "È disponibile una nuova versione di YTSage!",
|
||||
"current_version_label": "Versione corrente:",
|
||||
"latest_version_label": "Ultima versione:",
|
||||
"changelog": "Registro modifiche",
|
||||
"download_update": "Scarica aggiornamento",
|
||||
"remind_later": "Ricordamelo dopo"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Playlist sconosciuta",
|
||||
"total_videos": "Video totali: {count}",
|
||||
"display_format": "Playlist: {title} | {count} video",
|
||||
"select_videos_title": "Seleziona Video Playlist",
|
||||
"save_as": "Salva playlist come",
|
||||
"save_success_title": "Successo",
|
||||
"saved_successfully": "Playlist salvata con successo.",
|
||||
"save_error_title": "Errore di salvataggio",
|
||||
"no_videos_to_save": "Nessuna voce della playlist raccolta!",
|
||||
"save_error_msg": "Impossibile salvare la playlist."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} selezionati"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "Il file esiste già",
|
||||
"message": "Il file esiste già:\n{filename}",
|
||||
"info": "Questo video è già stato scaricato."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Ultimo controllo aggiornamenti: Mai",
|
||||
"last_check": "Ultimo controllo aggiornamenti: {time}",
|
||||
"next_check_disabled": "Prossimo controllo: Disabilitato",
|
||||
"next_check_startup": "Prossimo controllo: All'avvio",
|
||||
"next_check_overdue": "Prossimo controllo: Ora (in ritardo)",
|
||||
"next_check": "Prossimo controllo: {time}",
|
||||
"next_check_error": "Prossimo controllo: Errore di calcolo",
|
||||
"checking": "🔄 Controllo...",
|
||||
"check_now": "🔍 Controlla aggiornamenti ora",
|
||||
"current_version": "Versione corrente di yt-dlp: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "L'URL non può essere vuoto",
|
||||
"invalid_format": "Formato URL non valido",
|
||||
"invalid_scheme": "L'URL deve iniziare con http:// o https://",
|
||||
"missing_domain": "URL non valido: nome di dominio mancante",
|
||||
"unsupported_platform": "YTSage supporta solo URL di YouTube e YouTube Music.\nIl dominio '{domain}' non è supportato.",
|
||||
"invalid_youtu_be": "URL youtu.be non valido: ID video mancante"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Questo è un video privato. Puoi scaricarlo accedendo al tuo account utilizzando i cookie.\nVai su 'Opzioni Personalizzate' → 'Accedi con Cookie' → 'Estrai cookie dal browser' per autenticarti.",
|
||||
"age_restricted": "Questo video è soggetto a restrizioni di età. Devi effettuare l'accesso per accedervi.\nUtilizza 'Opzioni Personalizzate' → 'Accedi con Cookie' per autenticarti con il tuo account.",
|
||||
"geo_blocked": "Questo video non è disponibile nella tua regione (geo-bloccato).\nPotresti aver bisogno di utilizzare una VPN o il video potrebbe essere limitato nel tuo paese.",
|
||||
"video_unavailable": "Questo video è stato rimosso o non è più disponibile.\nIl video potrebbe essere stato eliminato dall'uploader o rimosso a causa di violazioni delle politiche.",
|
||||
"live_stream": "Questo è uno stream live che non può essere scaricato mentre è attivo.\nAttendi la fine dello stream, quindi prova a scaricare la versione archiviata.",
|
||||
"playlist_error": "Impossibile accedere a questa playlist. Potrebbe essere privata, eliminata o vuota.\nVerifica se la playlist esiste ed è accessibile pubblicamente.",
|
||||
"network_error": "Errore di connessione di rete. Controlla la tua connessione Internet e riprova.\nSe il problema persiste, il server video potrebbe essere temporaneamente non disponibile.",
|
||||
"invalid_url": "URL non valido o non supportato. Controlla il link e riprova.\nAssicurati di utilizzare un URL YouTube, Vimeo o altra piattaforma supportata valido.",
|
||||
"premium_content": "Questo contenuto richiede YouTube Premium o l'iscrizione al canale.\nDevi accedere con un account che ha accesso a questo contenuto.",
|
||||
"copyright_blocked": "Questo video è bloccato a causa di rivendicazioni di copyright.\nIl proprietario del contenuto ha limitato l'accesso a questo video.",
|
||||
"extraction_failed": "Impossibile estrarre le informazioni del video. Questo potrebbe essere un problema temporaneo.\nRiprova tra qualche minuto o verifica se il link del video è corretto.",
|
||||
"generic_error": "Impossibile estrarre le informazioni del video. Controlla il tuo link.\nDettagli tecnici: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Cronologia Download",
|
||||
"clear_all": "Cancella Tutto",
|
||||
"clear_confirm_title": "Cancellare Cronologia?",
|
||||
"clear_confirm_message": "Sei sicuro? Questa azione non può essere annullata.",
|
||||
"no_history": "Nessuna cronologia ancora",
|
||||
"no_history_description": "I tuoi download appariranno qui",
|
||||
"loading": "Caricamento cronologia...",
|
||||
"search_placeholder": "Cerca...",
|
||||
"open_location": "Apri Posizione",
|
||||
"redownload": "Scarica di Nuovo",
|
||||
"remove": "Rimuovi",
|
||||
"file_not_found": "File non trovato",
|
||||
"file_not_found_message": "Il file è stato spostato o eliminato:\n{path}",
|
||||
"downloaded_on": "Scaricato: {date}",
|
||||
"file_size": "Dimensione: {size}",
|
||||
"audio_download": "Audio",
|
||||
"video_download": "Video",
|
||||
"remove_confirm_title": "Rimuovere?",
|
||||
"remove_confirm_message": "Rimuovere dalla cronologia?\n\n{title}",
|
||||
"item_removed": "Rimosso",
|
||||
"history_cleared": "Cronologia cancellata",
|
||||
"entries_count": "{count} download",
|
||||
"one_entry": "1 download",
|
||||
"redownload_confirm_title": "Scaricare di Nuovo?",
|
||||
"redownload_confirm_message": "Scaricare di nuovo?\n\n{title}",
|
||||
"redownload_started": "Download avviato",
|
||||
"no_url_error": "Nessun URL trovato nella voce della cronologia",
|
||||
"redownload_failed": "Impossibile avviare il download di nuovo: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "Installazione di FFmpeg",
|
||||
"installation_message": "YTSage richiede FFmpeg per elaborare i video.\n\nScegli un'opzione di installazione qui sotto:",
|
||||
"install_button": "Installa FFmpeg",
|
||||
"manual_guide": "Guida manuale",
|
||||
"installation_failed": "L'installazione di FFmpeg ha riscontrato un problema.",
|
||||
"already_installed": "FFmpeg è già installato!",
|
||||
"installation_complete": "Installazione completata. Puoi chiudere questo dialogo e continuare a usare YTSage.",
|
||||
"installing": "Installazione di FFmpeg... Attendere",
|
||||
"install_success": "FFmpeg è stato installato con successo!",
|
||||
"installation_complete_close": "Installazione completata. Ora puoi chiudere questo dialogo e continuare a usare YTSage.",
|
||||
"try_manual": "Prova invece a usare la guida di installazione manuale."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "Configurazione di yt-dlp richiesta",
|
||||
"description": "YTSage richiede yt-dlp per scaricare video.<br><br>yt-dlp non è stato trovato nella directory locale dell'app. YTSage deve configurare yt-dlp per il tuo sistema {os_name}.<br><br>Scegli un'opzione qui sotto:",
|
||||
"option_auto": "Scarica automaticamente (consigliato)",
|
||||
"option_manual": "Seleziona il percorso manualmente",
|
||||
"setup_button": "Configura yt-dlp",
|
||||
"downloading": "Download di yt-dlp...",
|
||||
"success": "yt-dlp è stato installato correttamente!",
|
||||
"error": "Errore: {error}",
|
||||
"download_failed_title": "Download non riuscito",
|
||||
"download_failed_message": "Impossibile scaricare yt-dlp: {error}",
|
||||
"select_executable_title": "Seleziona l'eseguibile yt-dlp",
|
||||
"copied_to": "yt-dlp copiato correttamente in {path}",
|
||||
"setup_error_title": "Errore di configurazione",
|
||||
"copy_error": "Errore durante la copia di yt-dlp nella directory dell'app: {error}",
|
||||
"invalid_executable_title": "Eseguibile non valido",
|
||||
"invalid_executable_message": "Il file selezionato non sembra essere un eseguibile yt-dlp valido.",
|
||||
"verify_error": "Errore durante la verifica dell'eseguibile yt-dlp: {error}",
|
||||
"setup_failed_title": "Configurazione non riuscita",
|
||||
"setup_failed_message": "Impossibile configurare yt-dlp. Alcune funzionalità potrebbero non funzionare correttamente.",
|
||||
"success_dialog_title": "Configurazione di yt-dlp",
|
||||
"success_dialog_message": "yt-dlp è stato configurato correttamente in:\n{path}",
|
||||
"file_filter_windows": "File eseguibili (*.exe)",
|
||||
"file_filter_all": "Tutti i file (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "Controllo Versione FFmpeg",
|
||||
"current_version": "Versione corrente:",
|
||||
"latest_version": "Ultima versione:",
|
||||
"status_up_to_date": "✓ Aggiornato",
|
||||
"status_update_available": "⚠ Aggiornamento disponibile",
|
||||
"status_not_installed": "✗ Non installato",
|
||||
"status_idle": "Clicca 'Controlla Versione' per iniziare",
|
||||
"status_checking": "🔄 Verifica versione...",
|
||||
"check_updates": "Controlla Versione",
|
||||
"check_failed": "Verifica versione non riuscita. Controlla la connessione Internet.",
|
||||
"description": "Controlla la tua versione di FFmpeg e confrontala con l'ultima versione disponibile.",
|
||||
"guide_info": " Per installare o aggiornare FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>clicca qui per visualizzare la nostra guida completa all'installazione</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Configurazione Deno richiesta",
|
||||
"setup_description": "YTSage richiede Deno per eseguire alcune funzionalità.<br><br>Deno non è stato trovato nella directory locale dell'app. YTSage deve configurare Deno per il tuo sistema {os_name}.<br><br>Fai clic su 'Configura Deno' per scaricare e installare automaticamente.",
|
||||
"setup_button": "Configura Deno",
|
||||
"downloading": "Download di Deno in corso...",
|
||||
"extracting": "Estrazione di Deno in corso...",
|
||||
"verifying": "Verifica di Deno in corso...",
|
||||
"success": "Deno è stato installato con successo!",
|
||||
"download_failed": "Download fallito",
|
||||
"download_error": "Impossibile scaricare Deno: {error}",
|
||||
"verification_failed": "Verifica SHA256 fallita. Il file scaricato potrebbe essere danneggiato o manomesso.",
|
||||
"setup_failed": "Impossibile configurare Deno. Alcune funzionalità potrebbero non funzionare correttamente.",
|
||||
"setup_error": "Errore di configurazione"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Verificatore e Aggiornatore Versione Deno",
|
||||
"description": "Controlla la tua versione di Deno e aggiorna all'ultima versione.",
|
||||
"current_version": "Versione Corrente:",
|
||||
"latest_version": "Ultima Versione:",
|
||||
"status_idle": "Fai clic su 'Controlla Aggiornamenti' per iniziare",
|
||||
"status_checking": "🔄 Controllo aggiornamenti...",
|
||||
"status_up_to_date": "✓ Aggiornato",
|
||||
"status_update_available": "⚠ Aggiornamento disponibile",
|
||||
"status_not_installed": "✗ Non installato",
|
||||
"check_updates": "Controlla Aggiornamenti",
|
||||
"update_now": "Aggiorna Deno",
|
||||
"updating": "🔄 Aggiornamento di Deno...",
|
||||
"update_success": "✅ Deno è stato aggiornato con successo!",
|
||||
"update_failed": "❌ Aggiornamento fallito: {error}",
|
||||
"check_failed": "Impossibile controllare gli aggiornamenti. Controlla la tua connessione Internet."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "日本語 (Japanese)",
|
||||
"select_language": "言語を選択:",
|
||||
"current_language": "現在の言語: {language}",
|
||||
"restart_required": "言語の変更はアプリケーションの再起動後に有効になります。",
|
||||
"english": "英語",
|
||||
"spanish": "スペイン語",
|
||||
"portuguese": "ポルトガル語",
|
||||
"russian": "ロシア語",
|
||||
"chinese": "中国語",
|
||||
"german": "ドイツ語",
|
||||
"french": "フランス語",
|
||||
"hindi": "ヒンディー語",
|
||||
"indonesian": "インドネシア語",
|
||||
"turkish": "トルコ語",
|
||||
"polish": "ポーランド語",
|
||||
"italian": "イタリア語",
|
||||
"arabic": "アラビア語",
|
||||
"japanese": "日本語",
|
||||
"help_text": "インターフェースの言語を選択してください。",
|
||||
"restart_notice": "言語の変更はアプリケーションの再起動後に有効になります。"
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "準備完了"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "フォーマットを表示:",
|
||||
"video_format": "動画フォーマット:",
|
||||
"audio_format": "音声フォーマット:",
|
||||
"no_formats": "利用可能なフォーマットがありません",
|
||||
"loading": "フォーマットを読み込み中...",
|
||||
"select": "選択",
|
||||
"quality": "品質",
|
||||
"extension": "拡張子",
|
||||
"resolution": "解像度",
|
||||
"file_size": "ファイルサイズ",
|
||||
"codec": "コーデック",
|
||||
"audio": "音声",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "音声が結合されます",
|
||||
"has_audio": "✓ 音声あり",
|
||||
"audio_only": "音声のみ",
|
||||
"best_4k": "最高 (4K)",
|
||||
"best_2k": "最高 (2K)",
|
||||
"high_1080p": "高 (1080p)",
|
||||
"high_720p": "高 (720p)",
|
||||
"medium_480p": "中 (480p)",
|
||||
"low_quality": "低品質",
|
||||
"best_audio": "最高音質",
|
||||
"high_audio": "高音質",
|
||||
"medium_audio": "中音質",
|
||||
"low_audio": "低音質",
|
||||
"audio_only_resolution": "音声のみ"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "リセット",
|
||||
"download": "ダウンロード",
|
||||
"pause": "一時停止",
|
||||
"resume": "再開",
|
||||
"cancel": "キャンセル",
|
||||
"browse": "参照",
|
||||
"clear": "クリア",
|
||||
"ok": "OK",
|
||||
"apply": "適用",
|
||||
"close": "閉じる",
|
||||
"run_command": "コマンド実行",
|
||||
"custom_command_help": "ヘルプ",
|
||||
"about": "バージョン情報",
|
||||
"analyze": "解析",
|
||||
"paste_url": "URLを貼り付け",
|
||||
"select_videos": "動画を選択...",
|
||||
"video": "動画",
|
||||
"audio_only": "音声のみ",
|
||||
"custom_options": "カスタムオプション",
|
||||
"trim_video": "動画をトリミング",
|
||||
"download_settings": "ダウンロード設定",
|
||||
"update": "yt-dlpを更新",
|
||||
"select_defaults": "デフォルトを選択",
|
||||
"select_all": "すべて選択",
|
||||
"deselect_all": "すべて解除",
|
||||
"open_folder": "フォルダの場所を開く",
|
||||
"history": "履歴",
|
||||
"save_playlist": "プレイリストを別名で保存"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "カスタムオプション",
|
||||
"settings": "設定",
|
||||
"select_folder": "ダウンロードフォルダを選択",
|
||||
"sponsorblock_categories": "SponsorBlockカテゴリ",
|
||||
"sponsorblock_description": "ダウンロード時に自動的に削除する動画セグメントの種類を選択してください。\nSponsorBlockはコミュニティ提供のデータを使用してこれらのセグメントを識別します。",
|
||||
"select_subtitles": "字幕を選択",
|
||||
"filter_languages_placeholder": "言語でフィルタ (例: ja, en)...",
|
||||
"no_subtitles_available": "利用可能な字幕がありません",
|
||||
"matching": "一致",
|
||||
"ytdlp_log_title": "yt-dlpログ",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Cookieでログイン",
|
||||
"custom_command": "カスタムコマンド",
|
||||
"proxy": "プロキシ",
|
||||
"language": "言語",
|
||||
"updater": "アップデーター"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "認証用のCookieを提供する方法を選択してください。\nこれにより、プライベート動画や高品質の音声ファイルをダウンロードできます。",
|
||||
"cookie_source": "Cookieソース",
|
||||
"use_cookie_file": "Cookieファイルを使用",
|
||||
"extract_from_browser": "ブラウザから抽出",
|
||||
"recommended": "推奨",
|
||||
"cookie_file": "Cookieファイル",
|
||||
"cookie_file_placeholder": "cookies.txtへのパス...",
|
||||
"browser_selection": "ブラウザ選択",
|
||||
"browser_help": "Cookieを抽出するブラウザを選択してください:",
|
||||
"browser_label": "ブラウザ:",
|
||||
"profile_label": "プロファイル:",
|
||||
"profile_placeholder": "デフォルト",
|
||||
"browser_extract_message": "ブラウザのCookieは適用後に抽出されます",
|
||||
"file_selected_message": "Cookieファイルが選択されました - OKをクリックして適用",
|
||||
"select_file_title": "Cookieファイルを選択",
|
||||
"file_filter": "Cookieファイル (*.txt *.lwp)",
|
||||
"file_selected_title": "Cookieファイルが適用されました",
|
||||
"file_applied_message": "Cookieファイルが適用されました: {path}",
|
||||
"browser_selected_title": "ブラウザCookieが適用されました",
|
||||
"browser_applied_message": "ブラウザCookieが抽出されます: {browser}",
|
||||
"cleared_title": "Cookieがクリアされました",
|
||||
"cleared_message": "Cookie設定がクリアされました",
|
||||
"active_browser": "✓ 有効: ブラウザーのCookie ({browser})",
|
||||
"active_file": "✓ 有効: Cookieファイル ({file})",
|
||||
"none_active": "○ 有効なCookieはありません",
|
||||
"remember_settings": "次回の起動時にCookie設定を記憶する"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "以下にカスタムyt-dlpコマンドを入力してください。現在のURLは自動的に追加されます。<br><br>オプションの完全なリストと使用例については、<a href=\"{docs_url}\">こちらをクリックしてyt-dlp公式ドキュメントを参照してください</a>。<br><br>注: ダウンロードパスとファイル名テンプレートは自動的に処理されます。",
|
||||
"input_label": "yt-dlp引数:",
|
||||
"input_placeholder": "yt-dlp引数をここに入力...\n\n例: --extract-audio --audio-format mp3",
|
||||
"output_label": "コマンド出力:",
|
||||
"output_placeholder": "コマンド出力がここに表示されます...",
|
||||
"command_placeholder": "カスタムyt-dlpコマンドを入力...",
|
||||
"command_help": "使用可能なプレースホルダー:\n{url} - 動画URL\n{output} - 出力フォルダ\n\n例: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 完全なコマンド: {command}",
|
||||
"command_success": "✅ カスタムコマンドが正常に実行されました!",
|
||||
"command_failed": "❌ コマンドが終了コード{code}で失敗しました",
|
||||
"command_error": "❌ カスタムコマンドの実行エラー: {error}",
|
||||
"error_no_url": "❌ エラー: URL が指定されていません。メインウィンドウで URL を入力してください。",
|
||||
"error_no_command": "❌ エラー: コマンドが指定されていません。yt-dlp の引数を入力してください。",
|
||||
"executing": "🚀 カスタム yt-dlp コマンドを実行中",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ 引数: {command}",
|
||||
"download_path_label": "📁 ダウンロード先: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "ダウンロード用のプロキシ設定を構成します。直接接続の場合は空白のままにしてください。",
|
||||
"main_proxy": "メインプロキシ",
|
||||
"main_proxy_help": "すべてのダウンロード用のメインプロキシサーバー。HTTP/HTTPSおよびSOCKS5プロトコルをサポートします。",
|
||||
"proxy_url_label": "プロキシURL:",
|
||||
"proxy_url_placeholder": "http://proxy:port または socks5://proxy:port",
|
||||
"proxy_examples": "例:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "地域制限回避プロキシ",
|
||||
"geo_proxy_help": "地域制限を回避するための追加プロキシ。",
|
||||
"geo_proxy_url_label": "地域制限回避プロキシURL:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "メインプロキシをクリア",
|
||||
"clear_geo_proxy": "地域プロキシをクリア",
|
||||
"proxy_url": "プロキシURL",
|
||||
"proxy_placeholder": "http://proxy:port または socks5://proxy:port",
|
||||
"geo_bypass": "地域制限回避プロキシ (地域制限用)",
|
||||
"geo_bypass_placeholder": "http://proxy:port 地域ブロック回避用",
|
||||
"invalid_main_url": "メインプロキシのURL形式が無効です",
|
||||
"invalid_geo_url": "地域プロキシのURL形式が無効です",
|
||||
"main_configured": "メインプロキシが設定されました",
|
||||
"geo_configured": "地域プロキシが設定されました",
|
||||
"set_title": "プロキシを設定しました",
|
||||
"set_message": "メインプロキシを設定して保存しました: {proxy}",
|
||||
"geo_set_title": "ジオプロキシを設定しました",
|
||||
"geo_set_message": "ジオ検証プロキシを設定して保存しました: {proxy}",
|
||||
"cleared_title": "プロキシ設定をクリアしました",
|
||||
"cleared_message": "すべてのプロキシ設定をクリアして保存しました。",
|
||||
"saved_main": "保存されたメインプロキシ: {proxy}",
|
||||
"saved_geo": "保存されたジオプロキシ: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "ダウンロードを準備中...",
|
||||
"starting": "🚀 ダウンロードを開始しています...",
|
||||
"fetching_info": "🔍 動画情報を取得中...",
|
||||
"preparing_streams": "🎯 動画ストリームを準備中...",
|
||||
"downloading_audio": "⏬ 音声をダウンロード中...",
|
||||
"downloading_video": "⏬ 動画をダウンロード中...",
|
||||
"downloading_subtitle": "⏬ 字幕をダウンロード中...",
|
||||
"downloading": "⏬ ダウンロード中...",
|
||||
"completed": "✅ ダウンロード完了!",
|
||||
"video_completed": "✅ 動画ダウンロード完了!",
|
||||
"audio_completed": "✅ 音声ダウンロード完了!",
|
||||
"subtitle_completed": "✅ 字幕ダウンロード完了!",
|
||||
"completed_cleaning": "✅ ダウンロード完了!クリーンアップ中...",
|
||||
"cancelled": "ダウンロードがキャンセルされました",
|
||||
"processing_playlist": "📋 再生リストデータを処理中...",
|
||||
"paused": "ダウンロードが一時停止されました",
|
||||
"resumed": "ダウンロードが再開されました",
|
||||
"merging_formats": "✨ 後処理:フォーマットを結合中...",
|
||||
"removing_sponsor_segments": "✨ 後処理:スポンサーセグメントを削除中...",
|
||||
"speed": "速度",
|
||||
"eta": "予想時間",
|
||||
"please_enter_url": "最初にURLを入力してください。",
|
||||
"please_set_path": "最初にダウンロードパスを設定してください。",
|
||||
"please_enter_url_and_path": "URLを入力し、ダウンロードパスを設定してください。",
|
||||
"please_select_format": "最初にフォーマットを選択してください。",
|
||||
"downloading_fallback": "⚡ ダウンロード中..."
|
||||
},
|
||||
"update": {
|
||||
"title": "yt-dlpを更新",
|
||||
"checking": "アップデートを確認中...",
|
||||
"update_available": "アップデートが利用可能です!\n現在のバージョン: {current}\n最新バージョン: {latest}",
|
||||
"up_to_date": "yt-dlpは最新です (バージョン {version})",
|
||||
"could_not_determine": "バージョンを確認できませんでした。",
|
||||
"error_comparing": "バージョン比較エラー: {error}",
|
||||
"update_available_failed": "アップデートが利用可能です!(比較失敗)\n現在: {current}\n最新: {latest}",
|
||||
"updating": "更新中...",
|
||||
"initializing": "🚀 更新プロセスを初期化中...",
|
||||
"checking_current": "🔍 現在のインストールを確認中...",
|
||||
"found_at": "📍 yt-dlpが見つかりました: {path}",
|
||||
"error_getting_path": "❌ yt-dlpパスの取得エラー: {error}",
|
||||
"updating_binary": "📦 アプリ管理のyt-dlpバイナリを更新中...",
|
||||
"update_failed": "❌ yt-dlpの更新に失敗しました。もう一度試すか、インターネット接続を確認してください。",
|
||||
"binary_updated": "✅ バイナリが正常に更新されました!",
|
||||
"update_failed_stderr": "❌ yt-dlpの更新に失敗しました: {error}",
|
||||
"update_timeout": "❌ yt-dlp更新がタイムアウトしました。",
|
||||
"unexpected_error": "❌ 更新中に予期しないエラーが発生しました: {error}",
|
||||
"already_up_to_date": "✅ yt-dlpはすでに最新です!",
|
||||
"update_success": "✅ yt-dlpが正常に更新されました!",
|
||||
"already_latest": "yt-dlpは最新です(バージョン {version})",
|
||||
"network_error": "❌ 更新中にネットワークエラーが発生しました: {error}",
|
||||
"general_error": "❌ 更新に失敗しました: {error}",
|
||||
"update_in_progress_title": "更新中",
|
||||
"update_in_progress_message": "yt-dlpを更新中です。少々お待ちください。"
|
||||
},
|
||||
"about": {
|
||||
"title": "YTSageについて",
|
||||
"version": "バージョン {version}",
|
||||
"description": "クリーンなPySide6インターフェースを備えたモダンなYouTubeダウンローダー。",
|
||||
"author": "作成者: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "システム情報",
|
||||
"loading": "🔄 システム情報を読み込み中...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 ログ",
|
||||
"logs_tooltip": "アプリケーションのログフォルダを開く",
|
||||
"refreshing": "🔄 更新中...",
|
||||
"refresh_failed": "更新に失敗しました",
|
||||
"refresh_failed_message": "バージョン情報を更新できませんでした。",
|
||||
"detected": "✓ 検出済み",
|
||||
"missing": "✗ 不足",
|
||||
"not_available": "利用不可"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "動画をトリミング",
|
||||
"time_range_group": "時間範囲",
|
||||
"start_time": "開始時間 (HH:MM:SS)",
|
||||
"end_time": "終了時間 (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "開始時間 (HH:MM:SS)",
|
||||
"end_time_placeholder": "終了時間 (HH:MM:SS)",
|
||||
"force_keyframes": "カットポイントでキーフレームを強制",
|
||||
"help_text": "動画をトリミングするには開始時間と終了時間を設定してください。\n完全な動画をダウンロードする場合は空白のままにしてください。",
|
||||
"invalid_format": "時間フォーマットが無効です。HH:MM:SSフォーマットを使用してください。",
|
||||
"start_after_end": "開始時間を終了時間より後にすることはできません。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "ダウンロード設定",
|
||||
"download_path": "ダウンロードパス",
|
||||
"browse": "参照...",
|
||||
"speed_limit": "速度制限",
|
||||
"speed_limit_placeholder": "なし",
|
||||
"generic_mode": "汎用モード",
|
||||
"enable_generic_mode": "汎用モードを有効化(YouTube以外のサイトをサポート)",
|
||||
"generic_mode_help": "Dailymotion、CBC Gem、および yt-dlp が対応する他のサイトからのダウンロードを可能にします。",
|
||||
"auto_update_ytdlp": "yt-dlp自動更新",
|
||||
"enable_auto_updates": "yt-dlp自動更新を有効化",
|
||||
"update_frequency": "更新頻度:",
|
||||
"check_startup": "起動時に毎回確認 (チェック間隔は最低1時間)",
|
||||
"check_daily": "毎日確認",
|
||||
"check_weekly": "毎週確認",
|
||||
"check_updates_now": "yt-dlpを更新",
|
||||
"update_check_title": "更新確認",
|
||||
"could_not_determine_version": "yt-dlpの現在のバージョンを確認できませんでした。",
|
||||
"update_available_dialog": "アップデートが利用可能です!\n\n現在: {current}\n最新: {latest}\n\nOKをクリックして更新を続行します。",
|
||||
"up_to_date_dialog": "yt-dlpは最新です!\n\n現在のバージョン: {version}",
|
||||
"error_checking_updates": "更新確認エラー: {error}",
|
||||
"settings_saved_title": "設定を保存しました",
|
||||
"settings_saved_message": "自動更新設定が正常に保存されました!",
|
||||
"error_title": "エラー",
|
||||
"failed_save_settings": "自動更新設定の保存に失敗しました。",
|
||||
"error_saving_settings": "自動更新設定の保存エラー: {error}",
|
||||
"ytdlp_channel": "yt-dlp リリースチャンネル",
|
||||
"ytdlp_channel_stable": "安定版(テスト済みリリース)",
|
||||
"ytdlp_channel_nightly": "Nightly(毎日更新、yt-dlp推奨)",
|
||||
"ytdlp_channel_description": "安定版とNightlyリリースのどちらかを選択してください。Nightlyビルドは最新の修正と機能で毎日更新されます。いつでもチャンネルを切り替えることができます。",
|
||||
"ytdlp_switching_channel": "{channel}チャンネルに切り替え中...",
|
||||
"ytdlp_channel_switched": "✅ {channel}チャンネルへの切り替えに成功しました!",
|
||||
"ytdlp_channel_switch_failed": "❌ チャンネルの切り替えに失敗しました: {error}",
|
||||
"ytdlp_current_channel": "現在のチャンネル: {channel}",
|
||||
"app_updates_title": "YTSageの更新",
|
||||
"check_app_updates": "起動時にYTSageの更新を確認する",
|
||||
"check_beta_updates": "ベータ版の更新を受け取る",
|
||||
"auto_update_title": "自動更新設定",
|
||||
"auto_update_header": "🔄 自動更新設定",
|
||||
"auto_update_description": "yt-dlpの自動更新を設定して、最新の機能とバグ修正を確実に入手してください。",
|
||||
"current_status": "現在のステータス",
|
||||
"current_version_label": "現在のyt-dlpバージョン: 確認中...",
|
||||
"last_check_label": "最終更新確認: なし",
|
||||
"next_check_label": "次回確認: 設定に基づく",
|
||||
"manual_check_button": "🔍 今すぐ更新を確認",
|
||||
"update_frequency_group": "更新頻度",
|
||||
"save_settings": "設定を保存",
|
||||
"settings_saved_successfully": "✅ 設定が正常に保存されました!",
|
||||
"error_saving": "❌ 設定の保存エラー: {error}",
|
||||
"output_format_settings": "出力形式設定",
|
||||
"force_output_format": "マージ時に出力形式を強制",
|
||||
"preferred_format": "優先形式:",
|
||||
"force_format_help": "有効にすると、マージされた動画は優先形式に変換されます。無効にすると、yt-dlpが自動的に決定します。",
|
||||
"format_mp4": "MP4(最も互換性が高い)",
|
||||
"format_webm": "WebM(モダン、オープン)",
|
||||
"format_mkv": "MKV(機能豊富)",
|
||||
"audio_format_settings": "オーディオ形式設定",
|
||||
"force_audio_format": "オーディオのみのダウンロードにオーディオ形式を強制",
|
||||
"audio_normalization": "オーディオ正規化 (EBU R128)",
|
||||
"audio_normalization_help": "有効にすると、オーディオトラックが正規化されます。注:これには再エンコードが必要なため、特定のオーディオ形式(MP3やM4Aなど)を強制する必要があります。",
|
||||
"preferred_audio_format": "優先オーディオ形式:",
|
||||
"force_audio_format_help": "有効にすると、オーディオのみのダウンロードが優先形式に変換されます。これはオーディオ形式をダウンロードする場合にのみ適用されます。",
|
||||
"audio_format_best": "最高(変換なし)",
|
||||
"audio_format_aac": "AAC(編集に適している)",
|
||||
"audio_format_mp3": "MP3(汎用)",
|
||||
"audio_format_flac": "FLAC(ロスレス)",
|
||||
"audio_format_wav": "WAV(非圧縮)",
|
||||
"audio_format_opus": "Opus(効率的)",
|
||||
"audio_format_m4a": "M4A(Apple)",
|
||||
"audio_format_vorbis": "Vorbis(オープン)",
|
||||
"filename_format": "出力ファイル名の形式",
|
||||
"filename_format_help": "利用可能な変数: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s。標準のyt-dlp出力テンプレート構文がサポートされています。",
|
||||
"tab_general": "一般",
|
||||
"tab_format": "フォーマット",
|
||||
"tab_file": "ファイル",
|
||||
"concurrent_fragments": "同時接続数",
|
||||
"concurrent_fragments_help": "ダウンロードごとの接続数。値を大きくするとスロットリングを回避できますが、高すぎると一時的なブロックが発生する可能性があります。デフォルト: 1。",
|
||||
"defaults_settings": "デフォルト選択設定",
|
||||
"default_video_quality": "デフォルトのビデオ解像度 (高さ):",
|
||||
"default_subtitle_language": "デフォルトの字幕言語:",
|
||||
"defaults_help": "優先するビデオの高さと字幕言語(カンマ区切り)を設定します。利用可能な場合は自動的に選択されます。"
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "YouTubeの動画URLまたはプレイリストURLを入力",
|
||||
"url_placeholder_generic": "対応サイトの動画またはプレイリストURLを入力",
|
||||
"merge_subtitles": "字幕を結合",
|
||||
"save_thumbnail": "サムネイルを保存",
|
||||
"save_description": "説明を保存",
|
||||
"embed_chapters": "チャプターを埋め込む",
|
||||
"subtitles_selected": "{count}個選択",
|
||||
"all_selected": "すべて選択済み",
|
||||
"select_videos_all": "動画を選択... (すべて選択済み)",
|
||||
"please_enter_url": "最初にURLを入力してください",
|
||||
"error_title": "エラー",
|
||||
"cookie_file_selected_title": "Cookieファイルが選択されました",
|
||||
"cookie_file_selected_message": "選択されたCookieファイル: {path}",
|
||||
"browser_cookies_selected_title": "ブラウザCookieが選択されました",
|
||||
"browser_cookies_selected_message": "ブラウザCookieが抽出されます: {browser}",
|
||||
"error_no_format_info": "エラー: 利用可能なフォーマット情報がありません。",
|
||||
"error_extract_info": "エラー: 基本的な動画情報を抽出できませんでした。リンクを確認してください。",
|
||||
"analyzing_preparing": "リクエストを準備中...",
|
||||
"analyzing_extracting_basic": "基本情報を抽出中...",
|
||||
"analyzing_extracting_detailed": "詳細情報を抽出中...",
|
||||
"analyzing_processing_video": "動画データを処理中...",
|
||||
"analyzing_processing_formats": "フォーマットを処理中...",
|
||||
"analyzing_loading_thumbnail": "サムネイルを読み込み中...",
|
||||
"analyzing_processing_subtitles": "字幕を処理中...",
|
||||
"analyzing_updating_table": "フォーマットテーブルを更新中...",
|
||||
"analysis_complete": "解析完了!",
|
||||
"analyzing_extracting_ytdlp": "情報を抽出中...",
|
||||
"analyzing_fetching_first_video": "最初のビデオの形式を取得しています...",
|
||||
"analyzing_processing_data": "データを処理中...",
|
||||
"analyzing_processing_formats_ytdlp": "フォーマットを処理中...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "サムネイルを読み込み中...",
|
||||
"analyzing_processing_subtitles_ytdlp": "字幕を処理中...",
|
||||
"select_subtitles": "字幕を選択...",
|
||||
"sponsorblock_categories": "SponsorBlockカテゴリ...",
|
||||
"invalid_url_or_enter": "無効なURLまたはURLを入力してください。",
|
||||
"zero_selected": "0個選択",
|
||||
"analyze_first_tooltip": "最初に動画を分析してください",
|
||||
"audio_mode_disabled": "音声のみモードでは利用できません",
|
||||
"select_subtitles_first": "最初に字幕を選択してください",
|
||||
"settings_tooltip": "現在のパス: {path}\n速度制限: {speed_limit}",
|
||||
"speed_limit_none": "なし",
|
||||
"open_folder_error": "フォルダーを開けませんでした: {error}",
|
||||
"time_range_set": "セクション設定: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "スポンサー",
|
||||
"sponsor_desc": "有料広告、有料推薦、直接広告",
|
||||
"selfpromo": "無報酬/自己宣伝",
|
||||
"selfpromo_desc": "クリエイター自身のコンテンツの無報酬プロモーション",
|
||||
"interaction": "インタラクションリマインダー",
|
||||
"interaction_desc": "視聴者に「いいね」、登録、ソーシャルメディアでのフォローを求める",
|
||||
"intro": "イントロ",
|
||||
"intro_desc": "スキップ可能な動画のイントロ",
|
||||
"outro": "アウトロ/エンドカード",
|
||||
"outro_desc": "エンドクレジットまたは動画の終了",
|
||||
"preview": "プレビュー/要約",
|
||||
"preview_desc": "前回の動画の短い要約または今後のコンテンツのプレビュー",
|
||||
"music_offtopic": "非音楽セクション",
|
||||
"music_offtopic_desc": "音楽動画のみ。非音楽セクションを示す",
|
||||
"filler": "余談フィラー",
|
||||
"filler_desc": "フィラーやユーモアのためだけに追加された余談シーン"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "チャンネル",
|
||||
"views": "👁 視聴回数",
|
||||
"likes": "👍 いいね",
|
||||
"upload_date": "📅 アップロード日",
|
||||
"duration": "⏱ 長さ",
|
||||
"unknown_channel": "不明なチャンネル",
|
||||
"unknown_date": "不明な日付",
|
||||
"unknown_title": "不明なタイトル"
|
||||
},
|
||||
"command": {
|
||||
"running": "実行中...",
|
||||
"run_command": "コマンド実行"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0個選択",
|
||||
"one_selected": "1カテゴリ選択",
|
||||
"count_selected": "{count}個選択"
|
||||
},
|
||||
"status": {
|
||||
"ready": "準備完了",
|
||||
"file_exists": "⚠️ ファイルがすでに存在します",
|
||||
"video_file_exists": "⚠️ 動画ファイルがすでに存在します",
|
||||
"audio_file_exists": "⚠️ 音声ファイルがすでに存在します",
|
||||
"subtitle_file_exists": "⚠️ 字幕ファイルがすでに存在します",
|
||||
"cancelling": "ダウンロードをキャンセル中...",
|
||||
"thumbnail_saved": "✅ サムネイルを保存しました: {filename}",
|
||||
"thumbnail_error": "❌ サムネイルエラー: {error}",
|
||||
"thumbnail_no_image": "保存できるサムネイルがありません"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "エラー: 再生リストに有効な動画が含まれていません。",
|
||||
"playlist_no_url": "エラー: 再生リストの最初の動画のURLを取得できませんでした。",
|
||||
"ytdlp_not_found": "エラー: yt-dlp実行ファイルが見つかりません。最初にyt-dlpをインストールしてください。",
|
||||
"ytdlp_not_found_path": "エラー: yt-dlp実行ファイルが見つかりません。不適切なインストールまたはPATHの問題が原因の可能性があります。",
|
||||
"no_data_returned": "エラー: yt-dlpからデータが返されませんでした",
|
||||
"no_format_info": "エラー: 利用可能なフォーマット情報がありません。",
|
||||
"analysis_timeout": "エラー: 解析がタイムアウトしました。もう一度試してください。",
|
||||
"invalid_speed_limit": "❌ エラー: 設定で無効な速度制限値が設定されています。",
|
||||
"ytdlp_failed": "エラー: yt-dlpが失敗しました: {error}",
|
||||
"parse_failed": "エラー: yt-dlp出力の解析に失敗しました: {error}",
|
||||
"analysis_failed": "エラー: 解析が失敗しました: {error}",
|
||||
"generic_error": "エラー: {error}",
|
||||
"download_failed_return_code_conflict": "ダウンロードは戻りコード {return_code} で失敗しました。複数の yt-dlp インストールの競合が原因の可能性があります。システムにインストールされた yt-dlp(例: snap や apt)をアンインストールしてアプリを再起動してください。",
|
||||
"download_failed_return_code": "ダウンロードは戻りコード {return_code} で失敗しました",
|
||||
"direct_command_error": "直接コマンドのエラー: {error}",
|
||||
"private_video": "この動画は非公開かもしれません。カスタムオプションからCookieを使用してください。"
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "アップデートが利用可能です",
|
||||
"new_version_available": "YTSageの新しいバージョンが利用可能です!",
|
||||
"current_version_label": "現在のバージョン:",
|
||||
"latest_version_label": "最新バージョン:",
|
||||
"changelog": "変更履歴",
|
||||
"download_update": "更新をダウンロード",
|
||||
"remind_later": "後で通知"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "不明な再生リスト",
|
||||
"total_videos": "総動画数: {count}",
|
||||
"display_format": "再生リスト: {title} | {count}個の動画",
|
||||
"select_videos_title": "プレイリスト動画を選択",
|
||||
"save_as": "プレイリストを別名で保存",
|
||||
"save_success_title": "成功",
|
||||
"saved_successfully": "プレイリストを正常に保存しました。",
|
||||
"save_error_title": "保存エラー",
|
||||
"no_videos_to_save": "プレイリストのエントリが見つかりません!",
|
||||
"save_error_msg": "プレイリストの保存に失敗しました。"
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count}個選択"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "ファイルが既に存在します",
|
||||
"message": "ファイルが既に存在します:\n{filename}",
|
||||
"info": "この動画は既にダウンロードされています。"
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "最終更新確認: なし",
|
||||
"last_check": "最終更新確認: {time}",
|
||||
"next_check_disabled": "次回確認: 無効",
|
||||
"next_check_startup": "次回確認: 起動時",
|
||||
"next_check_overdue": "次回確認: 今すぐ (遅延)",
|
||||
"next_check": "次回確認: {time}",
|
||||
"next_check_error": "次回確認: 計算エラー",
|
||||
"checking": "🔄 確認中...",
|
||||
"check_now": "🔍 今すぐ更新を確認",
|
||||
"current_version": "現在のyt-dlpバージョン: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URLを空にすることはできません",
|
||||
"invalid_format": "無効なURL形式",
|
||||
"invalid_scheme": "URLはhttp://またはhttps://で始まる必要があります",
|
||||
"missing_domain": "無効なURL:ドメイン名がありません",
|
||||
"unsupported_platform": "YTSageはYouTubeとYouTube MusicのURLのみをサポートしています。\nドメイン'{domain}'はサポートされていません。",
|
||||
"invalid_youtu_be": "無効なyoutu.be URL:ビデオIDがありません"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "これは非公開動画です。Cookieを使用してアカウントにログインすることでダウンロードできます。\n認証するには、「カスタムオプション」→「Cookieでログイン」→「ブラウザからCookieを抽出」に移動してください。",
|
||||
"age_restricted": "この動画は年齢制限があります。アクセスするにはログインする必要があります。\nアカウントで認証するには、「カスタムオプション」→「Cookieでログイン」を使用してください。",
|
||||
"geo_blocked": "この動画はお住まいの地域では利用できません(地理的にブロックされています)。\nVPNを使用する必要がある場合や、お住まいの国で動画が制限されている可能性があります。",
|
||||
"video_unavailable": "この動画は削除されたか、利用できなくなりました。\n動画はアップローダーによって削除されたか、ポリシー違反のために削除された可能性があります。",
|
||||
"live_stream": "これはアクティブな間はダウンロードできないライブストリームです。\nストリームが終了するまで待ってから、アーカイブされたバージョンのダウンロードを試してください。",
|
||||
"playlist_error": "このプレイリストにアクセスできません。非公開、削除、または空の可能性があります。\nプレイリストが存在し、公開されているか確認してください。",
|
||||
"network_error": "ネットワーク接続エラー。インターネット接続を確認して、もう一度お試しください。\n問題が解決しない場合は、ビデオサーバーが一時的に利用できない可能性があります。",
|
||||
"invalid_url": "無効またはサポートされていないURL。リンクを確認して、もう一度お試しください。\n有効なYouTube、Vimeo、またはその他のサポートされているプラットフォームのURLを使用していることを確認してください。",
|
||||
"premium_content": "このコンテンツにはYouTube Premiumまたはチャンネルメンバーシップが必要です。\nこのコンテンツにアクセスできるアカウントでログインする必要があります。",
|
||||
"copyright_blocked": "著作権の申し立てにより、この動画はブロックされています。\nコンテンツ所有者がこの動画へのアクセスを制限しています。",
|
||||
"extraction_failed": "動画情報の抽出に失敗しました。これは一時的な問題である可能性があります。\n数分後にもう一度お試しいただくか、動画リンクが正しいか確認してください。",
|
||||
"generic_error": "動画情報を抽出できませんでした。リンクを確認してください。\n技術的詳細:{error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "ダウンロード履歴",
|
||||
"clear_all": "すべてクリア",
|
||||
"clear_confirm_title": "履歴をクリア?",
|
||||
"clear_confirm_message": "本当によろしいですか?この操作は取り消せません。",
|
||||
"no_history": "まだ履歴がありません",
|
||||
"no_history_description": "ダウンロードがここに表示されます",
|
||||
"loading": "履歴を読み込み中...",
|
||||
"search_placeholder": "検索...",
|
||||
"open_location": "場所を開く",
|
||||
"redownload": "再ダウンロード",
|
||||
"remove": "削除",
|
||||
"file_not_found": "ファイルが見つかりません",
|
||||
"file_not_found_message": "ファイルが移動または削除されました:\n{path}",
|
||||
"downloaded_on": "ダウンロード日:{date}",
|
||||
"file_size": "サイズ:{size}",
|
||||
"audio_download": "オーディオ",
|
||||
"video_download": "ビデオ",
|
||||
"remove_confirm_title": "削除?",
|
||||
"remove_confirm_message": "履歴から削除しますか?\n\n{title}",
|
||||
"item_removed": "削除しました",
|
||||
"history_cleared": "履歴をクリアしました",
|
||||
"entries_count": "{count} 件",
|
||||
"one_entry": "1 件",
|
||||
"redownload_confirm_title": "再ダウンロード?",
|
||||
"redownload_confirm_message": "再ダウンロードしますか?\n\n{title}",
|
||||
"redownload_started": "ダウンロード開始",
|
||||
"no_url_error": "履歴エントリにURLがありません",
|
||||
"redownload_failed": "再ダウンロードの開始に失敗しました: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "FFmpegのインストール",
|
||||
"installation_message": "YTSageは動画処理にFFmpegが必要です。\n\n以下のインストールオプションを選択してください:",
|
||||
"install_button": "FFmpegをインストール",
|
||||
"manual_guide": "手動ガイド",
|
||||
"installation_failed": "FFmpegのインストール中に問題が発生しました。",
|
||||
"already_installed": "FFmpegはすでにインストールされています!",
|
||||
"installation_complete": "インストール完了。このダイアログを閉じてYTSageを続けて使用できます。",
|
||||
"installing": "FFmpegをインストール中... お待ちください",
|
||||
"install_success": "FFmpegが正常にインストールされました!",
|
||||
"installation_complete_close": "インストール完了。今すぐこのダイアログを閉じてYTSageを続けて使用できます。",
|
||||
"try_manual": "代わりに手動インストールガイドをお試しください。"
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "yt-dlpのセットアップが必要です",
|
||||
"description": "YTSageは動画のダウンロードにyt-dlpが必要です。<br><br>アプリのローカルディレクトリにyt-dlpが見つかりませんでした。{os_name}システム用にyt-dlpをセットアップする必要があります。<br><br>以下のオプションを選択してください:",
|
||||
"option_auto": "自動でダウンロード(推奨)",
|
||||
"option_manual": "パスを手動で選択",
|
||||
"setup_button": "yt-dlpをセットアップ",
|
||||
"downloading": "yt-dlpをダウンロード中...",
|
||||
"success": "yt-dlpが正常にインストールされました!",
|
||||
"error": "エラー: {error}",
|
||||
"download_failed_title": "ダウンロード失敗",
|
||||
"download_failed_message": "yt-dlpのダウンロードに失敗しました: {error}",
|
||||
"select_executable_title": "yt-dlp実行ファイルを選択",
|
||||
"copied_to": "yt-dlpを{path}にコピーしました",
|
||||
"setup_error_title": "セットアップエラー",
|
||||
"copy_error": "yt-dlpをアプリディレクトリにコピーできません: {error}",
|
||||
"invalid_executable_title": "無効な実行ファイル",
|
||||
"invalid_executable_message": "選択されたファイルは有効なyt-dlp実行ファイルではありません。",
|
||||
"verify_error": "yt-dlp実行ファイルの検証に失敗しました: {error}",
|
||||
"setup_failed_title": "セットアップ失敗",
|
||||
"setup_failed_message": "yt-dlpのセットアップに失敗しました。いくつかの機能が正しく動作しない可能性があります。",
|
||||
"success_dialog_title": "yt-dlpセットアップ",
|
||||
"success_dialog_message": "yt-dlpの設定に成功しました:\n{path}",
|
||||
"file_filter_windows": "実行ファイル (*.exe)",
|
||||
"file_filter_all": "すべてのファイル (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "FFmpegバージョンチェッカー",
|
||||
"current_version": "現在のバージョン:",
|
||||
"latest_version": "最新バージョン:",
|
||||
"status_up_to_date": "✓ 最新",
|
||||
"status_update_available": "⚠ 更新可能",
|
||||
"status_not_installed": "✗ 未インストール",
|
||||
"status_idle": "開始するには「バージョンを確認」をクリック",
|
||||
"status_checking": "🔄 バージョンを確認中...",
|
||||
"check_updates": "バージョンを確認",
|
||||
"check_failed": "バージョンの確認に失敗しました。インターネット接続を確認してください。",
|
||||
"description": "FFmpegのバージョンを確認し、利用可能な最新バージョンと比較します。",
|
||||
"guide_info": " FFmpegをインストールまたは更新するには、<a href='https://github.com/oop7/ffmpeg-install-guide'>こちらをクリックして包括的なインストールガイドをご覧ください</a>。"
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Denoのセットアップが必要です",
|
||||
"setup_description": "YTSageは特定の機能を実行するためにDenoが必要です。<br><br>アプリのローカルディレクトリにDenoが見つかりませんでした。YTSageはお使いの{os_name}システム用にDenoをセットアップする必要があります。<br><br>「Denoをセットアップ」をクリックして、自動的にダウンロードとインストールを行います。",
|
||||
"setup_button": "Denoをセットアップ",
|
||||
"downloading": "Denoをダウンロード中...",
|
||||
"extracting": "Denoを展開中...",
|
||||
"verifying": "Denoを検証中...",
|
||||
"success": "Denoが正常にインストールされました!",
|
||||
"download_failed": "ダウンロード失敗",
|
||||
"download_error": "Denoのダウンロードに失敗しました:{error}",
|
||||
"verification_failed": "SHA256検証に失敗しました。ダウンロードされたファイルが破損しているか、改ざんされている可能性があります。",
|
||||
"setup_failed": "Denoのセットアップに失敗しました。一部の機能が正しく動作しない可能性があります。",
|
||||
"setup_error": "セットアップエラー"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Denoバージョンチェッカーとアップデーター",
|
||||
"description": "Denoのバージョンを確認し、最新バージョンに更新します。",
|
||||
"current_version": "現在のバージョン:",
|
||||
"latest_version": "最新バージョン:",
|
||||
"status_idle": "開始するには「更新を確認」をクリックしてください",
|
||||
"status_checking": "🔄 更新を確認中...",
|
||||
"status_up_to_date": "✓ 最新",
|
||||
"status_update_available": "⚠ 更新が利用可能",
|
||||
"status_not_installed": "✗ インストールされていません",
|
||||
"check_updates": "更新を確認",
|
||||
"update_now": "Denoを更新",
|
||||
"updating": "🔄 Denoを更新中...",
|
||||
"update_success": "✅ Denoが正常に更新されました!",
|
||||
"update_failed": "❌ 更新が失敗しました:{error}",
|
||||
"check_failed": "更新の確認に失敗しました。インターネット接続を確認してください。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Polski (Polish)",
|
||||
"select_language": "Wybierz język:",
|
||||
"current_language": "Aktualny język: {language}",
|
||||
"restart_required": "Zmiana języka zostanie zastosowana po ponownym uruchomieniu aplikacji.",
|
||||
"english": "Angielski",
|
||||
"spanish": "Hiszpański",
|
||||
"portuguese": "Portugalski",
|
||||
"russian": "Rosyjski",
|
||||
"chinese": "Chiński",
|
||||
"german": "Niemiecki",
|
||||
"french": "Francuski",
|
||||
"hindi": "Hindi",
|
||||
"indonesian": "Indonezyjski",
|
||||
"turkish": "Turecki",
|
||||
"polish": "Polski",
|
||||
"italian": "Włoski",
|
||||
"arabic": "Arabski",
|
||||
"japanese": "Japoński",
|
||||
"help_text": "Wybierz preferowany język interfejsu.",
|
||||
"restart_notice": "Zmiana języka zostanie zastosowana po ponownym uruchomieniu aplikacji."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Gotowy"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Pokaż formaty:",
|
||||
"video_format": "Format wideo:",
|
||||
"audio_format": "Format dźwięku:",
|
||||
"no_formats": "Brak dostępnych formatów",
|
||||
"loading": "Ładowanie formatów...",
|
||||
"select": "Wybierz",
|
||||
"quality": "Jakość",
|
||||
"extension": "Rozszerzenie",
|
||||
"resolution": "Rozdzielczość",
|
||||
"file_size": "Rozmiar pliku",
|
||||
"codec": "Kodek",
|
||||
"audio": "Dźwięk",
|
||||
"fps": "KL/S",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Dźwięk zostanie połączony",
|
||||
"has_audio": "✓ Zawiera dźwięk",
|
||||
"audio_only": "Tylko dźwięk",
|
||||
"best_4k": "Najlepsza (4K)",
|
||||
"best_2k": "Najlepsza (2K)",
|
||||
"high_1080p": "Wysoka (1080p)",
|
||||
"high_720p": "Wysoka (720p)",
|
||||
"medium_480p": "Średnia (480p)",
|
||||
"low_quality": "Niska jakość",
|
||||
"best_audio": "Najlepszy dźwięk",
|
||||
"high_audio": "Wysoki dźwięk",
|
||||
"medium_audio": "Średni dźwięk",
|
||||
"low_audio": "Niski dźwięk",
|
||||
"audio_only_resolution": "Tylko dźwięk"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Zresetuj",
|
||||
"download": "Pobierz",
|
||||
"pause": "Wstrzymaj",
|
||||
"resume": "Wznów",
|
||||
"cancel": "Anuluj",
|
||||
"browse": "Przeglądaj",
|
||||
"clear": "Wyczyść",
|
||||
"ok": "OK",
|
||||
"apply": "Zastosuj",
|
||||
"close": "Zamknij",
|
||||
"run_command": "Uruchom polecenie",
|
||||
"custom_command_help": "Pomoc",
|
||||
"about": "O programie",
|
||||
"analyze": "Analizuj",
|
||||
"paste_url": "Wklej URL",
|
||||
"select_videos": "Wybierz wideo...",
|
||||
"video": "Wideo",
|
||||
"audio_only": "Tylko dźwięk",
|
||||
"custom_options": "Opcje niestandardowe",
|
||||
"trim_video": "Przytnij wideo",
|
||||
"download_settings": "Ustawienia pobierania",
|
||||
"update": "Zaktualizuj yt-dlp",
|
||||
"select_defaults": "Wybierz domyślne",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"deselect_all": "Odznacz wszystko",
|
||||
"open_folder": "Otwórz lokalizację folderu",
|
||||
"history": "Historia",
|
||||
"save_playlist": "Zapisz playlistę jako"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Opcje niestandardowe",
|
||||
"settings": "Ustawienia",
|
||||
"select_folder": "Wybierz folder pobierania",
|
||||
"sponsorblock_categories": "Kategorie SponsorBlock",
|
||||
"sponsorblock_description": "Wybierz typy segmentów wideo do automatycznego usunięcia podczas pobierania.\nSponsorBlock używa danych przesyłanych przez społeczność do identyfikacji tych segmentów.",
|
||||
"select_subtitles": "Wybierz napisy",
|
||||
"filter_languages_placeholder": "Filtruj języki (np: pl, en)...",
|
||||
"no_subtitles_available": "Brak dostępnych napisów",
|
||||
"matching": "dopasowujące",
|
||||
"ytdlp_log_title": "Log yt-dlp",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Zaloguj za pomocą ciasteczek",
|
||||
"custom_command": "Polecenie niestandardowe",
|
||||
"proxy": "Proxy",
|
||||
"language": "Język",
|
||||
"updater": "Aktualizator"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Wybierz sposób dostarczania ciasteczek do uwierzytelniania.\nUmożliwia to pobieranie prywatnych filmów i wysokiej jakości plików audio.",
|
||||
"cookie_source": "Źródło ciasteczek",
|
||||
"use_cookie_file": "Użyj pliku ciasteczek",
|
||||
"extract_from_browser": "Wyodrębnij z przeglądarki",
|
||||
"recommended": "Zalecane",
|
||||
"cookie_file": "Plik ciasteczek",
|
||||
"cookie_file_placeholder": "Ścieżka do pliku cookies.txt...",
|
||||
"browser_selection": "Wybór przeglądarki",
|
||||
"browser_help": "Wybierz przeglądarkę do wyodrębnienia ciasteczek:",
|
||||
"browser_label": "Przeglądarka:",
|
||||
"profile_label": "Profil:",
|
||||
"profile_placeholder": "Domyślny",
|
||||
"browser_extract_message": "Ciasteczka przeglądarki zostaną wyodrębnione po zastosowaniu",
|
||||
"file_selected_message": "Wybrano plik ciasteczek - Kliknij OK, aby zastosować",
|
||||
"select_file_title": "Wybierz plik ciasteczek",
|
||||
"file_filter": "Pliki ciasteczek (*.txt *.lwp)",
|
||||
"file_selected_title": "Zastosowano plik ciasteczek",
|
||||
"file_applied_message": "Zastosowano plik ciasteczek: {path}",
|
||||
"browser_selected_title": "Zastosowano ciasteczka przeglądarki",
|
||||
"browser_applied_message": "Ciasteczka przeglądarki zostaną wyodrębnione z: {browser}",
|
||||
"cleared_title": "Wyczyszczono ciasteczka",
|
||||
"cleared_message": "Ustawienia ciasteczek zostały wyczyszczone",
|
||||
"active_browser": "✓ Aktywne: ciasteczka przeglądarki ({browser})",
|
||||
"active_file": "✓ Aktywny: plik ciasteczek ({file})",
|
||||
"none_active": "○ Brak aktywnych ciasteczek",
|
||||
"remember_settings": "Zapamiętaj ustawienia plików cookie przy następnym uruchomieniu"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Wprowadź niestandardowe polecenie yt-dlp poniżej. Aktualny URL zostanie automatycznie dodany.<br><br>Aby uzyskać pełną listę opcji i przykładów użycia <a href=\"{docs_url}\">kliknij tutaj, aby zobaczyć oficjalną dokumentację yt-dlp</a>.<br><br>Uwaga: Ścieżka pobierania i szablon nazwy pliku są obsługiwane automatycznie.",
|
||||
"input_label": "Argumenty yt-dlp:",
|
||||
"input_placeholder": "Wprowadź argumenty yt-dlp tutaj...\n\nPrzykład: --extract-audio --audio-format mp3",
|
||||
"output_label": "Wynik polecenia:",
|
||||
"output_placeholder": "Wynik polecenia pojawi się tutaj...",
|
||||
"command_placeholder": "Wprowadź niestandardowe polecenie yt-dlp...",
|
||||
"command_help": "Dostępne placeholdery:\n{url} - URL wideo\n{output} - Folder wyjściowy\n\nPrzykład: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Pełne polecenie: {command}",
|
||||
"command_success": "✅ Polecenie niestandardowe wykonane pomyślnie!",
|
||||
"command_failed": "❌ Polecenie nie powiodło się z kodem wyjścia {code}",
|
||||
"command_error": "❌ Błąd wykonywania polecenia niestandardowego: {error}",
|
||||
"error_no_url": "❌ Błąd: nie podano URL. Wprowadź URL w głównym oknie.",
|
||||
"error_no_command": "❌ Błąd: nie podano polecenia. Wprowadź argumenty yt-dlp.",
|
||||
"executing": "🚀 Uruchamianie niestandardowego polecenia yt-dlp",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Argumenty: {command}",
|
||||
"download_path_label": "📁 Ścieżka pobierania: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Skonfiguruj ustawienia proxy dla pobierania. Pozostaw puste dla bezpośredniego połączenia.",
|
||||
"main_proxy": "Główny proxy",
|
||||
"main_proxy_help": "Główny serwer proxy dla wszystkich pobierań. Obsługuje protokoły HTTP/HTTPS i SOCKS5.",
|
||||
"proxy_url_label": "URL Proxy:",
|
||||
"proxy_url_placeholder": "http://proxy:port lub socks5://proxy:port",
|
||||
"proxy_examples": "Przykłady:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Proxy omijania geograficznego",
|
||||
"geo_proxy_help": "Dodatkowy proxy specjalnie do omijania ograniczeń geograficznych.",
|
||||
"geo_proxy_url_label": "URL proxy omijania geograficznego:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "Wyczyść główny proxy",
|
||||
"clear_geo_proxy": "Wyczyść proxy geograficzny",
|
||||
"proxy_url": "URL Proxy",
|
||||
"proxy_placeholder": "http://proxy:port lub socks5://proxy:port",
|
||||
"geo_bypass": "Proxy omijania geograficznego (dla ograniczeń geograficznych)",
|
||||
"geo_bypass_placeholder": "http://proxy:port do omijania geo-blokowania",
|
||||
"invalid_main_url": "Nieprawidłowy format URL głównego proxy",
|
||||
"invalid_geo_url": "Nieprawidłowy format URL proxy geograficznego",
|
||||
"main_configured": "Główny proxy skonfigurowany",
|
||||
"geo_configured": "Proxy geograficzny skonfigurowany",
|
||||
"set_title": "Ustawiono proxy",
|
||||
"set_message": "Główne proxy ustawione i zapisane: {proxy}",
|
||||
"geo_set_title": "Ustawiono proxy geo",
|
||||
"geo_set_message": "Proxy weryfikacji geo ustawione i zapisane: {proxy}",
|
||||
"cleared_title": "Wyczyszczono ustawienia proxy",
|
||||
"cleared_message": "Wszystkie ustawienia proxy zostały wyczyszczone i zapisane.",
|
||||
"saved_main": "Zapisany główny proxy: {proxy}",
|
||||
"saved_geo": "Zapisany proxy geo: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Przygotowywanie pobierania...",
|
||||
"starting": "🚀 Rozpoczynanie pobierania...",
|
||||
"fetching_info": "🔍 Pobieranie informacji o wideo...",
|
||||
"preparing_streams": "🎯 Przygotowywanie strumieni wideo...",
|
||||
"downloading_audio": "⏬ Pobieranie dźwięku...",
|
||||
"downloading_video": "⏬ Pobieranie wideo...",
|
||||
"downloading_subtitle": "⏬ Pobieranie napisów...",
|
||||
"downloading": "⏬ Pobieranie...",
|
||||
"completed": "✅ Pobieranie zakończone!",
|
||||
"video_completed": "✅ Pobieranie wideo zakończone!",
|
||||
"audio_completed": "✅ Pobieranie dźwięku zakończone!",
|
||||
"subtitle_completed": "✅ Pobieranie napisów zakończone!",
|
||||
"completed_cleaning": "✅ Pobieranie zakończone! Czyszczenie...",
|
||||
"cancelled": "Pobieranie anulowane",
|
||||
"processing_playlist": "📋 Przetwarzanie danych playlisty...",
|
||||
"paused": "Pobieranie wstrzymane",
|
||||
"resumed": "Pobieranie wznowione",
|
||||
"merging_formats": "✨ Przetwarzanie końcowe: Łączenie formatów...",
|
||||
"removing_sponsor_segments": "✨ Przetwarzanie końcowe: Usuwanie segmentów sponsorowanych...",
|
||||
"speed": "Prędkość",
|
||||
"eta": "Pozostały czas",
|
||||
"please_enter_url": "Proszę najpierw wprowadzić URL.",
|
||||
"please_set_path": "Proszę najpierw ustawić ścieżkę pobierania.",
|
||||
"please_enter_url_and_path": "Proszę wprowadzić URL i ustawić ścieżkę pobierania.",
|
||||
"please_select_format": "Proszę najpierw wybrać format.",
|
||||
"downloading_fallback": "⚡ Pobieranie..."
|
||||
},
|
||||
"update": {
|
||||
"title": "Zaktualizuj yt-dlp",
|
||||
"checking": "Sprawdzanie aktualizacji...",
|
||||
"update_available": "Dostępna aktualizacja!\nAktualna wersja: {current}\nNajnowsza wersja: {latest}",
|
||||
"up_to_date": "yt-dlp jest aktualny (Wersja {version})",
|
||||
"could_not_determine": "Nie można określić wersji.",
|
||||
"error_comparing": "Błąd porównywania wersji: {error}",
|
||||
"update_available_failed": "Dostępna aktualizacja! (Porównanie nie powiodło się)\nAktualna: {current}\nNajnowsza: {latest}",
|
||||
"updating": "Aktualizowanie...",
|
||||
"initializing": "🚀 Inicjalizacja procesu aktualizacji...",
|
||||
"checking_current": "🔍 Sprawdzanie aktualnej instalacji...",
|
||||
"found_at": "📍 yt-dlp znaleziony w: {path}",
|
||||
"error_getting_path": "❌ Błąd pobierania ścieżki yt-dlp: {error}",
|
||||
"updating_binary": "📦 Aktualizowanie binarki yt-dlp zarządzanej przez aplikację...",
|
||||
"update_failed": "❌ Aktualizacja yt-dlp nie powiodła się. Spróbuj ponownie lub sprawdź połączenie internetowe.",
|
||||
"binary_updated": "✅ Binarka została pomyślnie zaktualizowana!",
|
||||
"update_failed_stderr": "❌ Aktualizacja yt-dlp nie powiodła się: {error}",
|
||||
"update_timeout": "❌ Limit czasu aktualizacji yt-dlp.",
|
||||
"unexpected_error": "❌ Nieoczekiwany błąd podczas aktualizacji: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp jest już aktualny!",
|
||||
"update_success": "✅ yt-dlp został pomyślnie zaktualizowany!",
|
||||
"already_latest": "yt-dlp jest aktualny (wersja {version})",
|
||||
"network_error": "❌ Błąd sieci podczas aktualizacji: {error}",
|
||||
"general_error": "❌ Aktualizacja nie powiodła się: {error}",
|
||||
"update_in_progress_title": "Trwa aktualizacja",
|
||||
"update_in_progress_message": "Trwa aktualizacja yt-dlp. Proszę chwilę zaczekać."
|
||||
},
|
||||
"about": {
|
||||
"title": "O YTSage",
|
||||
"version": "Wersja {version}",
|
||||
"description": "Nowoczesny pobieracz YouTube z czystym interfejsem PySide6.",
|
||||
"author": "Autor: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Informacje systemowe",
|
||||
"loading": "🔄 Ładowanie informacji systemowych...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Logi",
|
||||
"logs_tooltip": "Otwórz folder logów aplikacji",
|
||||
"refreshing": "🔄 Odświeżanie...",
|
||||
"refresh_failed": "Odświeżanie nie powiodło się",
|
||||
"refresh_failed_message": "Nie można odświeżyć informacji o wersji.",
|
||||
"detected": "✓ Wykryto",
|
||||
"missing": "✗ Brakuje",
|
||||
"not_available": "Niedostępne"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Przytnij wideo",
|
||||
"time_range_group": "Zakres czasu",
|
||||
"start_time": "Czas rozpoczęcia (GG:MM:SS)",
|
||||
"end_time": "Czas zakończenia (GG:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Czas rozpoczęcia (GG:MM:SS)",
|
||||
"end_time_placeholder": "Czas zakończenia (GG:MM:SS)",
|
||||
"force_keyframes": "Wymuś klatki kluczowe w punktach cięcia",
|
||||
"help_text": "Ustaw czasy rozpoczęcia i zakończenia, aby przyciąć wideo.\nPozostaw puste, aby pobrać pełne wideo.",
|
||||
"invalid_format": "Nieprawidłowy format czasu. Użyj formatu GG:MM:SS.",
|
||||
"start_after_end": "Czas rozpoczęcia nie może być po czasie zakończenia."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia pobierania",
|
||||
"download_path": "Ścieżka pobierania",
|
||||
"browse": "Przeglądaj...",
|
||||
"speed_limit": "Limit prędkości",
|
||||
"speed_limit_placeholder": "Brak",
|
||||
"generic_mode": "Tryb ogólny",
|
||||
"enable_generic_mode": "Włącz tryb ogólny (obsługa stron innych niż YouTube)",
|
||||
"generic_mode_help": "Umożliwia pobieranie z Dailymotion, CBC Gem i innych stron obsługiwanych przez yt-dlp.",
|
||||
"auto_update_ytdlp": "Automatyczne aktualizacje yt-dlp",
|
||||
"enable_auto_updates": "Włącz automatyczne aktualizacje yt-dlp",
|
||||
"update_frequency": "Częstotliwość aktualizacji:",
|
||||
"check_startup": "Sprawdzaj przy każdym uruchomieniu (minimum 1 godzina między sprawdzeniami)",
|
||||
"check_daily": "Sprawdzaj codziennie",
|
||||
"check_weekly": "Sprawdzaj co tydzień",
|
||||
"check_updates_now": "Zaktualizuj yt-dlp",
|
||||
"update_check_title": "Sprawdzanie aktualizacji",
|
||||
"could_not_determine_version": "Nie można określić aktualnej wersji yt-dlp.",
|
||||
"update_available_dialog": "Dostępna aktualizacja!\n\nAktualna: {current}\nNajnowsza: {latest}\n\nKliknij OK, aby kontynuować aktualizację.",
|
||||
"up_to_date_dialog": "yt-dlp jest aktualny!\n\nAktualna wersja: {version}",
|
||||
"error_checking_updates": "Błąd sprawdzania aktualizacji: {error}",
|
||||
"settings_saved_title": "Ustawienia zapisane",
|
||||
"settings_saved_message": "Ustawienia automatycznych aktualizacji zostały pomyślnie zapisane!",
|
||||
"error_title": "Błąd",
|
||||
"failed_save_settings": "Nie udało się zapisać ustawień automatycznych aktualizacji.",
|
||||
"error_saving_settings": "Błąd zapisywania ustawień automatycznych aktualizacji: {error}",
|
||||
"ytdlp_channel": "Kanał wydań yt-dlp",
|
||||
"ytdlp_channel_stable": "Stabilny (Przetestowane wydania)",
|
||||
"ytdlp_channel_nightly": "Nightly (Codzienne aktualizacje, zalecane przez yt-dlp)",
|
||||
"ytdlp_channel_description": "Wybierz między wydaniami stabilnymi a nightly. Buildy nightly są aktualizowane codziennie z najnowszymi poprawkami i funkcjami. Możesz zmienić kanał w dowolnym momencie.",
|
||||
"ytdlp_switching_channel": "Przełączanie na kanał {channel}...",
|
||||
"ytdlp_channel_switched": "✅ Pomyślnie przełączono na kanał {channel}!",
|
||||
"ytdlp_channel_switch_failed": "❌ Nie udało się przełączyć kanału: {error}",
|
||||
"ytdlp_current_channel": "Aktualny kanał: {channel}",
|
||||
"app_updates_title": "Aktualizacje YTSage",
|
||||
"check_app_updates": "Sprawdzaj aktualizacje YTSage przy uruchomieniu",
|
||||
"check_beta_updates": "Otrzymuj aktualizacje beta",
|
||||
"auto_update_title": "Ustawienia automatycznych aktualizacji",
|
||||
"auto_update_header": "🔄 Ustawienia automatycznych aktualizacji",
|
||||
"auto_update_description": "Skonfiguruj automatyczne aktualizacje dla yt-dlp, aby zapewnić najnowsze funkcje i poprawki błędów.",
|
||||
"current_status": "Aktualny status",
|
||||
"current_version_label": "Aktualna wersja yt-dlp: Sprawdzanie...",
|
||||
"last_check_label": "Ostatnie sprawdzenie aktualizacji: Nigdy",
|
||||
"next_check_label": "Następne sprawdzenie: Na podstawie ustawień",
|
||||
"manual_check_button": "🔍 Sprawdź aktualizacje teraz",
|
||||
"update_frequency_group": "Częstotliwość aktualizacji",
|
||||
"save_settings": "Zapisz ustawienia",
|
||||
"settings_saved_successfully": "✅ Ustawienia zapisane pomyślnie!",
|
||||
"error_saving": "❌ Błąd zapisywania ustawień: {error}",
|
||||
"output_format_settings": "Ustawienia formatu wyjściowego",
|
||||
"force_output_format": "Wymuś format wyjściowy podczas łączenia",
|
||||
"preferred_format": "Preferowany format:",
|
||||
"force_format_help": "Po włączeniu połączone filmy zostaną przekonwertowane na wybrany format. Wyłącz, aby pozwolić yt-dlp zdecydować automatycznie.",
|
||||
"format_mp4": "MP4 (Najbardziej kompatybilny)",
|
||||
"format_webm": "WebM (Nowoczesny, otwarty)",
|
||||
"format_mkv": "MKV (Bogaty w funkcje)",
|
||||
"audio_format_settings": "Ustawienia formatu audio",
|
||||
"force_audio_format": "Wymuś format audio dla pobierania tylko audio",
|
||||
"audio_normalization": "Normalizacja dźwięku (EBU R128)",
|
||||
"audio_normalization_help": "Po włączeniu ścieżki dźwiękowe zostaną znormalizowane. Uwaga: Wymaga to ponownego kodowania, więc należy wymusić określony format dźwięku (taki jak MP3 lub M4A).",
|
||||
"preferred_audio_format": "Preferowany format audio:",
|
||||
"force_audio_format_help": "Gdy włączone, pobieranie tylko audio zostanie przekonwertowane na preferowany format. Dotyczy to tylko pobierania formatów audio.",
|
||||
"audio_format_best": "Najlepsze (Bez konwersji)",
|
||||
"audio_format_aac": "AAC (Dobre do edycji)",
|
||||
"audio_format_mp3": "MP3 (Uniwersalny)",
|
||||
"audio_format_flac": "FLAC (Bezstratny)",
|
||||
"audio_format_wav": "WAV (Nieskompresowany)",
|
||||
"audio_format_opus": "Opus (Wydajny)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Otwarty)",
|
||||
"filename_format": "Format nazwy pliku",
|
||||
"filename_format_help": "Dostępne zmienne: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. Obsługiwana jest standardowa składnia szablonu wyjściowego yt-dlp.",
|
||||
"tab_general": "Ogólne",
|
||||
"tab_format": "Format",
|
||||
"tab_file": "Plik",
|
||||
"concurrent_fragments": "Jednoczesne połączenia",
|
||||
"concurrent_fragments_help": "Liczba połączeń na pobieranie. Wyższe wartości omijają dławienie, ale mogą powodować tymczasowe blokady, jeśli zostaną ustawione zbyt wysoko. Domyślnie: 1.",
|
||||
"defaults_settings": "Domyślne ustawienia wyboru",
|
||||
"default_video_quality": "Domyślna rozdzielczość wideo (wysokość):",
|
||||
"default_subtitle_language": "Domyślny język(i) napisów:",
|
||||
"defaults_help": "Ustaw preferowaną wysokość wideo i języki napisów (rozdzielone przecinkami). Zostaną one automatycznie wybrane, jeśli będą dostępne."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Wprowadź URL wideo YouTube lub playlisty",
|
||||
"url_placeholder_generic": "Wprowadź URL filmu lub playlisty z dowolnej obsługiwanej strony",
|
||||
"merge_subtitles": "Połącz napisy",
|
||||
"save_thumbnail": "Zapisz miniaturę",
|
||||
"save_description": "Zapisz opis",
|
||||
"embed_chapters": "Osadź rozdziały",
|
||||
"subtitles_selected": "{count} wybrano",
|
||||
"all_selected": "Wszystkie wybrane",
|
||||
"select_videos_all": "Wybierz wideo... (Wszystkie wybrane)",
|
||||
"please_enter_url": "Proszę najpierw wprowadzić URL",
|
||||
"error_title": "Błąd",
|
||||
"cookie_file_selected_title": "Wybrano plik ciasteczek",
|
||||
"cookie_file_selected_message": "Wybrano plik ciasteczek: {path}",
|
||||
"browser_cookies_selected_title": "Wybrano ciasteczka przeglądarki",
|
||||
"browser_cookies_selected_message": "Ciasteczka przeglądarki zostaną wyodrębnione z: {browser}",
|
||||
"error_no_format_info": "Błąd: Brak dostępnych informacji o formacie.",
|
||||
"error_extract_info": "Błąd: Nie można wyodrębnić podstawowych informacji o wideo. Sprawdź swój link.",
|
||||
"analyzing_preparing": "Przygotowywanie żądania...",
|
||||
"analyzing_extracting_basic": "Wyodrębnianie podstawowych informacji...",
|
||||
"analyzing_extracting_detailed": "Wyodrębnianie szczegółowych informacji...",
|
||||
"analyzing_processing_video": "Przetwarzanie danych wideo...",
|
||||
"analyzing_processing_formats": "Przetwarzanie formatów...",
|
||||
"analyzing_loading_thumbnail": "Ładowanie miniatury...",
|
||||
"analyzing_processing_subtitles": "Przetwarzanie napisów...",
|
||||
"analyzing_updating_table": "Aktualizowanie tabeli formatów...",
|
||||
"analysis_complete": "Analiza zakończona!",
|
||||
"analyzing_fetching_first_video": "Pobieranie formatów dla pierwszego filmu...",
|
||||
"analyzing_extracting_ytdlp": "Wyodrębnianie informacji...",
|
||||
"analyzing_processing_data": "Przetwarzanie danych...",
|
||||
"analyzing_processing_formats_ytdlp": "Przetwarzanie formatów...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Ładowanie miniatury...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Przetwarzanie napisów...",
|
||||
"select_subtitles": "Wybierz napisy...",
|
||||
"sponsorblock_categories": "Kategorie SponsorBlock...",
|
||||
"invalid_url_or_enter": "Nieprawidłowy URL lub wprowadź URL.",
|
||||
"zero_selected": "0 wybranych",
|
||||
"analyze_first_tooltip": "Najpierw przeanalizuj wideo",
|
||||
"audio_mode_disabled": "Niedostępne w trybie tylko audio",
|
||||
"select_subtitles_first": "Najpierw wybierz napisy",
|
||||
"settings_tooltip": "Bieżąca ścieżka: {path}\nLimit prędkości: {speed_limit}",
|
||||
"speed_limit_none": "Brak",
|
||||
"open_folder_error": "Nie można otworzyć folderu: {error}",
|
||||
"time_range_set": "Ustawiono sekcję: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Sponsor",
|
||||
"sponsor_desc": "Płatne reklamy, płatne rekomendacje i bezpośrednie reklamy",
|
||||
"selfpromo": "Nieobłożona/Samopromocja",
|
||||
"selfpromo_desc": "Nieobłożona promocja własnych treści twórcy",
|
||||
"interaction": "Przypomnienie o interakcji",
|
||||
"interaction_desc": "Widzowie są proszeni o polubienie, subskrypcję lub śledzenie w mediach społecznościowych",
|
||||
"intro": "Intro",
|
||||
"intro_desc": "Wprowadzenie do wideo, które można pominąć",
|
||||
"outro": "Outro/Karty końcowe",
|
||||
"outro_desc": "Napisy końcowe lub gdy wideo się kończy",
|
||||
"preview": "Podgląd/Podsumowanie",
|
||||
"preview_desc": "Krótkie podsumowanie poprzednich wideo lub podgląd nadchodzącej treści",
|
||||
"music_offtopic": "Sekcja niemuzyczna",
|
||||
"music_offtopic_desc": "Tylko dla wideo muzycznych. Oznacza sekcje niemuzyczne",
|
||||
"filler": "Wypełniacz poboczny",
|
||||
"filler_desc": "Sceny poboczne dodane tylko jako wypełniacz lub dla humoru"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Kanał",
|
||||
"views": "👁 Wyświetlenia",
|
||||
"likes": "👍 Polubienia",
|
||||
"upload_date": "📅 Data przesłania",
|
||||
"duration": "⏱ Czas trwania",
|
||||
"unknown_channel": "Nieznany kanał",
|
||||
"unknown_date": "Nieznana data",
|
||||
"unknown_title": "Nieznany tytuł"
|
||||
},
|
||||
"command": {
|
||||
"running": "Uruchamianie...",
|
||||
"run_command": "Uruchom polecenie"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 wybrano",
|
||||
"one_selected": "1 kategoria wybrana",
|
||||
"count_selected": "{count} wybrano"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Gotowy",
|
||||
"file_exists": "⚠️ Plik już istnieje",
|
||||
"video_file_exists": "⚠️ Plik wideo już istnieje",
|
||||
"audio_file_exists": "⚠️ Plik audio już istnieje",
|
||||
"subtitle_file_exists": "⚠️ Plik napisów już istnieje",
|
||||
"cancelling": "Anulowanie pobierania...",
|
||||
"thumbnail_saved": "✅ Miniatura zapisana: {filename}",
|
||||
"thumbnail_error": "❌ Błąd miniatury: {error}",
|
||||
"thumbnail_no_image": "Brak miniatury do zapisania"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Błąd: Playlista nie zawiera prawidłowych wideo.",
|
||||
"playlist_no_url": "Błąd: Nie można pobrać URL pierwszego wideo z playlisty.",
|
||||
"ytdlp_not_found": "Błąd: Plik wykonywalny yt-dlp nie został znaleziony. Proszę najpierw zainstalować yt-dlp.",
|
||||
"ytdlp_not_found_path": "Błąd: Plik wykonywalny yt-dlp nie został znaleziony. Może to być spowodowane niepoprawną instalacją lub problemem z PATH.",
|
||||
"no_data_returned": "Błąd: Brak danych zwróconych z yt-dlp",
|
||||
"no_format_info": "Błąd: Brak dostępnych informacji o formacie.",
|
||||
"analysis_timeout": "Błąd: Limit czasu analizy. Spróbuj ponownie.",
|
||||
"invalid_speed_limit": "❌ Błąd: Nieprawidłowa wartość limitu prędkości ustawiona w ustawieniach.",
|
||||
"ytdlp_failed": "Błąd: yt-dlp nie powiodło się: {error}",
|
||||
"parse_failed": "Błąd: Nie udało się przeanalizować wyjścia yt-dlp: {error}",
|
||||
"analysis_failed": "Błąd: Analiza nie powiodła się: {error}",
|
||||
"generic_error": "Błąd: {error}",
|
||||
"download_failed_return_code_conflict": "Pobieranie nie powiodło się z kodem {return_code}. Może to wynikać z konfliktu wielu instalacji yt-dlp. Spróbuj odinstalować systemowo zainstalowane yt-dlp (np. przez snap lub apt) i uruchom aplikację ponownie.",
|
||||
"download_failed_return_code": "Pobieranie nie powiodło się z kodem {return_code}",
|
||||
"direct_command_error": "Błąd w bezpośrednim poleceniu: {error}",
|
||||
"private_video": "Ten film może być prywatny. Proszę użyć plików cookie z opcji niestandardowych."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Dostępna aktualizacja",
|
||||
"new_version_available": "Dostępna jest nowa wersja YTSage!",
|
||||
"current_version_label": "Aktualna wersja:",
|
||||
"latest_version_label": "Najnowsza wersja:",
|
||||
"changelog": "Lista zmian",
|
||||
"download_update": "Pobierz aktualizację",
|
||||
"remind_later": "Przypomnij później"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Nieznana playlista",
|
||||
"total_videos": "Wszystkich wideo: {count}",
|
||||
"display_format": "Playlista: {title} | {count} wideo",
|
||||
"select_videos_title": "Wybierz Filmy z Playlisty",
|
||||
"save_as": "Zapisz playlistę jako",
|
||||
"save_success_title": "Sukces",
|
||||
"saved_successfully": "Playlista zapisana pomyślnie.",
|
||||
"save_error_title": "Błąd zapisu",
|
||||
"no_videos_to_save": "Nie zebrano żadnych wpisów z playlisty!",
|
||||
"save_error_msg": "Nie udało się zapisać playlisty."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} wybrano"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "Plik już istnieje",
|
||||
"message": "Plik już istnieje:\n{filename}",
|
||||
"info": "Ten film został już pobrany."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Ostatnie sprawdzenie: Nigdy",
|
||||
"last_check": "Ostatnie sprawdzenie: {time}",
|
||||
"next_check_disabled": "Następne sprawdzenie: Wyłączone",
|
||||
"next_check_startup": "Następne sprawdzenie: Przy starcie",
|
||||
"next_check_overdue": "Następne sprawdzenie: Teraz (zaległe)",
|
||||
"next_check": "Następne sprawdzenie: {time}",
|
||||
"next_check_error": "Następne sprawdzenie: Błąd obliczania",
|
||||
"checking": "🔄 Sprawdzanie...",
|
||||
"check_now": "🔍 Sprawdź aktualizacje teraz",
|
||||
"current_version": "Bieżąca wersja yt-dlp: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL nie może być pusty",
|
||||
"invalid_format": "Nieprawidłowy format URL",
|
||||
"invalid_scheme": "URL musi zaczynać się od http:// lub https://",
|
||||
"missing_domain": "Nieprawidłowy URL: brak nazwy domeny",
|
||||
"unsupported_platform": "YTSage obsługuje tylko adresy URL YouTube i YouTube Music.\nDomena '{domain}' nie jest obsługiwana.",
|
||||
"invalid_youtu_be": "Nieprawidłowy URL youtu.be: brak ID wideo"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "To jest prywatny film. Możesz go pobrać, logując się na swoje konto za pomocą plików cookie.\nPrzejdź do 'Opcje Niestandardowe' → 'Zaloguj się z plikami cookie' → 'Wyodrębnij pliki cookie z przeglądarki', aby się uwierzytelnić.",
|
||||
"age_restricted": "Ten film ma ograniczenie wiekowe. Musisz się zalogować, aby uzyskać do niego dostęp.\nUżyj 'Opcje Niestandardowe' → 'Zaloguj się z plikami cookie', aby uwierzytelnić się swoim kontem.",
|
||||
"geo_blocked": "Ten film nie jest dostępny w Twoim regionie (geoblokada).\nMożesz potrzebować VPN lub film może być ograniczony w Twoim kraju.",
|
||||
"video_unavailable": "Ten film został usunięty lub nie jest już dostępny.\nFilm mógł zostać usunięty przez osobę przesyłającą lub usunięty z powodu naruszenia zasad.",
|
||||
"live_stream": "To jest transmisja na żywo, której nie można pobrać, gdy jest aktywna.\nPoczekaj, aż transmisja się zakończy, a następnie spróbuj pobrać zarchiwizowaną wersję.",
|
||||
"playlist_error": "Nie można uzyskać dostępu do tej playlisty. Może być prywatna, usunięta lub pusta.\nSprawdź, czy playlista istnieje i jest publicznie dostępna.",
|
||||
"network_error": "Błąd połączenia sieciowego. Sprawdź swoje połączenie internetowe i spróbuj ponownie.\nJeśli problem będzie się utrzymywał, serwer wideo może być tymczasowo niedostępny.",
|
||||
"invalid_url": "Nieprawidłowy lub nieobsługiwany URL. Sprawdź link i spróbuj ponownie.\nUpewnij się, że używasz prawidłowego adresu URL YouTube, Vimeo lub innej obsługiwanej platformy.",
|
||||
"premium_content": "Ta treść wymaga YouTube Premium lub członkostwa w kanale.\nMusisz zalogować się na konto, które ma dostęp do tej treści.",
|
||||
"copyright_blocked": "Ten film jest zablokowany z powodu roszczeń dotyczących praw autorskich.\nWłaściciel treści ograniczył dostęp do tego filmu.",
|
||||
"extraction_failed": "Nie udało się wyodrębnić informacji o filmie. To może być tymczasowy problem.\nSpróbuj ponownie za kilka minut lub sprawdź, czy link do filmu jest poprawny.",
|
||||
"generic_error": "Nie udało się wyodrębnić informacji o filmie. Sprawdź swój link.\nSzczegóły techniczne: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Historia Pobierania",
|
||||
"clear_all": "Wyczyść Wszystko",
|
||||
"clear_confirm_title": "Wyczyścić Historię?",
|
||||
"clear_confirm_message": "Czy jesteś pewien? Nie można tego cofnąć.",
|
||||
"no_history": "Brak historii",
|
||||
"no_history_description": "Twoje pobrane pliki pojawią się tutaj",
|
||||
"loading": "Ładowanie historii...",
|
||||
"search_placeholder": "Szukaj...",
|
||||
"open_location": "Otwórz Lokalizację",
|
||||
"redownload": "Pobierz Ponownie",
|
||||
"remove": "Usuń",
|
||||
"file_not_found": "Nie znaleziono pliku",
|
||||
"file_not_found_message": "Plik został przeniesiony lub usunięty:\n{path}",
|
||||
"downloaded_on": "Pobrano: {date}",
|
||||
"file_size": "Rozmiar: {size}",
|
||||
"audio_download": "Audio",
|
||||
"video_download": "Wideo",
|
||||
"remove_confirm_title": "Usunąć?",
|
||||
"remove_confirm_message": "Usunąć z historii?\n\n{title}",
|
||||
"item_removed": "Usunięto",
|
||||
"history_cleared": "Historia wyczyszczona",
|
||||
"entries_count": "{count} pobrań",
|
||||
"one_entry": "1 pobranie",
|
||||
"redownload_confirm_title": "Pobrać Ponownie?",
|
||||
"redownload_confirm_message": "Pobrać ponownie?\n\n{title}",
|
||||
"redownload_started": "Rozpoczęto pobieranie",
|
||||
"no_url_error": "Nie znaleziono URL w wpisie historii",
|
||||
"redownload_failed": "Nie udało się rozpocząć ponownego pobierania: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "Instalacja FFmpeg",
|
||||
"installation_message": "YTSage wymaga FFmpeg do przetwarzania wideo.\n\nWybierz opcję instalacji poniżej:",
|
||||
"install_button": "Zainstaluj FFmpeg",
|
||||
"manual_guide": "Instrukcja ręczna",
|
||||
"installation_failed": "Instalacja FFmpeg napotkała problem.",
|
||||
"already_installed": "FFmpeg jest już zainstalowany!",
|
||||
"installation_complete": "Instalacja zakończona. Możesz zamknąć to okno i kontynuować używanie YTSage.",
|
||||
"installing": "Instalowanie FFmpeg... Proszę czekać",
|
||||
"install_success": "FFmpeg został pomyślnie zainstalowany!",
|
||||
"installation_complete_close": "Instalacja zakończona. Możesz teraz zamknąć to okno i kontynuować używanie YTSage.",
|
||||
"try_manual": "Spróbuj użyć instrukcji instalacji ręcznej."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "Wymagana konfiguracja yt-dlp",
|
||||
"description": "YTSage wymaga yt-dlp do pobierania wideo.<br><br>Nie znaleziono yt-dlp w lokalnym katalogu aplikacji. YTSage musi skonfigurować yt-dlp dla systemu {os_name}.<br><br>Wybierz opcję poniżej:",
|
||||
"option_auto": "Pobierz automatycznie (zalecane)",
|
||||
"option_manual": "Wybierz ścieżkę ręcznie",
|
||||
"setup_button": "Skonfiguruj yt-dlp",
|
||||
"downloading": "Pobieranie yt-dlp...",
|
||||
"success": "yt-dlp został pomyślnie zainstalowany!",
|
||||
"error": "Błąd: {error}",
|
||||
"download_failed_title": "Pobieranie nieudane",
|
||||
"download_failed_message": "Nie udało się pobrać yt-dlp: {error}",
|
||||
"select_executable_title": "Wybierz plik wykonywalny yt-dlp",
|
||||
"copied_to": "yt-dlp skopiowano pomyślnie do {path}",
|
||||
"setup_error_title": "Błąd konfiguracji",
|
||||
"copy_error": "Błąd podczas kopiowania yt-dlp do katalogu aplikacji: {error}",
|
||||
"invalid_executable_title": "Nieprawidłowy plik wykonywalny",
|
||||
"invalid_executable_message": "Wybrany plik nie wygląda na prawidłowy plik wykonywalny yt-dlp.",
|
||||
"verify_error": "Błąd podczas weryfikacji pliku yt-dlp: {error}",
|
||||
"setup_failed_title": "Konfiguracja nieudana",
|
||||
"setup_failed_message": "Nie udało się skonfigurować yt-dlp. Niektóre funkcje mogą działać nieprawidłowo.",
|
||||
"success_dialog_title": "Konfiguracja yt-dlp",
|
||||
"success_dialog_message": "yt-dlp został pomyślnie skonfigurowany w:\n{path}",
|
||||
"file_filter_windows": "Pliki wykonywalne (*.exe)",
|
||||
"file_filter_all": "Wszystkie pliki (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "Sprawdzanie Wersji FFmpeg",
|
||||
"current_version": "Obecna wersja:",
|
||||
"latest_version": "Najnowsza wersja:",
|
||||
"status_up_to_date": "✓ Aktualne",
|
||||
"status_update_available": "⚠ Dostępna aktualizacja",
|
||||
"status_not_installed": "✗ Nie zainstalowano",
|
||||
"status_idle": "Kliknij 'Sprawdź Wersję', aby rozpocząć",
|
||||
"status_checking": "🔄 Sprawdzanie wersji...",
|
||||
"check_updates": "Sprawdź Wersję",
|
||||
"check_failed": "Nie udało się sprawdzić wersji. Sprawdź połączenie internetowe.",
|
||||
"description": "Sprawdź swoją wersję FFmpeg i porównaj ją z najnowszą dostępną wersją.",
|
||||
"guide_info": " Aby zainstalować lub zaktualizować FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>kliknij tutaj, aby wyświetlić nasz kompleksowy przewodnik instalacji</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Wymagana konfiguracja Deno",
|
||||
"setup_description": "YTSage wymaga Deno do uruchomienia niektórych funkcji.<br><br>Deno nie zostało znalezione w lokalnym katalogu aplikacji. YTSage musi skonfigurować Deno dla Twojego systemu {os_name}.<br><br>Kliknij 'Skonfiguruj Deno', aby pobrać i zainstalować automatycznie.",
|
||||
"setup_button": "Skonfiguruj Deno",
|
||||
"downloading": "Pobieranie Deno...",
|
||||
"extracting": "Wyodrębnianie Deno...",
|
||||
"verifying": "Weryfikacja Deno...",
|
||||
"success": "Deno zostało pomyślnie zainstalowane!",
|
||||
"download_failed": "Pobieranie nie powiodło się",
|
||||
"download_error": "Nie udało się pobrać Deno: {error}",
|
||||
"verification_failed": "Weryfikacja SHA256 nie powiodła się. Pobrany plik może być uszkodzony lub zmodyfikowany.",
|
||||
"setup_failed": "Nie udało się skonfigurować Deno. Niektóre funkcje mogą nie działać poprawnie.",
|
||||
"setup_error": "Błąd konfiguracji"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Sprawdzanie i Aktualizacja Wersji Deno",
|
||||
"description": "Sprawdź swoją wersję Deno i zaktualizuj do najnowszej wersji.",
|
||||
"current_version": "Aktualna Wersja:",
|
||||
"latest_version": "Najnowsza Wersja:",
|
||||
"status_idle": "Kliknij 'Sprawdź Aktualizacje', aby zacząć",
|
||||
"status_checking": "🔄 Sprawdzanie aktualizacji...",
|
||||
"status_up_to_date": "✓ Zaktualizowane",
|
||||
"status_update_available": "⚠ Aktualizacja dostępna",
|
||||
"status_not_installed": "✗ Nie zainstalowano",
|
||||
"check_updates": "Sprawdź Aktualizacje",
|
||||
"update_now": "Zaktualizuj Deno",
|
||||
"updating": "🔄 Aktualizowanie Deno...",
|
||||
"update_success": "✅ Deno zostało pomyślnie zaktualizowane!",
|
||||
"update_failed": "❌ Aktualizacja nie powiodła się: {error}",
|
||||
"check_failed": "Nie udało się sprawdzić aktualizacji. Sprawdź połączenie internetowe."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Português (Portuguese)",
|
||||
"select_language": "Selecionar idioma:",
|
||||
"current_language": "Idioma atual: {language}",
|
||||
"restart_required": "As alterações de idioma terão efeito após reiniciar a aplicação.",
|
||||
"english": "Inglês",
|
||||
"spanish": "Espanhol",
|
||||
"portuguese": "Português",
|
||||
"russian": "Русский (Russian)",
|
||||
"chinese": "中文 (简体) (Chinese Simplified)",
|
||||
"german": "Alemão",
|
||||
"french": "Francês",
|
||||
"hindi": "Hindi",
|
||||
"indonesian": "Indonésio",
|
||||
"turkish": "Turco",
|
||||
"polish": "Polonês",
|
||||
"italian": "Italiano",
|
||||
"arabic": "Árabe",
|
||||
"japanese": "Japonês",
|
||||
"help_text": "Selecione seu idioma preferido para a interface.",
|
||||
"restart_notice": "A alteração de idioma terá efeito após reiniciar a aplicação."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Pronto"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Mostrar Formatos:",
|
||||
"video_format": "Formato de Vídeo:",
|
||||
"audio_format": "Formato de Áudio:",
|
||||
"no_formats": "Nenhum formato disponível",
|
||||
"loading": "Carregando formatos...",
|
||||
"select": "Selecionar",
|
||||
"quality": "Qualidade",
|
||||
"extension": "Extensão",
|
||||
"resolution": "Resolução",
|
||||
"file_size": "Tamanho do Arquivo",
|
||||
"codec": "Codec",
|
||||
"audio": "Áudio",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Irá mesclar áudio",
|
||||
"has_audio": "✓ Tem Áudio",
|
||||
"audio_only": "Apenas Áudio",
|
||||
"best_4k": "Melhor (4K)",
|
||||
"best_2k": "Melhor (2K)",
|
||||
"high_1080p": "Alta (1080p)",
|
||||
"high_720p": "Alta (720p)",
|
||||
"medium_480p": "Média (480p)",
|
||||
"low_quality": "Baixa Qualidade",
|
||||
"best_audio": "Melhor Áudio",
|
||||
"high_audio": "Áudio Alto",
|
||||
"medium_audio": "Áudio Médio",
|
||||
"low_audio": "Áudio Baixo",
|
||||
"audio_only_resolution": "Apenas áudio"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Redefinir",
|
||||
"download": "Baixar",
|
||||
"pause": "Pausar",
|
||||
"resume": "Retomar",
|
||||
"cancel": "Cancelar",
|
||||
"browse": "Procurar",
|
||||
"clear": "Limpar",
|
||||
"ok": "OK",
|
||||
"apply": "Aplicar",
|
||||
"close": "Fechar",
|
||||
"run_command": "Executar Comando",
|
||||
"custom_command_help": "Ajuda",
|
||||
"about": "Sobre",
|
||||
"analyze": "Analisar",
|
||||
"paste_url": "Colar URL",
|
||||
"select_videos": "Selecionar Vídeos...",
|
||||
"video": "Vídeo",
|
||||
"audio_only": "Apenas Áudio",
|
||||
"custom_options": "Opções Personalizadas",
|
||||
"trim_video": "Cortar Vídeo",
|
||||
"download_settings": "Configurações de Download",
|
||||
"update": "Atualizar yt-dlp",
|
||||
"select_defaults": "Selecionar Padrões",
|
||||
"select_all": "Selecionar Tudo",
|
||||
"deselect_all": "Desmarcar Tudo",
|
||||
"open_folder": "Abrir local da pasta",
|
||||
"history": "Histórico",
|
||||
"save_playlist": "Salvar playlist como"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Opções Personalizadas",
|
||||
"settings": "Configurações",
|
||||
"select_folder": "Selecionar Pasta de Download",
|
||||
"sponsorblock_categories": "Categorias SponsorBlock",
|
||||
"sponsorblock_description": "Selecione quais tipos de segmentos de vídeo serão removidos automaticamente durante o download.\nO SponsorBlock usa dados enviados pela comunidade para identificar esses segmentos.",
|
||||
"select_subtitles": "Selecionar Legendas",
|
||||
"filter_languages_placeholder": "Filtrar idiomas (ex., en, pt)...",
|
||||
"no_subtitles_available": "Nenhuma legenda disponível",
|
||||
"matching": "correspondendo",
|
||||
"ytdlp_log_title": "Log do yt-dlp",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Entrar com Cookies",
|
||||
"custom_command": "Comando Personalizado",
|
||||
"proxy": "Proxy",
|
||||
"language": "Idioma",
|
||||
"updater": "Atualizador"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Escolha como fornecer cookies para fazer login.\nIsso permite baixar vídeos privados e áudio de qualidade premium.",
|
||||
"cookie_source": "Fonte de Cookies",
|
||||
"use_cookie_file": "Usar arquivo de cookies",
|
||||
"extract_from_browser": "Extrair do navegador",
|
||||
"recommended": "Recomendado",
|
||||
"cookie_file": "Arquivo de Cookies",
|
||||
"cookie_file_placeholder": "Caminho para o arquivo cookies.txt...",
|
||||
"browser_selection": "Seleção de Navegador",
|
||||
"browser_help": "Selecione o navegador do qual extrair cookies:",
|
||||
"browser_label": "Navegador:",
|
||||
"profile_label": "Perfil:",
|
||||
"profile_placeholder": "padrão",
|
||||
"browser_extract_message": "Os cookies do navegador serão extraídos quando aplicados",
|
||||
"file_selected_message": "Arquivo de cookies selecionado - Clique em OK para aplicar",
|
||||
"select_file_title": "Selecionar Arquivo de Cookies",
|
||||
"file_filter": "Arquivos de cookies (*.txt *.lwp)",
|
||||
"file_selected_title": "Arquivo de Cookies Aplicado",
|
||||
"file_applied_message": "Arquivo de cookies aplicado: {path}",
|
||||
"browser_selected_title": "Cookies do Navegador Aplicados",
|
||||
"browser_applied_message": "Os cookies do navegador serão extraídos de: {browser}",
|
||||
"cleared_title": "Cookies Limpos",
|
||||
"cleared_message": "As configurações de cookies foram limpas",
|
||||
"active_browser": "✓ Ativos: cookies do navegador ({browser})",
|
||||
"active_file": "✓ Ativo: arquivo de cookies ({file})",
|
||||
"none_active": "○ Nenhum cookie ativo",
|
||||
"remember_settings": "Lembrar configurações de cookies na próxima inicialização"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Digite seu comando yt-dlp personalizado abaixo. A URL atual será anexada automaticamente.<br><br>Para a lista completa de opções e exemplos de uso, <a href=\"{docs_url}\">clique aqui para ver a documentação oficial do yt-dlp</a>.<br><br>Nota: O caminho de download e modelo de nome de arquivo serão tratados automaticamente.",
|
||||
"input_label": "Argumentos yt-dlp:",
|
||||
"input_placeholder": "Digite os argumentos yt-dlp aqui...\n\nex. --extract-audio --audio-format mp3",
|
||||
"output_label": "Saída do Comando:",
|
||||
"output_placeholder": "A saída do comando aparecerá aqui...",
|
||||
"command_placeholder": "Digite comando yt-dlp personalizado...",
|
||||
"command_help": "Marcadores disponíveis:\n{url} - URL do Vídeo\n{output} - Diretório de saída\n\nExemplo: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Comando completo: {command}",
|
||||
"command_success": "✅ Comando personalizado executado com sucesso!",
|
||||
"command_failed": "❌ Comando falhou com código de saída {code}",
|
||||
"command_error": "❌ Erro ao executar comando personalizado: {error}",
|
||||
"error_no_url": "❌ Erro: nenhuma URL fornecida. Insira uma URL na janela principal.",
|
||||
"error_no_command": "❌ Erro: nenhum comando fornecido. Insira os argumentos do yt-dlp.",
|
||||
"executing": "🚀 Executando comando personalizado do yt-dlp",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Argumentos: {command}",
|
||||
"download_path_label": "📁 Caminho de download: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Configure as definições de proxy para download. Deixe vazio para usar conexão direta.",
|
||||
"main_proxy": "Proxy Principal",
|
||||
"main_proxy_help": "Servidor proxy principal para todos os downloads. Suporta protocolos HTTP/HTTPS e SOCKS5.",
|
||||
"proxy_url_label": "URL do Proxy:",
|
||||
"proxy_url_placeholder": "http://proxy:porta ou socks5://proxy:porta",
|
||||
"proxy_examples": "Exemplos:\n• HTTP: http://proxy.exemplo.com:8080\n• SOCKS5: socks5://proxy.exemplo.com:1080",
|
||||
"geo_proxy": "Proxy de Contorno Geográfico",
|
||||
"geo_proxy_help": "Proxy secundário especificamente para contornar restrições geográficas.",
|
||||
"geo_proxy_url_label": "URL do Proxy de Contorno Geográfico:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:porta",
|
||||
"clear_main_proxy": "Limpar Proxy Principal",
|
||||
"clear_geo_proxy": "Limpar Proxy Geográfico",
|
||||
"proxy_url": "URL do Proxy",
|
||||
"proxy_placeholder": "http://proxy:porta ou socks5://proxy:porta",
|
||||
"geo_bypass": "Proxy de contorno geográfico (para restrições geográficas)",
|
||||
"geo_bypass_placeholder": "http://proxy:porta para contornar bloqueios geográficos",
|
||||
"invalid_main_url": "Formato de URL do proxy principal inválido",
|
||||
"invalid_geo_url": "Formato de URL do proxy geográfico inválido",
|
||||
"main_configured": "Proxy principal configurado",
|
||||
"geo_configured": "Proxy geográfico configurado",
|
||||
"set_title": "Proxy definido",
|
||||
"set_message": "Proxy principal definido e salvo: {proxy}",
|
||||
"geo_set_title": "Proxy geo definido",
|
||||
"geo_set_message": "Proxy de verificação geo definido e salvo: {proxy}",
|
||||
"cleared_title": "Configurações de proxy limpas",
|
||||
"cleared_message": "Todas as configurações de proxy foram limpas e salvas.",
|
||||
"saved_main": "Proxy principal salvo: {proxy}",
|
||||
"saved_geo": "Proxy geo salvo: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Preparando download...",
|
||||
"starting": "🚀 Iniciando download...",
|
||||
"fetching_info": "🔍 Buscando informações do vídeo...",
|
||||
"preparing_streams": "🎯 Preparando streams de vídeo...",
|
||||
"downloading_audio": "⏬ Baixando áudio...",
|
||||
"downloading_video": "⏬ Baixando vídeo...",
|
||||
"downloading_subtitle": "⏬ Baixando legendas...",
|
||||
"downloading": "⏬ Baixando...",
|
||||
"completed": "✅ Download concluído!",
|
||||
"video_completed": "✅ Download de vídeo concluído!",
|
||||
"audio_completed": "✅ Download de áudio concluído!",
|
||||
"subtitle_completed": "✅ Download de legendas concluído!",
|
||||
"completed_cleaning": "✅ Download concluído! Limpando...",
|
||||
"cancelled": "Download cancelado",
|
||||
"processing_playlist": "📋 Processando dados da playlist...",
|
||||
"paused": "Download pausado",
|
||||
"resumed": "Download retomado",
|
||||
"merging_formats": "✨ Pós-processamento: Mesclando formatos...",
|
||||
"removing_sponsor_segments": "✨ Pós-processamento: Removendo segmentos patrocinados...",
|
||||
"speed": "Velocidade",
|
||||
"eta": "Tempo restante",
|
||||
"please_enter_url": "Por favor, digite uma URL primeiro.",
|
||||
"please_set_path": "Por favor, defina um caminho de download primeiro.",
|
||||
"please_enter_url_and_path": "Por favor, digite uma URL e defina o caminho de download.",
|
||||
"please_select_format": "Por favor, selecione um formato primeiro.",
|
||||
"downloading_fallback": "⚡ Baixando..."
|
||||
},
|
||||
"update": {
|
||||
"title": "Atualizar yt-dlp",
|
||||
"checking": "Verificando atualizações...",
|
||||
"update_available": "Atualização disponível!\nVersão atual: {current}\nÚltima versão: {latest}",
|
||||
"up_to_date": "yt-dlp está atualizado (versão {version})",
|
||||
"could_not_determine": "Não foi possível determinar as versões.",
|
||||
"error_comparing": "Erro ao comparar versões: {error}",
|
||||
"update_available_failed": "Atualização disponível! (Comparação falhou)\nAtual: {current}\nÚltima: {latest}",
|
||||
"updating": "Atualizando...",
|
||||
"initializing": "🚀 Inicializando processo de atualização...",
|
||||
"checking_current": "🔍 Verificando instalação atual...",
|
||||
"found_at": "📍 yt-dlp encontrado em: {path}",
|
||||
"error_getting_path": "❌ Erro ao obter caminho do yt-dlp: {error}",
|
||||
"updating_binary": "📦 Atualizando binário yt-dlp gerenciado pela aplicação...",
|
||||
"update_failed": "❌ Falha ao atualizar yt-dlp. Tente novamente ou verifique sua conexão com a internet.",
|
||||
"binary_updated": "✅ Binário atualizado com sucesso!",
|
||||
"update_failed_stderr": "❌ Atualização do yt-dlp falhou: {error}",
|
||||
"update_timeout": "❌ Atualização do yt-dlp expirou.",
|
||||
"unexpected_error": "❌ Erro inesperado durante atualização: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp já está atualizado!",
|
||||
"update_success": "✅ yt-dlp foi atualizado com sucesso!",
|
||||
"already_latest": "yt-dlp está atualizado (versão {version})",
|
||||
"network_error": "❌ Erro de rede durante a atualização: {error}",
|
||||
"general_error": "❌ A atualização falhou: {error}",
|
||||
"update_in_progress_title": "Atualização em andamento",
|
||||
"update_in_progress_message": "O yt-dlp está sendo atualizado. Por favor, aguarde um momento."
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre YTSage",
|
||||
"version": "Versão {version}",
|
||||
"description": "Downloader moderno do YouTube com interface limpa em PySide6.",
|
||||
"author": "Por: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Informações do Sistema",
|
||||
"loading": "🔄 Carregando informações do sistema...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Logs",
|
||||
"logs_tooltip": "Abrir pasta de logs da aplicação",
|
||||
"refreshing": "🔄 Atualizando...",
|
||||
"refresh_failed": "Atualização Falhou",
|
||||
"refresh_failed_message": "Não foi possível atualizar as informações de versão.",
|
||||
"detected": "✓ Detectado",
|
||||
"missing": "✗ Ausente",
|
||||
"not_available": "Não Disponível"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Cortar Vídeo",
|
||||
"time_range_group": "Intervalo de Tempo",
|
||||
"start_time": "Tempo de Início (HH:MM:SS)",
|
||||
"end_time": "Tempo de Fim (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Tempo de início (HH:MM:SS)",
|
||||
"end_time_placeholder": "Tempo de fim (HH:MM:SS)",
|
||||
"force_keyframes": "Forçar quadros-chave nos pontos de corte",
|
||||
"help_text": "Defina os tempos de início e fim para cortar o vídeo.\nDeixe vazio para baixar o vídeo completo.",
|
||||
"invalid_format": "Formato de tempo inválido. Use o formato HH:MM:SS.",
|
||||
"start_after_end": "O tempo de início não pode ser após o tempo de fim."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações de Download",
|
||||
"download_path": "Caminho de Download",
|
||||
"browse": "Procurar...",
|
||||
"speed_limit": "Limite de Velocidade",
|
||||
"speed_limit_placeholder": "Nenhum",
|
||||
"generic_mode": "Modo genérico",
|
||||
"enable_generic_mode": "Ativar modo genérico (suporte a sites que não são do YouTube)",
|
||||
"generic_mode_help": "Permite baixar de Dailymotion, CBC Gem e outros sites compatíveis com o yt-dlp.",
|
||||
"auto_update_ytdlp": "Auto-Atualizar yt-dlp",
|
||||
"enable_auto_updates": "Habilitar atualizações automáticas do yt-dlp",
|
||||
"update_frequency": "Frequência de atualização:",
|
||||
"check_startup": "Verificar a cada inicialização (mínimo 1 hora entre verificações)",
|
||||
"check_daily": "Verificar diariamente",
|
||||
"check_weekly": "Verificar semanalmente",
|
||||
"check_updates_now": "Atualizar yt-dlp",
|
||||
"update_check_title": "Verificação de Atualização",
|
||||
"could_not_determine_version": "Não foi possível determinar a versão atual do yt-dlp.",
|
||||
"update_available_dialog": "Atualização disponível!\n\nAtual: {current}\nÚltima: {latest}\n\nClique em OK para continuar com a atualização.",
|
||||
"up_to_date_dialog": "yt-dlp está atualizado!\n\nVersão atual: {version}",
|
||||
"error_checking_updates": "Erro ao verificar atualizações: {error}",
|
||||
"settings_saved_title": "Configurações Salvas",
|
||||
"settings_saved_message": "Configurações de auto-atualização foram salvas com sucesso!",
|
||||
"error_title": "Erro",
|
||||
"failed_save_settings": "Falha ao salvar configurações de auto-atualização.",
|
||||
"error_saving_settings": "Erro ao salvar configurações de auto-atualização: {error}",
|
||||
"ytdlp_channel": "Canal de Lançamento do yt-dlp",
|
||||
"ytdlp_channel_stable": "Estável (Lançamentos testados)",
|
||||
"ytdlp_channel_nightly": "Nightly (Atualizações diárias, recomendado pelo yt-dlp)",
|
||||
"ytdlp_channel_description": "Escolha entre lançamentos estáveis e nightly. Builds nightly são atualizadas diariamente com as últimas correções e recursos. Você pode trocar de canal a qualquer momento.",
|
||||
"ytdlp_switching_channel": "Mudando para o canal {channel}...",
|
||||
"ytdlp_channel_switched": "✅ Mudado com sucesso para o canal {channel}!",
|
||||
"ytdlp_channel_switch_failed": "❌ Falha ao trocar de canal: {error}",
|
||||
"ytdlp_current_channel": "Canal atual: {channel}",
|
||||
"app_updates_title": "Atualizações do YTSage",
|
||||
"check_app_updates": "Verificar atualizações do YTSage na inicialização",
|
||||
"check_beta_updates": "Receber atualizações beta",
|
||||
"auto_update_title": "Configurações de Auto-Atualização",
|
||||
"auto_update_header": "🔄 Configurações de Auto-Atualização",
|
||||
"auto_update_description": "Configure atualizações automáticas para o yt-dlp para garantir que você sempre tenha os recursos mais recentes e correções de bugs.",
|
||||
"current_status": "Status Atual",
|
||||
"current_version_label": "Versão atual do yt-dlp: Verificando...",
|
||||
"last_check_label": "Última verificação de atualização: Nunca",
|
||||
"next_check_label": "Próxima verificação: Baseada nas configurações",
|
||||
"manual_check_button": "🔍 Verificar Atualizações Agora",
|
||||
"update_frequency_group": "Frequência de Atualização",
|
||||
"save_settings": "Salvar Configurações",
|
||||
"settings_saved_successfully": "✅ Configurações salvas com sucesso!",
|
||||
"error_saving": "❌ Erro ao salvar configurações: {error}",
|
||||
"output_format_settings": "Configurações de Formato de Saída",
|
||||
"force_output_format": "Forçar formato de saída ao mesclar",
|
||||
"preferred_format": "Formato preferido:",
|
||||
"force_format_help": "Quando ativado, os vídeos mesclados serão convertidos para o seu formato preferido. Desative para deixar o yt-dlp decidir automaticamente.",
|
||||
"format_mp4": "MP4 (Mais compatível)",
|
||||
"format_webm": "WebM (Moderno, aberto)",
|
||||
"format_mkv": "MKV (Rico em recursos)",
|
||||
"audio_format_settings": "Configurações de formato de áudio",
|
||||
"force_audio_format": "Forçar formato de áudio para downloads somente de áudio",
|
||||
"audio_normalization": "Normalização de Áudio (EBU R128)",
|
||||
"audio_normalization_help": "Quando ativado, as faixas de áudio serão normalizadas. Nota: isso requer recodificação, portanto, um formato de áudio específico (como MP3 ou M4A) deve ser forçado.",
|
||||
"preferred_audio_format": "Formato de áudio preferido:",
|
||||
"force_audio_format_help": "Quando ativado, downloads somente de áudio serão convertidos para seu formato preferido. Isso se aplica apenas ao baixar formatos de áudio.",
|
||||
"audio_format_best": "Melhor (Sem conversão)",
|
||||
"audio_format_aac": "AAC (Bom para edição)",
|
||||
"audio_format_mp3": "MP3 (Universal)",
|
||||
"audio_format_flac": "FLAC (Sem perdas)",
|
||||
"audio_format_wav": "WAV (Não comprimido)",
|
||||
"audio_format_opus": "Opus (Eficiente)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Aberto)",
|
||||
"filename_format": "Formato de nome de arquivo",
|
||||
"filename_format_help": "Variáveis disponíveis: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. A sintaxe padrão de modelo de saída do yt-dlp é suportada.",
|
||||
"tab_general": "Geral",
|
||||
"tab_format": "Formato",
|
||||
"tab_file": "Arquivo",
|
||||
"concurrent_fragments": "Conexões simultâneas",
|
||||
"concurrent_fragments_help": "Número de conexões por download. Valores mais altos contornam o limite de velocidade, mas podem causar bloqueios temporários se forem muito altos. Padrão: 1.",
|
||||
"defaults_settings": "Configurações de seleção padrão",
|
||||
"default_video_quality": "Resolução de vídeo padrão (altura):",
|
||||
"default_subtitle_language": "Idioma(s) de legenda padrão:",
|
||||
"defaults_help": "Defina sua altura de vídeo preferida e os idiomas das legendas (separados por vírgula). Eles serão selecionados automaticamente se disponíveis."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Digite a URL do vídeo ou playlist do YouTube",
|
||||
"url_placeholder_generic": "Digite a URL de um vídeo ou playlist de qualquer site compatível",
|
||||
"merge_subtitles": "Mesclar Legendas",
|
||||
"save_thumbnail": "Salvar Miniatura",
|
||||
"save_description": "Salvar Descrição",
|
||||
"embed_chapters": "Incorporar Capítulos",
|
||||
"subtitles_selected": "{count} selecionadas",
|
||||
"all_selected": "Todas selecionadas",
|
||||
"select_videos_all": "Selecionar Vídeos... (Todas selecionadas)",
|
||||
"please_enter_url": "Por favor, digite uma URL primeiro",
|
||||
"error_title": "Erro",
|
||||
"cookie_file_selected_title": "Arquivo de Cookies Selecionado",
|
||||
"cookie_file_selected_message": "Arquivo de cookies selecionado: {path}",
|
||||
"browser_cookies_selected_title": "Cookies do Navegador Selecionados",
|
||||
"browser_cookies_selected_message": "Os cookies do navegador serão extraídos de: {browser}",
|
||||
"error_no_format_info": "Erro: Nenhuma informação de formato disponível.",
|
||||
"error_extract_info": "Erro: Não foi possível extrair informações básicas do vídeo. Verifique seu link.",
|
||||
"analyzing_preparing": "Preparando solicitação...",
|
||||
"analyzing_extracting_basic": "Extraindo informações básicas...",
|
||||
"analyzing_extracting_detailed": "Extraindo informações detalhadas...",
|
||||
"analyzing_processing_video": "Processando dados do vídeo...",
|
||||
"analyzing_processing_formats": "Processando formatos...",
|
||||
"analyzing_loading_thumbnail": "Carregando miniatura...",
|
||||
"analyzing_processing_subtitles": "Processando legendas...",
|
||||
"analyzing_updating_table": "Atualizando tabela de formatos...",
|
||||
"analysis_complete": "Análise completa!",
|
||||
"analyzing_fetching_first_video": "Obtendo formatos do primeiro vídeo...",
|
||||
"analyzing_extracting_ytdlp": "Extraindo informações...",
|
||||
"analyzing_processing_data": "Processando dados...",
|
||||
"analyzing_processing_formats_ytdlp": "Processando formatos...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Carregando miniatura...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Processando legendas...",
|
||||
"select_subtitles": "Selecionar Legendas...",
|
||||
"sponsorblock_categories": "Categorias SponsorBlock...",
|
||||
"invalid_url_or_enter": "URL inválido ou por favor insira um URL.",
|
||||
"zero_selected": "0 selecionados",
|
||||
"analyze_first_tooltip": "Por favor analise o vídeo primeiro",
|
||||
"audio_mode_disabled": "Não disponível no modo apenas áudio",
|
||||
"select_subtitles_first": "Por favor selecione legendas primeiro",
|
||||
"settings_tooltip": "Caminho atual: {path}\nLimite de velocidade: {speed_limit}",
|
||||
"speed_limit_none": "Nenhum",
|
||||
"open_folder_error": "Não foi possível abrir a pasta: {error}",
|
||||
"time_range_set": "Seção definida: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Patrocinador",
|
||||
"sponsor_desc": "Promoção paga, referências pagas e anúncios diretos",
|
||||
"selfpromo": "Promoção Não Paga/Própria",
|
||||
"selfpromo_desc": "Promoção não paga do próprio conteúdo dos criadores",
|
||||
"interaction": "Lembrete de Interação",
|
||||
"interaction_desc": "Pedindo aos espectadores para curtir, se inscrever ou seguir nas redes sociais",
|
||||
"intro": "Introdução",
|
||||
"intro_desc": "Introdução do vídeo que pode ser pulada",
|
||||
"outro": "Encerramento/Cards Finais",
|
||||
"outro_desc": "Créditos ou quando o vídeo termina",
|
||||
"preview": "Prévia/Recapitulação",
|
||||
"preview_desc": "Recapitulação rápida de vídeos anteriores ou prévia do que está por vir",
|
||||
"music_offtopic": "Seção Não Musical",
|
||||
"music_offtopic_desc": "Apenas para vídeos musicais. Marca seções não musicais",
|
||||
"filler": "Tangente de Preenchimento",
|
||||
"filler_desc": "Cenas tangenciais adicionadas apenas para preenchimento ou humor"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Canal",
|
||||
"views": "👁 Visualizações",
|
||||
"likes": "👍 Curtidas",
|
||||
"upload_date": "📅 Data de envio",
|
||||
"duration": "⏱ Duração",
|
||||
"unknown_channel": "Canal desconhecido",
|
||||
"unknown_date": "Data desconhecida",
|
||||
"unknown_title": "Título desconhecido"
|
||||
},
|
||||
"command": {
|
||||
"running": "Executando...",
|
||||
"run_command": "Executar Comando"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 selecionadas",
|
||||
"one_selected": "1 categoria selecionada",
|
||||
"count_selected": "{count} selecionadas"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Pronto",
|
||||
"file_exists": "⚠️ Arquivo já existe",
|
||||
"video_file_exists": "⚠️ Arquivo de vídeo já existe",
|
||||
"audio_file_exists": "⚠️ Arquivo de áudio já existe",
|
||||
"subtitle_file_exists": "⚠️ Arquivo de legendas já existe",
|
||||
"cancelling": "Cancelando download...",
|
||||
"thumbnail_saved": "✅ Miniatura salva: {filename}",
|
||||
"thumbnail_error": "❌ Erro na miniatura: {error}",
|
||||
"thumbnail_no_image": "Nenhuma miniatura disponível para salvar"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Erro: A playlist não contém vídeos válidos.",
|
||||
"playlist_no_url": "Erro: Não foi possível obter a URL do primeiro vídeo da playlist.",
|
||||
"ytdlp_not_found": "Erro: Executável yt-dlp não encontrado. Por favor, instale o yt-dlp primeiro.",
|
||||
"ytdlp_not_found_path": "Erro: Executável yt-dlp não encontrado. Isso pode ser devido a uma instalação inadequada ou problema de PATH.",
|
||||
"no_data_returned": "Erro: Nenhum dado retornado do yt-dlp",
|
||||
"no_format_info": "Erro: Nenhuma informação de formato disponível.",
|
||||
"analysis_timeout": "Erro: Tempo de análise esgotado. Por favor, tente novamente.",
|
||||
"invalid_speed_limit": "❌ Erro: Valor de limite de velocidade inválido definido nas configurações.",
|
||||
"ytdlp_failed": "Erro: yt-dlp falhou: {error}",
|
||||
"parse_failed": "Erro: Falha ao analisar a saída do yt-dlp: {error}",
|
||||
"analysis_failed": "Erro: Análise falhou: {error}",
|
||||
"generic_error": "Erro: {error}",
|
||||
"download_failed_return_code_conflict": "O download falhou com o código de retorno {return_code}. Isso pode ser devido a um conflito com várias instalações do yt-dlp. Tente desinstalar qualquer yt-dlp instalado no sistema (ex.: snap ou apt) e reinicie o aplicativo.",
|
||||
"download_failed_return_code": "O download falhou com o código de retorno {return_code}",
|
||||
"direct_command_error": "Erro no comando direto: {error}",
|
||||
"private_video": "Este vídeo pode ser privado. Por favor, use cookies das opções personalizadas."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Atualização Disponível",
|
||||
"new_version_available": "Uma nova versão do YTSage está disponível!",
|
||||
"current_version_label": "Versão atual:",
|
||||
"latest_version_label": "Última versão:",
|
||||
"changelog": "Registro de alterações",
|
||||
"download_update": "Baixar Atualização",
|
||||
"remind_later": "Lembrar Mais Tarde"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Playlist Desconhecida",
|
||||
"total_videos": "Total de Vídeos: {count}",
|
||||
"display_format": "Playlist: {title} | {count} vídeos",
|
||||
"select_videos_title": "Selecionar Vídeos da Playlist",
|
||||
"save_as": "Salvar playlist como",
|
||||
"save_success_title": "Sucesso",
|
||||
"saved_successfully": "Playlist salva com sucesso.",
|
||||
"save_error_title": "Erro ao salvar",
|
||||
"no_videos_to_save": "Nenhuma entrada de playlist coletada!",
|
||||
"save_error_msg": "Falha ao salvar a playlist."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} selecionadas"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "Arquivo já existe",
|
||||
"message": "O arquivo já existe:\n{filename}",
|
||||
"info": "Este vídeo já foi baixado."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Última verificação: Nunca",
|
||||
"last_check": "Última verificação: {time}",
|
||||
"next_check_disabled": "Próxima verificação: Desativada",
|
||||
"next_check_startup": "Próxima verificação: Na inicialização",
|
||||
"next_check_overdue": "Próxima verificação: Agora (atrasada)",
|
||||
"next_check": "Próxima verificação: {time}",
|
||||
"next_check_error": "Próxima verificação: Erro ao calcular",
|
||||
"checking": "🔄 Verificando...",
|
||||
"check_now": "🔍 Verificar atualizações agora",
|
||||
"current_version": "Versão atual do yt-dlp: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "O URL não pode estar vazio",
|
||||
"invalid_format": "Formato de URL inválido",
|
||||
"invalid_scheme": "O URL deve começar com http:// ou https://",
|
||||
"missing_domain": "URL inválido: nome de domínio ausente",
|
||||
"unsupported_platform": "YTSage suporta apenas URLs do YouTube e YouTube Music.\nO domínio '{domain}' não é suportado.",
|
||||
"invalid_youtu_be": "URL youtu.be inválido: ID do vídeo ausente"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Este é um vídeo privado. Você pode baixá-lo fazendo login em sua conta usando cookies.\nVá para 'Opções Personalizadas' → 'Login com Cookies' → 'Extrair cookies do navegador' para autenticar.",
|
||||
"age_restricted": "Este vídeo tem restrição de idade. Você precisa estar conectado para acessá-lo.\nUse 'Opções Personalizadas' → 'Login com Cookies' para autenticar com sua conta.",
|
||||
"geo_blocked": "Este vídeo não está disponível em sua região (geo-bloqueado).\nVocê pode precisar usar uma VPN ou o vídeo pode estar restrito em seu país.",
|
||||
"video_unavailable": "Este vídeo foi removido ou não está mais disponível.\nO vídeo pode ter sido excluído pelo uploader ou removido devido a violações de políticas.",
|
||||
"live_stream": "Esta é uma transmissão ao vivo que não pode ser baixada enquanto está ativa.\nAguarde o término da transmissão e tente baixar a versão arquivada.",
|
||||
"playlist_error": "Não é possível acessar esta playlist. Ela pode ser privada, excluída ou vazia.\nVerifique se a playlist existe e está acessível publicamente.",
|
||||
"network_error": "Erro de conexão de rede. Verifique sua conexão com a internet e tente novamente.\nSe o problema persistir, o servidor de vídeo pode estar temporariamente indisponível.",
|
||||
"invalid_url": "URL inválido ou não suportado. Verifique o link e tente novamente.\nCertifique-se de usar um URL válido do YouTube, Vimeo ou outra plataforma suportada.",
|
||||
"premium_content": "Este conteúdo requer YouTube Premium ou associação ao canal.\nVocê precisa fazer login com uma conta que tenha acesso a este conteúdo.",
|
||||
"copyright_blocked": "Este vídeo está bloqueado devido a reivindicações de direitos autorais.\nO proprietário do conteúdo restringiu o acesso a este vídeo.",
|
||||
"extraction_failed": "Falha ao extrair informações do vídeo. Isso pode ser um problema temporário.\nTente novamente em alguns minutos ou verifique se o link do vídeo está correto.",
|
||||
"generic_error": "Não foi possível extrair informações do vídeo. Verifique seu link.\nDetalhes técnicos: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "Histórico de Downloads",
|
||||
"clear_all": "Limpar Tudo",
|
||||
"clear_confirm_title": "Limpar Histórico?",
|
||||
"clear_confirm_message": "Tem certeza? Isto não pode ser desfeito.",
|
||||
"no_history": "Nenhum histórico ainda",
|
||||
"no_history_description": "Seus downloads aparecerão aqui",
|
||||
"loading": "Carregando histórico...",
|
||||
"search_placeholder": "Pesquisar...",
|
||||
"open_location": "Abrir Local",
|
||||
"redownload": "Baixar Novamente",
|
||||
"remove": "Remover",
|
||||
"file_not_found": "Arquivo não encontrado",
|
||||
"file_not_found_message": "O arquivo foi movido ou excluído:\n{path}",
|
||||
"downloaded_on": "Baixado: {date}",
|
||||
"file_size": "Tamanho: {size}",
|
||||
"audio_download": "Áudio",
|
||||
"video_download": "Vídeo",
|
||||
"remove_confirm_title": "Remover?",
|
||||
"remove_confirm_message": "Remover do histórico?\n\n{title}",
|
||||
"item_removed": "Removido",
|
||||
"history_cleared": "Histórico limpo",
|
||||
"entries_count": "{count} downloads",
|
||||
"one_entry": "1 download",
|
||||
"redownload_confirm_title": "Baixar Novamente?",
|
||||
"redownload_confirm_message": "Baixar novamente?\n\n{title}",
|
||||
"redownload_started": "Download iniciado",
|
||||
"no_url_error": "Nenhuma URL encontrada no item do histórico",
|
||||
"redownload_failed": "Falha ao iniciar o novo download: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "Instalação do FFmpeg",
|
||||
"installation_message": "O YTSage precisa do FFmpeg para processar vídeos.\n\nEscolha uma opção de instalação abaixo:",
|
||||
"install_button": "Instalar FFmpeg",
|
||||
"manual_guide": "Guia manual",
|
||||
"installation_failed": "A instalação do FFmpeg encontrou um problema.",
|
||||
"already_installed": "O FFmpeg já está instalado!",
|
||||
"installation_complete": "Instalação concluída. Você pode fechar este diálogo e continuar usando o YTSage.",
|
||||
"installing": "Instalando FFmpeg... Aguarde",
|
||||
"install_success": "FFmpeg instalado com sucesso!",
|
||||
"installation_complete_close": "Instalação concluída. Agora você pode fechar este diálogo e continuar usando o YTSage.",
|
||||
"try_manual": "Tente usar o guia de instalação manual."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "Configuração do yt-dlp necessária",
|
||||
"description": "O YTSage precisa do yt-dlp para baixar vídeos.<br><br>O yt-dlp não foi encontrado no diretório local do aplicativo. O YTSage precisa configurar o yt-dlp para o seu sistema {os_name}.<br><br>Escolha uma opção abaixo:",
|
||||
"option_auto": "Baixar automaticamente (recomendado)",
|
||||
"option_manual": "Selecionar caminho manualmente",
|
||||
"setup_button": "Configurar yt-dlp",
|
||||
"downloading": "Baixando yt-dlp...",
|
||||
"success": "yt-dlp foi instalado com sucesso!",
|
||||
"error": "Erro: {error}",
|
||||
"download_failed_title": "Falha no download",
|
||||
"download_failed_message": "Falha ao baixar yt-dlp: {error}",
|
||||
"select_executable_title": "Selecionar executável do yt-dlp",
|
||||
"copied_to": "yt-dlp copiado com sucesso para {path}",
|
||||
"setup_error_title": "Erro de configuração",
|
||||
"copy_error": "Erro ao copiar yt-dlp para o diretório do aplicativo: {error}",
|
||||
"invalid_executable_title": "Executável inválido",
|
||||
"invalid_executable_message": "O arquivo selecionado não parece ser um executável válido do yt-dlp.",
|
||||
"verify_error": "Erro ao verificar o executável do yt-dlp: {error}",
|
||||
"setup_failed_title": "Configuração falhou",
|
||||
"setup_failed_message": "Falha ao configurar o yt-dlp. Alguns recursos podem não funcionar corretamente.",
|
||||
"success_dialog_title": "Configuração do yt-dlp",
|
||||
"success_dialog_message": "yt-dlp foi configurado com sucesso em:\n{path}",
|
||||
"file_filter_windows": "Arquivos executáveis (*.exe)",
|
||||
"file_filter_all": "Todos os arquivos (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "Verificador de Versão FFmpeg",
|
||||
"current_version": "Versão Atual:",
|
||||
"latest_version": "Última Versão:",
|
||||
"status_up_to_date": "✓ Atualizado",
|
||||
"status_update_available": "⚠ Atualização disponível",
|
||||
"status_not_installed": "✗ Não instalado",
|
||||
"status_idle": "Clique em 'Verificar Versão' para começar",
|
||||
"status_checking": "🔄 Verificando versão...",
|
||||
"check_updates": "Verificar Versão",
|
||||
"check_failed": "Falha ao verificar versão. Verifique sua conexão com a Internet.",
|
||||
"description": "Verifique sua versão do FFmpeg e compare com a última versão disponível.",
|
||||
"guide_info": " Para instalar ou atualizar o FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>clique aqui para ver nosso guia completo de instalação</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Configuração do Deno Necessária",
|
||||
"setup_description": "O YTSage requer o Deno para executar determinados recursos.<br><br>O Deno não foi encontrado no diretório local do aplicativo. O YTSage precisa configurar o Deno para o seu sistema {os_name}.<br><br>Clique em 'Configurar Deno' para baixar e instalar automaticamente.",
|
||||
"setup_button": "Configurar Deno",
|
||||
"downloading": "Baixando Deno...",
|
||||
"extracting": "Extraindo Deno...",
|
||||
"verifying": "Verificando Deno...",
|
||||
"success": "Deno foi instalado com sucesso!",
|
||||
"download_failed": "Falha no Download",
|
||||
"download_error": "Falha ao baixar o Deno: {error}",
|
||||
"verification_failed": "Falha na verificação SHA256. O arquivo baixado pode estar corrompido ou adulterado.",
|
||||
"setup_failed": "Falha ao configurar o Deno. Alguns recursos podem não funcionar corretamente.",
|
||||
"setup_error": "Erro de Configuração"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Verificador e Atualizador de Versão do Deno",
|
||||
"description": "Verifique sua versão do Deno e atualize para a versão mais recente.",
|
||||
"current_version": "Versão Atual:",
|
||||
"latest_version": "Última Versão:",
|
||||
"status_idle": "Clique em 'Verificar Atualizações' para começar",
|
||||
"status_checking": "🔄 Verificando atualizações...",
|
||||
"status_up_to_date": "✓ Atualizado",
|
||||
"status_update_available": "⚠ Atualização disponível",
|
||||
"status_not_installed": "✗ Não instalado",
|
||||
"check_updates": "Verificar Atualizações",
|
||||
"update_now": "Atualizar Deno",
|
||||
"updating": "🔄 Atualizando Deno...",
|
||||
"update_success": "✅ Deno foi atualizado com sucesso!",
|
||||
"update_failed": "❌ Falha na atualização: {error}",
|
||||
"check_failed": "Falha ao verificar atualizações. Verifique sua conexão com a Internet."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Русский (Russian)",
|
||||
"select_language": "Выберите язык:",
|
||||
"current_language": "Текущий язык: {language}",
|
||||
"restart_required": "Изменения языка вступят в силу после перезапуска приложения.",
|
||||
"english": "Английский",
|
||||
"spanish": "Испанский",
|
||||
"portuguese": "Португальский",
|
||||
"russian": "Русский",
|
||||
"chinese": "中文 (简体) (Chinese Simplified)",
|
||||
"german": "Немецкий",
|
||||
"french": "Французский",
|
||||
"hindi": "Хинди",
|
||||
"indonesian": "Индонезийский",
|
||||
"turkish": "Турецкий",
|
||||
"polish": "Польский",
|
||||
"italian": "Итальянский",
|
||||
"arabic": "Арабский",
|
||||
"japanese": "Японский",
|
||||
"help_text": "Выберите предпочитаемый язык для интерфейса.",
|
||||
"restart_notice": "Изменение языка вступит в силу после перезапуска приложения."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Готово"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Показать форматы:",
|
||||
"video_format": "Видео формат:",
|
||||
"audio_format": "Аудио формат:",
|
||||
"no_formats": "Форматы недоступны",
|
||||
"loading": "Загрузка форматов...",
|
||||
"select": "Выбрать",
|
||||
"quality": "Качество",
|
||||
"extension": "Расширение",
|
||||
"resolution": "Разрешение",
|
||||
"file_size": "Размер файла",
|
||||
"codec": "Кодек",
|
||||
"audio": "Аудио",
|
||||
"fps": "Кадр/с",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Объединит аудио",
|
||||
"has_audio": "✓ Есть аудио",
|
||||
"audio_only": "Только аудио",
|
||||
"best_4k": "Лучшее (4K)",
|
||||
"best_2k": "Лучшее (2K)",
|
||||
"high_1080p": "Высокое (1080p)",
|
||||
"high_720p": "Высокое (720p)",
|
||||
"medium_480p": "Среднее (480p)",
|
||||
"low_quality": "Низкое качество",
|
||||
"best_audio": "Лучшее аудио",
|
||||
"high_audio": "Высокое аудио",
|
||||
"medium_audio": "Среднее аудио",
|
||||
"low_audio": "Низкое аудио",
|
||||
"audio_only_resolution": "Только аудио"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Сброс",
|
||||
"download": "Скачать",
|
||||
"pause": "Пауза",
|
||||
"resume": "Продолжить",
|
||||
"cancel": "Отмена",
|
||||
"browse": "Обзор",
|
||||
"clear": "Очистить",
|
||||
"ok": "OK",
|
||||
"apply": "Применить",
|
||||
"close": "Закрыть",
|
||||
"run_command": "Выполнить команду",
|
||||
"custom_command_help": "Помощь",
|
||||
"about": "О программе",
|
||||
"analyze": "Анализировать",
|
||||
"paste_url": "Вставить URL",
|
||||
"select_videos": "Выбрать видео...",
|
||||
"video": "Видео",
|
||||
"audio_only": "Только аудио",
|
||||
"custom_options": "Пользовательские опции",
|
||||
"trim_video": "Обрезать видео",
|
||||
"download_settings": "Настройки загрузки",
|
||||
"update": "Обновить yt-dlp",
|
||||
"select_defaults": "Выбрать по умолчанию",
|
||||
"select_all": "Выбрать всё",
|
||||
"deselect_all": "Снять выделение",
|
||||
"open_folder": "Открыть расположение папки",
|
||||
"history": "История",
|
||||
"save_playlist": "Сохранить плейлист как"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Пользовательские опции",
|
||||
"settings": "Настройки",
|
||||
"select_folder": "Выбрать папку загрузки",
|
||||
"sponsorblock_categories": "Категории SponsorBlock",
|
||||
"sponsorblock_description": "Выберите типы сегментов видео, которые будут автоматически удалены во время загрузки.\nSponsorBlock использует данные, отправленные сообществом, для идентификации этих сегментов.",
|
||||
"select_subtitles": "Выбрать субтитры",
|
||||
"filter_languages_placeholder": "Фильтр языков (например, en, ru)...",
|
||||
"no_subtitles_available": "Субтитры недоступны",
|
||||
"matching": "соответствующие",
|
||||
"ytdlp_log_title": "Журнал yt-dlp",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Войти через Cookie",
|
||||
"custom_command": "Пользовательская команда",
|
||||
"proxy": "Прокси",
|
||||
"language": "Язык",
|
||||
"updater": "Обновление"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Выберите способ предоставления cookie для входа в систему.\nЭто позволяет загружать приватные видео и аудио премиум качества.",
|
||||
"cookie_source": "Источник Cookie",
|
||||
"use_cookie_file": "Использовать файл cookie",
|
||||
"extract_from_browser": "Извлечь из браузера",
|
||||
"recommended": "Рекомендуется",
|
||||
"cookie_file": "Файл Cookie",
|
||||
"cookie_file_placeholder": "Путь к файлу cookies.txt...",
|
||||
"browser_selection": "Выбор браузера",
|
||||
"browser_help": "Выберите браузер для извлечения cookie:",
|
||||
"browser_label": "Браузер:",
|
||||
"profile_label": "Профиль:",
|
||||
"profile_placeholder": "по умолчанию",
|
||||
"browser_extract_message": "Cookie браузера будут извлечены при применении",
|
||||
"file_selected_message": "Файл cookie выбран - Нажмите OK для применения",
|
||||
"select_file_title": "Выбрать файл Cookie",
|
||||
"file_filter": "Файлы cookie (*.txt *.lwp)",
|
||||
"file_selected_title": "Файл Cookie применён",
|
||||
"file_applied_message": "Файл cookie применён: {path}",
|
||||
"browser_selected_title": "Cookie браузера применены",
|
||||
"browser_applied_message": "Cookie браузера будут извлечены из: {browser}",
|
||||
"cleared_title": "Cookie очищены",
|
||||
"cleared_message": "Настройки cookie были очищены",
|
||||
"active_browser": "✓ Активны: cookie браузера ({browser})",
|
||||
"active_file": "✓ Активен: файл cookie ({file})",
|
||||
"none_active": "○ Нет активных cookie",
|
||||
"remember_settings": "Запомнить настройки cookie при следующем запуске"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Введите пользовательскую команду yt-dlp ниже. Текущий URL будет добавлен автоматически.<br><br>Для полного списка опций и примеров использования, <a href=\"{docs_url}\">нажмите здесь для просмотра официальной документации yt-dlp</a>.<br><br>Примечание: Путь загрузки и шаблон имени файла будут обработаны автоматически.",
|
||||
"input_label": "Аргументы yt-dlp:",
|
||||
"input_placeholder": "Введите аргументы yt-dlp здесь...\n\nнапример: --extract-audio --audio-format mp3",
|
||||
"output_label": "Вывод команды:",
|
||||
"output_placeholder": "Вывод команды появится здесь...",
|
||||
"command_placeholder": "Введите пользовательскую команду yt-dlp...",
|
||||
"command_help": "Доступные заполнители:\n{url} - URL видео\n{output} - Выходной каталог\n\nПример: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Полная команда: {command}",
|
||||
"command_success": "✅ Пользовательская команда выполнена успешно!",
|
||||
"command_failed": "❌ Команда завершилась с кодом ошибки {code}",
|
||||
"command_error": "❌ Ошибка выполнения пользовательской команды: {error}",
|
||||
"error_no_url": "❌ Ошибка: URL не указан. Введите URL в главном окне.",
|
||||
"error_no_command": "❌ Ошибка: команда не указана. Введите аргументы yt-dlp.",
|
||||
"executing": "🚀 Выполняется пользовательская команда yt-dlp",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Аргументы: {command}",
|
||||
"download_path_label": "📁 Путь загрузки: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "Настройте параметры прокси для загрузки. Оставьте пустым для прямого подключения.",
|
||||
"main_proxy": "Основной прокси",
|
||||
"main_proxy_help": "Основной прокси-сервер для всех загрузок. Поддерживает протоколы HTTP/HTTPS и SOCKS5.",
|
||||
"proxy_url_label": "URL прокси:",
|
||||
"proxy_url_placeholder": "http://proxy:port или socks5://proxy:port",
|
||||
"proxy_examples": "Примеры:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Прокси для обхода географических ограничений",
|
||||
"geo_proxy_help": "Вторичный прокси специально для обхода географических ограничений.",
|
||||
"geo_proxy_url_label": "URL прокси для обхода гео-ограничений:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "Очистить основной прокси",
|
||||
"clear_geo_proxy": "Очистить гео-прокси",
|
||||
"proxy_url": "URL прокси",
|
||||
"proxy_placeholder": "http://proxy:port или socks5://proxy:port",
|
||||
"geo_bypass": "Прокси для обхода гео-ограничений",
|
||||
"geo_bypass_placeholder": "http://proxy:port для обхода гео-блокировок",
|
||||
"invalid_main_url": "Неверный формат URL основного прокси",
|
||||
"invalid_geo_url": "Неверный формат URL гео-прокси",
|
||||
"main_configured": "Основной прокси настроен",
|
||||
"geo_configured": "Гео-прокси настроен",
|
||||
"set_title": "Прокси задан",
|
||||
"set_message": "Основной прокси задан и сохранён: {proxy}",
|
||||
"geo_set_title": "Гео‑прокси задан",
|
||||
"geo_set_message": "Прокси для гео‑проверки задан и сохранён: {proxy}",
|
||||
"cleared_title": "Настройки прокси очищены",
|
||||
"cleared_message": "Все настройки прокси очищены и сохранены.",
|
||||
"saved_main": "Сохранён основной прокси: {proxy}",
|
||||
"saved_geo": "Сохранён гео‑прокси: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "Подготовка загрузки...",
|
||||
"starting": "🚀 Начинается загрузка...",
|
||||
"fetching_info": "🔍 Получение информации о видео...",
|
||||
"preparing_streams": "🎯 Подготовка видеопотоков...",
|
||||
"downloading_audio": "⏬ Загрузка аудио...",
|
||||
"downloading_video": "⏬ Загрузка видео...",
|
||||
"downloading_subtitle": "⏬ Загрузка субтитров...",
|
||||
"downloading": "⏬ Загрузка...",
|
||||
"completed": "✅ Загрузка завершена!",
|
||||
"video_completed": "✅ Загрузка видео завершена!",
|
||||
"audio_completed": "✅ Загрузка аудио завершена!",
|
||||
"subtitle_completed": "✅ Загрузка субтитров завершена!",
|
||||
"completed_cleaning": "✅ Загрузка завершена! Очистка...",
|
||||
"cancelled": "Загрузка отменена",
|
||||
"processing_playlist": "📋 Обработка данных плейлиста...",
|
||||
"paused": "Загрузка приостановлена",
|
||||
"resumed": "Загрузка возобновлена",
|
||||
"merging_formats": "✨ Постобработка: Слияние форматов...",
|
||||
"removing_sponsor_segments": "✨ Постобработка: Удаление рекламных сегментов...",
|
||||
"speed": "Скорость",
|
||||
"eta": "Осталось",
|
||||
"please_enter_url": "Пожалуйста, введите URL сначала.",
|
||||
"please_set_path": "Пожалуйста, установите путь загрузки сначала.",
|
||||
"please_enter_url_and_path": "Пожалуйста, введите URL и установите путь загрузки.",
|
||||
"please_select_format": "Пожалуйста, выберите формат сначала.",
|
||||
"downloading_fallback": "⚡ Загрузка..."
|
||||
},
|
||||
"update": {
|
||||
"title": "Обновить yt-dlp",
|
||||
"checking": "Проверка обновлений...",
|
||||
"update_available": "Доступно обновление!\nТекущая версия: {current}\nПоследняя версия: {latest}",
|
||||
"up_to_date": "yt-dlp актуален (версия {version})",
|
||||
"could_not_determine": "Не удалось определить версии.",
|
||||
"error_comparing": "Ошибка сравнения версий: {error}",
|
||||
"update_available_failed": "Доступно обновление! (Сравнение не удалось)\nТекущая: {current}\nПоследняя: {latest}",
|
||||
"updating": "Обновление...",
|
||||
"initializing": "🚀 Инициализация процесса обновления...",
|
||||
"checking_current": "🔍 Проверка текущей установки...",
|
||||
"found_at": "📍 yt-dlp найден в: {path}",
|
||||
"error_getting_path": "❌ Ошибка получения пути yt-dlp: {error}",
|
||||
"updating_binary": "📦 Обновление бинарного файла yt-dlp, управляемого приложением...",
|
||||
"update_failed": "❌ Не удалось обновить yt-dlp. Попробуйте еще раз или проверьте подключение к интернету.",
|
||||
"binary_updated": "✅ Бинарный файл успешно обновлен!",
|
||||
"update_failed_stderr": "❌ Обновление yt-dlp не удалось: {error}",
|
||||
"update_timeout": "❌ Время ожидания обновления yt-dlp истекло.",
|
||||
"unexpected_error": "❌ Неожиданная ошибка во время обновления: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp уже актуален!",
|
||||
"update_success": "✅ yt-dlp был успешно обновлен!",
|
||||
"already_latest": "yt-dlp актуален (версия {version})",
|
||||
"network_error": "❌ Ошибка сети во время обновления: {error}",
|
||||
"general_error": "❌ Обновление не удалось: {error}",
|
||||
"update_in_progress_title": "Идет обновление",
|
||||
"update_in_progress_message": "yt-dlp обновляется. Пожалуйста, подождите."
|
||||
},
|
||||
"about": {
|
||||
"title": "О YTSage",
|
||||
"version": "Версия {version}",
|
||||
"description": "Современный загрузчик YouTube с чистым интерфейсом PySide6.",
|
||||
"author": "Автор: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Системная информация",
|
||||
"loading": "🔄 Загрузка системной информации...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Логи",
|
||||
"logs_tooltip": "Открыть папку с логами приложения",
|
||||
"refreshing": "🔄 Обновление...",
|
||||
"refresh_failed": "Обновление не удалось",
|
||||
"refresh_failed_message": "Не удалось обновить информацию о версии.",
|
||||
"detected": "✓ Обнаружено",
|
||||
"missing": "✗ Отсутствует",
|
||||
"not_available": "Недоступно"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Обрезать видео",
|
||||
"time_range_group": "Временной диапазон",
|
||||
"start_time": "Время начала (HH:MM:SS)",
|
||||
"end_time": "Время окончания (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Время начала (HH:MM:SS)",
|
||||
"end_time_placeholder": "Время окончания (HH:MM:SS)",
|
||||
"force_keyframes": "Принудительные ключевые кадры в точках обрезки",
|
||||
"help_text": "Установите время начала и окончания для обрезки видео.\nОставьте пустым для загрузки полного видео.",
|
||||
"invalid_format": "Неверный формат времени. Используйте формат HH:MM:SS.",
|
||||
"start_after_end": "Время начала не может быть после времени окончания."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки загрузки",
|
||||
"download_path": "Путь загрузки",
|
||||
"browse": "Обзор...",
|
||||
"speed_limit": "Ограничение скорости",
|
||||
"speed_limit_placeholder": "Нет",
|
||||
"generic_mode": "Универсальный режим",
|
||||
"enable_generic_mode": "Включить универсальный режим (поддержка сайтов не только YouTube)",
|
||||
"generic_mode_help": "Позволяет скачивать с Dailymotion, CBC Gem и других сайтов, поддерживаемых yt-dlp.",
|
||||
"auto_update_ytdlp": "Автообновление yt-dlp",
|
||||
"enable_auto_updates": "Включить автоматические обновления yt-dlp",
|
||||
"update_frequency": "Частота обновления:",
|
||||
"check_startup": "Проверять при каждом запуске (минимум 1 час между проверками)",
|
||||
"check_daily": "Проверять ежедневно",
|
||||
"check_weekly": "Проверять еженедельно",
|
||||
"check_updates_now": "Обновить yt-dlp",
|
||||
"update_check_title": "Проверка обновлений",
|
||||
"could_not_determine_version": "Не удалось определить текущую версию yt-dlp.",
|
||||
"update_available_dialog": "Доступно обновление!\n\nТекущая: {current}\nПоследняя: {latest}\n\nНажмите OK для продолжения обновления.",
|
||||
"up_to_date_dialog": "yt-dlp актуален!\n\nТекущая версия: {version}",
|
||||
"error_checking_updates": "Ошибка проверки обновлений: {error}",
|
||||
"settings_saved_title": "Настройки сохранены",
|
||||
"settings_saved_message": "Настройки автообновления успешно сохранены!",
|
||||
"error_title": "Ошибка",
|
||||
"failed_save_settings": "Не удалось сохранить настройки автообновления.",
|
||||
"error_saving_settings": "Ошибка сохранения настроек автообновления: {error}",
|
||||
"ytdlp_channel": "Канал выпуска yt-dlp",
|
||||
"ytdlp_channel_stable": "Стабильный (Протестированные релизы)",
|
||||
"ytdlp_channel_nightly": "Nightly (Ежедневные обновления, рекомендовано yt-dlp)",
|
||||
"ytdlp_channel_description": "Выберите между стабильными и nightly релизами. Nightly сборки обновляются ежедневно с последними исправлениями и функциями. Вы можете переключать каналы в любое время.",
|
||||
"ytdlp_switching_channel": "Переключение на канал {channel}...",
|
||||
"ytdlp_channel_switched": "✅ Успешно переключено на канал {channel}!",
|
||||
"ytdlp_channel_switch_failed": "❌ Не удалось переключить канал: {error}",
|
||||
"ytdlp_current_channel": "Текущий канал: {channel}",
|
||||
"app_updates_title": "Обновления YTSage",
|
||||
"check_app_updates": "Проверять обновления YTSage при запуске",
|
||||
"check_beta_updates": "Получать бета-обновления",
|
||||
"auto_update_title": "Настройки автообновления",
|
||||
"auto_update_header": "🔄 Настройки автообновления",
|
||||
"auto_update_description": "Настройте автоматические обновления для yt-dlp, чтобы всегда иметь последние функции и исправления ошибок.",
|
||||
"current_status": "Текущий статус",
|
||||
"current_version_label": "Текущая версия yt-dlp: Проверка...",
|
||||
"last_check_label": "Последняя проверка обновлений: Никогда",
|
||||
"next_check_label": "Следующая проверка: Основана на настройках",
|
||||
"manual_check_button": "🔍 Проверить обновления сейчас",
|
||||
"update_frequency_group": "Частота обновления",
|
||||
"save_settings": "Сохранить настройки",
|
||||
"settings_saved_successfully": "✅ Настройки успешно сохранены!",
|
||||
"error_saving": "❌ Ошибка сохранения настроек: {error}",
|
||||
"output_format_settings": "Настройки выходного формата",
|
||||
"force_output_format": "Принудительно задать выходной формат при объединении",
|
||||
"preferred_format": "Предпочтительный формат:",
|
||||
"force_format_help": "Когда включено, объединённые видео будут конвертированы в ваш предпочтительный формат. Отключите, чтобы yt-dlp решал автоматически.",
|
||||
"format_mp4": "MP4 (Наиболее совместимый)",
|
||||
"format_webm": "WebM (Современный, открытый)",
|
||||
"format_mkv": "MKV (Многофункциональный)",
|
||||
"audio_format_settings": "Настройки формата аудио",
|
||||
"force_audio_format": "Принудительный формат аудио для загрузки только аудио",
|
||||
"audio_normalization": "Нормализация звука (EBU R128)",
|
||||
"audio_normalization_help": "При включении звуковые дорожки будут нормализованы. Примечание: Это требует перекодирования, поэтому необходимо принудительно задать определенный аудиоформат (например, MP3 или M4A).",
|
||||
"preferred_audio_format": "Предпочтительный формат аудио:",
|
||||
"force_audio_format_help": "При включении загрузки только аудио будут конвертированы в ваш предпочтительный формат. Это применяется только при загрузке аудио форматов.",
|
||||
"audio_format_best": "Лучшее (Без конвертации)",
|
||||
"audio_format_aac": "AAC (Хорошо для редактирования)",
|
||||
"audio_format_mp3": "MP3 (Универсальный)",
|
||||
"audio_format_flac": "FLAC (Без потерь)",
|
||||
"audio_format_wav": "WAV (Несжатый)",
|
||||
"audio_format_opus": "Opus (Эффективный)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Открытый)",
|
||||
"filename_format": "Формат имени файла",
|
||||
"filename_format_help": "Доступные переменные: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. Поддерживается стандартный синтаксис шаблона вывода yt-dlp.",
|
||||
"tab_general": "Общие",
|
||||
"tab_format": "Формат",
|
||||
"tab_file": "Файл",
|
||||
"concurrent_fragments": "Одновременные соединения",
|
||||
"concurrent_fragments_help": "Количество соединений на одну загрузку. Более высокие значения позволяют обойти ограничение скорости, но могут вызвать временную блокировку при слишком высоких значениях. По умолчанию: 1.",
|
||||
"defaults_settings": "Настройки выбора по умолчанию",
|
||||
"default_video_quality": "Разрешение видео по умолчанию (высота):",
|
||||
"default_subtitle_language": "Язык(и) субтитров по умолчанию:",
|
||||
"defaults_help": "Установите предпочтительную высоту видео и языки субтитров (через запятую). Они будут выбраны автоматически, إذا كان متاحاً."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "Введите URL видео или плейлиста YouTube",
|
||||
"url_placeholder_generic": "Введите URL видео или плейлиста с любого поддерживаемого сайта",
|
||||
"merge_subtitles": "Объединить субтитры",
|
||||
"save_thumbnail": "Сохранить миниатюру",
|
||||
"save_description": "Сохранить описание",
|
||||
"embed_chapters": "Встроить главы",
|
||||
"subtitles_selected": "выбрано: {count}",
|
||||
"all_selected": "Все выбраны",
|
||||
"select_videos_all": "Выбрать видео... (Все выбраны)",
|
||||
"please_enter_url": "Пожалуйста, введите URL сначала",
|
||||
"error_title": "Ошибка",
|
||||
"cookie_file_selected_title": "Файл Cookie выбран",
|
||||
"cookie_file_selected_message": "Файл cookie выбран: {path}",
|
||||
"browser_cookies_selected_title": "Cookie браузера выбраны",
|
||||
"browser_cookies_selected_message": "Cookie браузера будут извлечены из: {browser}",
|
||||
"error_no_format_info": "Ошибка: Информация о формате недоступна.",
|
||||
"error_extract_info": "Ошибка: Не удалось извлечь базовую информацию о видео. Проверьте вашу ссылку.",
|
||||
"analyzing_preparing": "Подготовка запроса...",
|
||||
"analyzing_extracting_basic": "Извлечение базовой информации...",
|
||||
"analyzing_extracting_detailed": "Извлечение подробной информации...",
|
||||
"analyzing_processing_video": "Обработка данных видео...",
|
||||
"analyzing_processing_formats": "Обработка форматов...",
|
||||
"analyzing_loading_thumbnail": "Загрузка миниатюры...",
|
||||
"analyzing_processing_subtitles": "Обработка субтитров...",
|
||||
"analyzing_updating_table": "Обновление таблицы форматов...",
|
||||
"analysis_complete": "Анализ завершен!",
|
||||
"analyzing_fetching_first_video": "Получение форматов для первого видео...",
|
||||
"analyzing_extracting_ytdlp": "Извлечение информации...",
|
||||
"analyzing_processing_data": "Обработка данных...",
|
||||
"analyzing_processing_formats_ytdlp": "Обработка форматов...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Загрузка миниатюры...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Обработка субтитров...",
|
||||
"select_subtitles": "Выбрать субтитры...",
|
||||
"sponsorblock_categories": "Категории SponsorBlock...",
|
||||
"invalid_url_or_enter": "Неверный URL или пожалуйста введите URL.",
|
||||
"zero_selected": "0 выбрано",
|
||||
"analyze_first_tooltip": "Пожалуйста, сначала проанализируйте видео",
|
||||
"audio_mode_disabled": "Недоступно в режиме только аудио",
|
||||
"select_subtitles_first": "Пожалуйста, сначала выберите субтитры",
|
||||
"settings_tooltip": "Текущий путь: {path}\nЛимит скорости: {speed_limit}",
|
||||
"speed_limit_none": "Нет",
|
||||
"open_folder_error": "Не удалось открыть папку: {error}",
|
||||
"time_range_set": "Установлен участок: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Спонсор",
|
||||
"sponsor_desc": "Платная реклама, платные рефералы и прямая реклама",
|
||||
"selfpromo": "Неоплачиваемая/Собственная реклама",
|
||||
"selfpromo_desc": "Неоплачиваемая реклама собственного контента создателей",
|
||||
"interaction": "Напоминание о взаимодействии",
|
||||
"interaction_desc": "Просьба к зрителям поставить лайк, подписаться или подписаться в социальных сетях",
|
||||
"intro": "Вступление",
|
||||
"intro_desc": "Вступление к видео, которое можно пропустить",
|
||||
"outro": "Концовка/Финальные карточки",
|
||||
"outro_desc": "Титры или когда видео заканчивается",
|
||||
"preview": "Предварительный просмотр/Краткое изложение",
|
||||
"preview_desc": "Краткое изложение предыдущих видео или предварительный просмотр предстоящего",
|
||||
"music_offtopic": "Немузыкальная секция",
|
||||
"music_offtopic_desc": "Только для музыкальных видео. Отмечает немузыкальные секции",
|
||||
"filler": "Отступление-заполнитель",
|
||||
"filler_desc": "Касательные сцены, добавленные только для заполнения или юмора"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Канал",
|
||||
"views": "👁 Просмотры",
|
||||
"likes": "👍 Лайки",
|
||||
"upload_date": "📅 Дата загрузки",
|
||||
"duration": "⏱ Продолжительность",
|
||||
"unknown_channel": "Неизвестный канал",
|
||||
"unknown_date": "Неизвестная дата",
|
||||
"unknown_title": "Неизвестный заголовок"
|
||||
},
|
||||
"command": {
|
||||
"running": "Выполняется...",
|
||||
"run_command": "Выполнить команду"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 выбрано",
|
||||
"one_selected": "1 категория выбрана",
|
||||
"count_selected": "выбрано: {count}"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Готово",
|
||||
"file_exists": "⚠️ Файл уже существует",
|
||||
"video_file_exists": "⚠️ Видеофайл уже существует",
|
||||
"audio_file_exists": "⚠️ Аудиофайл уже существует",
|
||||
"subtitle_file_exists": "⚠️ Файл субтитров уже существует",
|
||||
"cancelling": "Отмена загрузки...",
|
||||
"thumbnail_saved": "✅ Миниатюра сохранена: {filename}",
|
||||
"thumbnail_error": "❌ Ошибка миниатюры: {error}",
|
||||
"thumbnail_no_image": "Нет доступной миниатюры для сохранения"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Ошибка: Плейлист не содержит действительных видео.",
|
||||
"playlist_no_url": "Ошибка: Не удалось получить URL для первого видео плейлиста.",
|
||||
"ytdlp_not_found": "Ошибка: Исполняемый файл yt-dlp не найден. Пожалуйста, сначала установите yt-dlp.",
|
||||
"ytdlp_not_found_path": "Ошибка: Исполняемый файл yt-dlp не найден. Это может быть связано с неправильной установкой или проблемой PATH.",
|
||||
"no_data_returned": "Ошибка: Данные от yt-dlp не возвращены",
|
||||
"no_format_info": "Ошибка: Информация о формате недоступна.",
|
||||
"analysis_timeout": "Ошибка: Тайм-аут анализа. Пожалуйста, попробуйте снова.",
|
||||
"invalid_speed_limit": "❌ Ошибка: Неверное значение ограничения скорости в настройках.",
|
||||
"ytdlp_failed": "Ошибка: yt-dlp завершился с ошибкой: {error}",
|
||||
"parse_failed": "Ошибка: Не удалось разобрать вывод yt-dlp: {error}",
|
||||
"analysis_failed": "Ошибка: Анализ не удался: {error}",
|
||||
"generic_error": "Ошибка: {error}",
|
||||
"download_failed_return_code_conflict": "Загрузка завершилась ошибкой с кодом {return_code}. Это может быть из-за конфликта нескольких установок yt-dlp. Попробуйте удалить системно установленный yt-dlp (например, через snap или apt) и перезапустите приложение.",
|
||||
"download_failed_return_code": "Загрузка не удалась, код {return_code}",
|
||||
"direct_command_error": "Ошибка в прямой команде: {error}",
|
||||
"private_video": "Это видео может быть приватным. Пожалуйста, используйте файлы cookie из пользовательских настроек."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Доступно обновление",
|
||||
"new_version_available": "Доступна новая версия YTSage!",
|
||||
"current_version_label": "Текущая версия:",
|
||||
"latest_version_label": "Последняя версия:",
|
||||
"changelog": "Список изменений",
|
||||
"download_update": "Скачать обновление",
|
||||
"remind_later": "Напомнить позже"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Неизвестный плейлист",
|
||||
"total_videos": "Всего видео: {count}",
|
||||
"display_format": "Плейлист: {title} | {count} видео",
|
||||
"select_videos_title": "Выбрать Видео из Плейлиста",
|
||||
"save_as": "Сохранить плейлист как",
|
||||
"save_success_title": "Успех",
|
||||
"saved_successfully": "Плейлист успешно сохранен.",
|
||||
"save_error_title": "Ошибка сохранения",
|
||||
"no_videos_to_save": "Записи плейлиста не собраны!",
|
||||
"save_error_msg": "Не удалось сохранить плейлист."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "выбрано: {count}"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "Файл уже существует",
|
||||
"message": "Файл уже существует:\n{filename}",
|
||||
"info": "Это видео уже было загружено."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Последняя проверка: Никогда",
|
||||
"last_check": "Последняя проверка: {time}",
|
||||
"next_check_disabled": "Следующая проверка: Отключена",
|
||||
"next_check_startup": "Следующая проверка: При запуске",
|
||||
"next_check_overdue": "Следующая проверка: Сейчас (просрочено)",
|
||||
"next_check": "Следующая проверка: {time}",
|
||||
"next_check_error": "Следующая проверка: Ошибка расчета",
|
||||
"checking": "🔄 Проверка...",
|
||||
"check_now": "🔍 Проверить обновления сейчас",
|
||||
"current_version": "Текущая версия yt-dlp: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL не может быть пустым",
|
||||
"invalid_format": "Недопустимый формат URL",
|
||||
"invalid_scheme": "URL должен начинаться с http:// или https://",
|
||||
"missing_domain": "Недопустимый URL: отсутствует доменное имя",
|
||||
"unsupported_platform": "YTSage поддерживает только URL-адреса YouTube и YouTube Music.\nДомен '{domain}' не поддерживается.",
|
||||
"invalid_youtu_be": "Недопустимый URL youtu.be: отсутствует ID видео"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Это частное видео. Вы можете загрузить его, войдя в свою учетную запись с помощью cookies.\nПерейдите в 'Пользовательские параметры' → 'Вход с помощью Cookies' → 'Извлечь cookies из браузера' для аутентификации.",
|
||||
"age_restricted": "Это видео имеет возрастные ограничения. Для доступа необходимо войти в систему.\nИспользуйте 'Пользовательские параметры' → 'Вход с помощью Cookies' для аутентификации с вашей учетной записью.",
|
||||
"geo_blocked": "Это видео недоступно в вашем регионе (геоблокировка).\nВам может понадобиться VPN или видео может быть ограничено в вашей стране.",
|
||||
"video_unavailable": "Это видео было удалено или больше не доступно.\nВидео могло быть удалено загрузчиком или удалено из-за нарушения правил.",
|
||||
"live_stream": "Это прямая трансляция, которую нельзя загрузить во время активности.\nДождитесь окончания трансляции, затем попробуйте загрузить архивную версию.",
|
||||
"playlist_error": "Не удается получить доступ к этому плейлисту. Он может быть частным, удаленным или пустым.\nПроверьте, существует ли плейлист и доступен ли он публично.",
|
||||
"network_error": "Ошибка сетевого подключения. Проверьте подключение к Интернету и повторите попытку.\nЕсли проблема сохраняется, видеосервер может быть временно недоступен.",
|
||||
"invalid_url": "Недопустимый или неподдерживаемый URL. Проверьте ссылку и повторите попытку.\nУбедитесь, что вы используете действительный URL YouTube, Vimeo или другой поддерживаемой платформы.",
|
||||
"premium_content": "Для этого контента требуется YouTube Premium или членство в канале.\nВам необходимо войти с учетной записью, имеющей доступ к этому контенту.",
|
||||
"copyright_blocked": "Это видео заблокировано из-за претензий по авторским правам.\nВладелец контента ограничил доступ к этому видео.",
|
||||
"extraction_failed": "Не удалось извлечь информацию о видео. Это может быть временной проблемой.\nПовторите попытку через несколько минут или проверьте правильность ссылки на видео.",
|
||||
"generic_error": "Не удалось извлечь информацию о видео. Проверьте вашу ссылку.\nТехнические детали: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "История Загрузок",
|
||||
"clear_all": "Очистить Всё",
|
||||
"clear_confirm_title": "Очистить Историю?",
|
||||
"clear_confirm_message": "Вы уверены? Это нельзя отменить.",
|
||||
"no_history": "История пока пуста",
|
||||
"no_history_description": "Загрузки появятся здесь",
|
||||
"loading": "Загрузка истории...",
|
||||
"search_placeholder": "Поиск...",
|
||||
"open_location": "Открыть Папку",
|
||||
"redownload": "Загрузить Снова",
|
||||
"remove": "Удалить",
|
||||
"file_not_found": "Файл не найден",
|
||||
"file_not_found_message": "Файл был перемещен или удален:\n{path}",
|
||||
"downloaded_on": "Загружено: {date}",
|
||||
"file_size": "Размер: {size}",
|
||||
"audio_download": "Аудио",
|
||||
"video_download": "Видео",
|
||||
"remove_confirm_title": "Удалить?",
|
||||
"remove_confirm_message": "Удалить из истории?\n\n{title}",
|
||||
"item_removed": "Удалено",
|
||||
"history_cleared": "История очищена",
|
||||
"entries_count": "{count} загрузок",
|
||||
"one_entry": "1 загрузка",
|
||||
"redownload_confirm_title": "Загрузить Снова?",
|
||||
"redownload_confirm_message": "Загрузить снова?\n\n{title}",
|
||||
"redownload_started": "Загрузка начата",
|
||||
"no_url_error": "В записи истории не найден URL",
|
||||
"redownload_failed": "Не удалось запустить повторную загрузку: {error}"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "Установка FFmpeg",
|
||||
"installation_message": "YTSage требуется FFmpeg для обработки видео.\n\nВыберите вариант установки ниже:",
|
||||
"install_button": "Установить FFmpeg",
|
||||
"manual_guide": "Руководство вручную",
|
||||
"installation_failed": "При установке FFmpeg возникла проблема.",
|
||||
"already_installed": "FFmpeg уже установлен!",
|
||||
"installation_complete": "Установка завершена. Вы можете закрыть это окно и продолжить использовать YTSage.",
|
||||
"installing": "Установка FFmpeg... Пожалуйста, подождите",
|
||||
"install_success": "FFmpeg успешно установлен!",
|
||||
"installation_complete_close": "Установка завершена. Теперь вы можете закрыть это окно и продолжить использовать YTSage.",
|
||||
"try_manual": "Пожалуйста, попробуйте воспользоваться руководством по ручной установке."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "Требуется настройка yt-dlp",
|
||||
"description": "YTSage требует yt-dlp для загрузки видео.<br><br>yt-dlp не найден в локальном каталоге приложения. YTSage нужно настроить yt-dlp для вашей системы {os_name}.<br><br>Выберите вариант ниже:",
|
||||
"option_auto": "Скачать автоматически (рекомендуется)",
|
||||
"option_manual": "Выбрать путь вручную",
|
||||
"setup_button": "Настроить yt-dlp",
|
||||
"downloading": "Загрузка yt-dlp...",
|
||||
"success": "yt-dlp успешно установлен!",
|
||||
"error": "Ошибка: {error}",
|
||||
"download_failed_title": "Сбой загрузки",
|
||||
"download_failed_message": "Не удалось скачать yt-dlp: {error}",
|
||||
"select_executable_title": "Выберите исполняемый файл yt-dlp",
|
||||
"copied_to": "yt-dlp успешно скопирован в {path}",
|
||||
"setup_error_title": "Ошибка настройки",
|
||||
"copy_error": "Ошибка при копировании yt-dlp в каталог приложения: {error}",
|
||||
"invalid_executable_title": "Недопустимый исполняемый файл",
|
||||
"invalid_executable_message": "Выбранный файл не является корректным исполняемым файлом yt-dlp.",
|
||||
"verify_error": "Ошибка при проверке исполняемого файла yt-dlp: {error}",
|
||||
"setup_failed_title": "Настройка не удалась",
|
||||
"setup_failed_message": "Не удалось настроить yt-dlp. Некоторые функции могут работать некорректно.",
|
||||
"success_dialog_title": "Настройка yt-dlp",
|
||||
"success_dialog_message": "yt-dlp успешно настроен по пути:\n{path}",
|
||||
"file_filter_windows": "Исполняемые файлы (*.exe)",
|
||||
"file_filter_all": "Все файлы (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "Проверка версии FFmpeg",
|
||||
"current_version": "Текущая версия:",
|
||||
"latest_version": "Последняя версия:",
|
||||
"status_up_to_date": "✓ Обновлено",
|
||||
"status_update_available": "⚠ Доступно обновление",
|
||||
"status_not_installed": "✗ Не установлено",
|
||||
"status_idle": "Нажмите 'Проверить версию' для начала",
|
||||
"status_checking": "🔄 Проверка версии...",
|
||||
"check_updates": "Проверить версию",
|
||||
"check_failed": "Не удалось проверить версию. Проверьте подключение к Интернету.",
|
||||
"description": "Проверьте версию FFmpeg и сравните ее с последней доступной версией.",
|
||||
"guide_info": " Для установки или обновления FFmpeg, <a href='https://github.com/oop7/ffmpeg-install-guide'>нажмите здесь, чтобы просмотреть наше подробное руководство по установке</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Требуется настройка Deno",
|
||||
"setup_description": "YTSage требует Deno для запуска определенных функций.<br><br>Deno не найден в локальном каталоге приложения. YTSage необходимо настроить Deno для вашей системы {os_name}.<br><br>Нажмите 'Настроить Deno', чтобы загрузить и установить автоматически.",
|
||||
"setup_button": "Настроить Deno",
|
||||
"downloading": "Загрузка Deno...",
|
||||
"extracting": "Извлечение Deno...",
|
||||
"verifying": "Проверка Deno...",
|
||||
"success": "Deno успешно установлен!",
|
||||
"download_failed": "Ошибка загрузки",
|
||||
"download_error": "Не удалось загрузить Deno: {error}",
|
||||
"verification_failed": "Проверка SHA256 не удалась. Загруженный файл может быть поврежден или изменен.",
|
||||
"setup_failed": "Не удалось настроить Deno. Некоторые функции могут работать неправильно.",
|
||||
"setup_error": "Ошибка настройки"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Проверка и Обновление Версии Deno",
|
||||
"description": "Проверьте вашу версию Deno и обновите до последней версии.",
|
||||
"current_version": "Текущая Версия:",
|
||||
"latest_version": "Последняя Версия:",
|
||||
"status_idle": "Нажмите 'Проверить Обновления', чтобы начать",
|
||||
"status_checking": "🔄 Проверка обновлений...",
|
||||
"status_up_to_date": "✓ Обновлено",
|
||||
"status_update_available": "⚠ Доступно обновление",
|
||||
"status_not_installed": "✗ Не установлено",
|
||||
"check_updates": "Проверить Обновления",
|
||||
"update_now": "Обновить Deno",
|
||||
"updating": "🔄 Обновление Deno...",
|
||||
"update_success": "✅ Deno успешно обновлен!",
|
||||
"update_failed": "❌ Ошибка обновления: {error}",
|
||||
"check_failed": "Не удалось проверить обновления. Проверьте подключение к Интернету."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "Türkçe (Turkish)",
|
||||
"select_language": "Dil Seç:",
|
||||
"current_language": "Mevcut dil: {language}",
|
||||
"restart_required": "Dil değişikliği uygulamayı yeniden başlattıktan sonra geçerli olacaktır.",
|
||||
"english": "İngilizce",
|
||||
"spanish": "İspanyolca",
|
||||
"portuguese": "Portekizce",
|
||||
"russian": "Rusça",
|
||||
"chinese": "Çince",
|
||||
"german": "Almanca",
|
||||
"french": "Fransızca",
|
||||
"hindi": "Hintçe",
|
||||
"indonesian": "Endonezce",
|
||||
"turkish": "Türkçe",
|
||||
"polish": "Lehçe",
|
||||
"italian": "İtalyanca",
|
||||
"arabic": "Arapça",
|
||||
"japanese": "Japonca",
|
||||
"help_text": "Arayüz için tercih ettiğiniz dili seçin.",
|
||||
"restart_notice": "Dil değişikliği uygulamayı yeniden başlattıktan sonra geçerli olacaktır."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "Hazır"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "Formatları göster:",
|
||||
"video_format": "Video formatı:",
|
||||
"audio_format": "Ses formatı:",
|
||||
"no_formats": "Kullanılabilir format yok",
|
||||
"loading": "Formatlar yükleniyor...",
|
||||
"select": "Seç",
|
||||
"quality": "Kalite",
|
||||
"extension": "Uzantı",
|
||||
"resolution": "Çözünürlük",
|
||||
"file_size": "Dosya boyutu",
|
||||
"codec": "Codec",
|
||||
"audio": "Ses",
|
||||
"fps": "FPS",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "Ses birleştirilecek",
|
||||
"has_audio": "✓ Ses var",
|
||||
"audio_only": "Sadece ses",
|
||||
"best_4k": "En iyi (4K)",
|
||||
"best_2k": "En iyi (2K)",
|
||||
"high_1080p": "Yüksek (1080p)",
|
||||
"high_720p": "Yüksek (720p)",
|
||||
"medium_480p": "Orta (480p)",
|
||||
"low_quality": "Düşük kalite",
|
||||
"best_audio": "En iyi ses",
|
||||
"high_audio": "Yüksek ses",
|
||||
"medium_audio": "Orta ses",
|
||||
"low_audio": "Düşük ses",
|
||||
"audio_only_resolution": "Sadece ses"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "Sıfırla",
|
||||
"download": "İndir",
|
||||
"pause": "Duraklat",
|
||||
"resume": "Devam Et",
|
||||
"cancel": "İptal",
|
||||
"browse": "Gözat",
|
||||
"clear": "Temizle",
|
||||
"ok": "Tamam",
|
||||
"apply": "Uygula",
|
||||
"close": "Kapat",
|
||||
"run_command": "Komutu çalıştır",
|
||||
"custom_command_help": "Yardım",
|
||||
"about": "Hakkında",
|
||||
"analyze": "Analiz Et",
|
||||
"paste_url": "URL Yapıştır",
|
||||
"select_videos": "Video seç...",
|
||||
"video": "Video",
|
||||
"audio_only": "Sadece ses",
|
||||
"custom_options": "Özel seçenekler",
|
||||
"trim_video": "Videoyu kırp",
|
||||
"download_settings": "İndirme ayarları",
|
||||
"update": "yt-dlp'yi güncelle",
|
||||
"select_defaults": "Varsayılanları seç",
|
||||
"select_all": "Tümünü seç",
|
||||
"deselect_all": "Tüm seçimi kaldır",
|
||||
"open_folder": "Klasör konumunu aç",
|
||||
"history": "Geçmiş",
|
||||
"save_playlist": "Oynatma Listesini Farklı Kaydet"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Özel seçenekler",
|
||||
"settings": "Ayarlar",
|
||||
"select_folder": "İndirme klasörünü seç",
|
||||
"sponsorblock_categories": "SponsorBlock kategorileri",
|
||||
"sponsorblock_description": "İndirme sırasında otomatik olarak kaldırılacak video bölümlerinin türlerini seçin.\nSponsorBlock bu bölümleri belirlemek için topluluk tarafından gönderilen verileri kullanır.",
|
||||
"select_subtitles": "Altyazı seç",
|
||||
"filter_languages_placeholder": "Dilleri filtrele (örn: tr, en)...",
|
||||
"no_subtitles_available": "Altyazı mevcut değil",
|
||||
"matching": "eşleşen",
|
||||
"ytdlp_log_title": "yt-dlp Günlüğü",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Çerezlerle giriş yap",
|
||||
"custom_command": "Özel komut",
|
||||
"proxy": "Proxy",
|
||||
"language": "Dil",
|
||||
"updater": "Güncelleyici"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "Kimlik doğrulama için çerezlerin nasıl sağlanacağını seçin.\nBu, özel videoları ve yüksek kaliteli ses dosyalarını indirmeyi sağlar.",
|
||||
"cookie_source": "Çerez kaynağı",
|
||||
"use_cookie_file": "Çerez dosyası kullan",
|
||||
"extract_from_browser": "Tarayıcıdan çıkar",
|
||||
"recommended": "Önerilen",
|
||||
"cookie_file": "Çerez dosyası",
|
||||
"cookie_file_placeholder": "cookies.txt dosya yolu...",
|
||||
"browser_selection": "Tarayıcı seçimi",
|
||||
"browser_help": "Çerezleri çıkaracak tarayıcıyı seçin:",
|
||||
"browser_label": "Tarayıcı:",
|
||||
"profile_label": "Profil:",
|
||||
"profile_placeholder": "Varsayılan",
|
||||
"browser_extract_message": "Tarayıcı çerezleri uygulandığında çıkarılacak",
|
||||
"file_selected_message": "Çerez dosyası seçildi - Uygulamak için Tamam'a tıklayın",
|
||||
"select_file_title": "Çerez dosyası seç",
|
||||
"file_filter": "Çerez dosyaları (*.txt *.lwp)",
|
||||
"file_selected_title": "Çerez Dosyası Uygulandı",
|
||||
"file_applied_message": "Çerez dosyası uygulandı: {path}",
|
||||
"browser_selected_title": "Tarayıcı Çerezleri Uygulandı",
|
||||
"browser_applied_message": "Tarayıcı çerezleri şuradan çıkarılacak: {browser}",
|
||||
"cleared_title": "Çerezler Temizlendi",
|
||||
"cleared_message": "Çerez ayarları temizlendi",
|
||||
"active_browser": "✓ Etkin: Tarayıcı çerezleri ({browser})",
|
||||
"active_file": "✓ Etkin: Çerez dosyası ({file})",
|
||||
"none_active": "○ Etkin çerez yok",
|
||||
"remember_settings": "Sonraki başlangıçta çerez ayarlarını hatırla"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "Özel yt-dlp komutunuzu aşağıya girin. Mevcut URL otomatik olarak eklenecektir.<br><br>Seçeneklerin tam listesi ve kullanım örnekleri için <a href=\"{docs_url}\">resmi yt-dlp belgelerini görmek için buraya tıklayın</a>.<br><br>Not: İndirme yolu ve dosya adı şablonu otomatik olarak işlenir.",
|
||||
"input_label": "yt-dlp argümanları:",
|
||||
"input_placeholder": "yt-dlp argümanlarını buraya girin...\n\nÖrnek: --extract-audio --audio-format mp3",
|
||||
"output_label": "Komut çıktısı:",
|
||||
"output_placeholder": "Komut çıktısı burada görünecek...",
|
||||
"command_placeholder": "Özel yt-dlp komutunu girin...",
|
||||
"command_help": "Mevcut yer tutucular:\n{url} - Video URL'si\n{output} - Çıktı klasörü\n\nÖrnek: --write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 Tam komut: {command}",
|
||||
"command_success": "✅ Özel komut başarıyla çalıştırıldı!",
|
||||
"command_failed": "❌ Komut {code} çıkış koduyla başarısız oldu",
|
||||
"command_error": "❌ Özel komut çalıştırılırken hata: {error}",
|
||||
"error_no_url": "❌ Hata: URL sağlanmadı. Lütfen ana pencerede bir URL girin.",
|
||||
"error_no_command": "❌ Hata: Komut sağlanmadı. Lütfen yt-dlp argümanlarını girin.",
|
||||
"executing": "🚀 Özel yt-dlp komutu çalıştırılıyor",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Argümanlar: {command}",
|
||||
"download_path_label": "📁 İndirme yolu: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "İndirmeler için proxy ayarlarını yapılandırın. Doğrudan bağlantı için boş bırakın.",
|
||||
"main_proxy": "Ana proxy",
|
||||
"main_proxy_help": "Tüm indirmeler için birincil proxy sunucusu. HTTP/HTTPS ve SOCKS5 protokollerini destekler.",
|
||||
"proxy_url_label": "Proxy URL:",
|
||||
"proxy_url_placeholder": "http://proxy:port veya socks5://proxy:port",
|
||||
"proxy_examples": "Örnekler:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "Coğrafi atlama proxy'si",
|
||||
"geo_proxy_help": "Coğrafi kısıtlamaları atlamak için ikincil proxy.",
|
||||
"geo_proxy_url_label": "Coğrafi atlama proxy URL'si:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "Ana proxy'yi temizle",
|
||||
"clear_geo_proxy": "Coğrafi proxy'yi temizle",
|
||||
"proxy_url": "Proxy URL",
|
||||
"proxy_placeholder": "http://proxy:port veya socks5://proxy:port",
|
||||
"geo_bypass": "Coğrafi atlama proxy'si (coğrafi kısıtlamalar için)",
|
||||
"geo_bypass_placeholder": "http://proxy:port coğrafi engellemeyi atlamak için",
|
||||
"invalid_main_url": "Geçersiz ana proxy URL formatı",
|
||||
"invalid_geo_url": "Geçersiz coğrafi proxy URL formatı",
|
||||
"main_configured": "Ana proxy yapılandırıldı",
|
||||
"geo_configured": "Coğrafi proxy yapılandırıldı",
|
||||
"set_title": "Proxy ayarlandı",
|
||||
"set_message": "Ana proxy ayarlandı ve kaydedildi: {proxy}",
|
||||
"geo_set_title": "Coğrafi proxy ayarlandı",
|
||||
"geo_set_message": "Coğrafi doğrulama proxy'si ayarlandı ve kaydedildi: {proxy}",
|
||||
"cleared_title": "Proxy ayarları temizlendi",
|
||||
"cleared_message": "Tüm proxy ayarları temizlendi ve kaydedildi.",
|
||||
"saved_main": "Saved main proxy: {proxy}",
|
||||
"saved_geo": "Saved geo proxy: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "İndirme hazırlanıyor...",
|
||||
"starting": "🚀 İndirme başlatılıyor...",
|
||||
"fetching_info": "🔍 Video bilgileri alınıyor...",
|
||||
"preparing_streams": "🎯 Video akışları hazırlanıyor...",
|
||||
"downloading_audio": "⏬ Ses indiriliyor...",
|
||||
"downloading_video": "⏬ Video indiriliyor...",
|
||||
"downloading_subtitle": "⏬ Altyazı indiriliyor...",
|
||||
"downloading": "⏬ İndiriliyor...",
|
||||
"completed": "✅ İndirme tamamlandı!",
|
||||
"video_completed": "✅ Video indirmesi tamamlandı!",
|
||||
"audio_completed": "✅ Ses indirmesi tamamlandı!",
|
||||
"subtitle_completed": "✅ Altyazı indirmesi tamamlandı!",
|
||||
"completed_cleaning": "✅ İndirme tamamlandı! Temizleniyor...",
|
||||
"cancelled": "İndirme iptal edildi",
|
||||
"processing_playlist": "📋 Oynatma listesi verileri işleniyor...",
|
||||
"paused": "İndirme duraklatıldı",
|
||||
"resumed": "İndirme devam ettirildi",
|
||||
"merging_formats": "✨ İşleme sonrası: Formatlar birleştiriliyor...",
|
||||
"removing_sponsor_segments": "✨ İşleme sonrası: Sponsor segmentleri kaldırılıyor...",
|
||||
"speed": "Hız",
|
||||
"eta": "Kalan süre",
|
||||
"please_enter_url": "Lütfen önce bir URL girin.",
|
||||
"please_set_path": "Lütfen önce indirme yolunu ayarlayın.",
|
||||
"please_enter_url_and_path": "Lütfen bir URL girin ve indirme yolunu ayarlayın.",
|
||||
"please_select_format": "Lütfen önce bir format seçin.",
|
||||
"downloading_fallback": "⚡ İndiriliyor..."
|
||||
},
|
||||
"update": {
|
||||
"title": "yt-dlp'yi güncelle",
|
||||
"checking": "Güncellemeler kontrol ediliyor...",
|
||||
"update_available": "Güncelleme mevcut!\nMevcut sürüm: {current}\nEn son sürüm: {latest}",
|
||||
"up_to_date": "yt-dlp güncel (Sürüm {version})",
|
||||
"could_not_determine": "Sürümler belirlenemedi.",
|
||||
"error_comparing": "Sürüm karşılaştırma hatası: {error}",
|
||||
"update_available_failed": "Güncelleme mevcut! (Karşılaştırma başarısız)\nMevcut: {current}\nEn son: {latest}",
|
||||
"updating": "Güncelleniyor...",
|
||||
"initializing": "🚀 Güncelleme işlemi başlatılıyor...",
|
||||
"checking_current": "🔍 Mevcut kurulum kontrol ediliyor...",
|
||||
"found_at": "📍 yt-dlp bulundu: {path}",
|
||||
"error_getting_path": "❌ yt-dlp yolu alma hatası: {error}",
|
||||
"updating_binary": "📦 Uygulama yönetimli yt-dlp binary'si güncelleniyor...",
|
||||
"update_failed": "❌ yt-dlp güncellemesi başarısız. Lütfen tekrar deneyin veya internet bağlantınızı kontrol edin.",
|
||||
"binary_updated": "✅ Binary başarıyla güncellendi!",
|
||||
"update_failed_stderr": "❌ yt-dlp güncellemesi başarısız: {error}",
|
||||
"update_timeout": "❌ yt-dlp güncelleme zaman aşımı.",
|
||||
"unexpected_error": "❌ Güncelleme sırasında beklenmeyen hata: {error}",
|
||||
"already_up_to_date": "✅ yt-dlp zaten güncel!",
|
||||
"update_success": "✅ yt-dlp başarıyla güncellendi!",
|
||||
"already_latest": "yt-dlp güncel (sürüm {version})",
|
||||
"network_error": "❌ Güncelleme sırasında ağ hatası: {error}",
|
||||
"general_error": "❌ Güncelleme başarısız: {error}",
|
||||
"update_in_progress_title": "Güncelleme Devam Ediyor",
|
||||
"update_in_progress_message": "yt-dlp şu anda güncelleniyor. Lütfen bir dakika bekleyin."
|
||||
},
|
||||
"about": {
|
||||
"title": "YTSage Hakkında",
|
||||
"version": "Sürüm {version}",
|
||||
"description": "Temiz PySide6 arayüzü ile modern YouTube indiricisi.",
|
||||
"author": "Yapımcı: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "Sistem bilgileri",
|
||||
"loading": "🔄 Sistem bilgileri yükleniyor...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 Kayıtlar",
|
||||
"logs_tooltip": "Uygulama kayıt klasörünü aç",
|
||||
"refreshing": "🔄 Yenileniyor...",
|
||||
"refresh_failed": "Yenileme başarısız",
|
||||
"refresh_failed_message": "Sürüm bilgileri yenilenemedi.",
|
||||
"detected": "✓ Algılandı",
|
||||
"missing": "✗ Eksik",
|
||||
"not_available": "Mevcut değil"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "Videoyu kırp",
|
||||
"time_range_group": "Zaman aralığı",
|
||||
"start_time": "Başlama zamanı (SS:DD:SS)",
|
||||
"end_time": "Bitiş zamanı (SS:DD:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "Başlama zamanı (SS:DD:SS)",
|
||||
"end_time_placeholder": "Bitiş zamanı (SS:DD:SS)",
|
||||
"force_keyframes": "Kesim noktalarında anahtar kareleri zorla",
|
||||
"help_text": "Videoyu kırpmak için başlama ve bitiş zamanlarını ayarlayın.\nTam videoyu indirmek için boş bırakın.",
|
||||
"invalid_format": "Geçersiz zaman formatı. SS:DD:SS formatını kullanın.",
|
||||
"start_after_end": "Başlama zamanı bitiş zamanından sonra olamaz."
|
||||
},
|
||||
"settings": {
|
||||
"title": "İndirme ayarları",
|
||||
"download_path": "İndirme yolu",
|
||||
"browse": "Gözat...",
|
||||
"speed_limit": "Hız sınırı",
|
||||
"speed_limit_placeholder": "Yok",
|
||||
"generic_mode": "Genel mod",
|
||||
"enable_generic_mode": "Genel modu etkinleştir (YouTube dışı siteleri destekle)",
|
||||
"generic_mode_help": "Dailymotion, CBC Gem ve yt-dlp tarafından desteklenen diğer sitelerden indirmeye izin verir.",
|
||||
"auto_update_ytdlp": "yt-dlp otomatik güncelleme",
|
||||
"enable_auto_updates": "yt-dlp otomatik güncellemelerini etkinleştir",
|
||||
"update_frequency": "Güncelleme sıklığı:",
|
||||
"check_startup": "Her başlangıçta kontrol et (kontroller arası en az 1 saat)",
|
||||
"check_daily": "Günlük kontrol et",
|
||||
"check_weekly": "Haftalık kontrol et",
|
||||
"check_updates_now": "yt-dlp'yi güncelle",
|
||||
"update_check_title": "Güncelleme kontrolü",
|
||||
"could_not_determine_version": "Mevcut yt-dlp sürümü belirlenemedi.",
|
||||
"update_available_dialog": "Güncelleme mevcut!\n\nMevcut: {current}\nEn son: {latest}\n\nGüncellemeye devam etmek için Tamam'a tıklayın.",
|
||||
"up_to_date_dialog": "yt-dlp güncel!\n\nMevcut sürüm: {version}",
|
||||
"error_checking_updates": "Güncelleme kontrol hatası: {error}",
|
||||
"settings_saved_title": "Ayarlar kaydedildi",
|
||||
"settings_saved_message": "Otomatik güncelleme ayarları başarıyla kaydedildi!",
|
||||
"error_title": "Hata",
|
||||
"failed_save_settings": "Otomatik güncelleme ayarları kaydedilemedi.",
|
||||
"error_saving_settings": "Otomatik güncelleme ayarları kaydetme hatası: {error}",
|
||||
"ytdlp_channel": "yt-dlp Sürüm Kanalı",
|
||||
"ytdlp_channel_stable": "Kararlı (Test edilmiş sürümler)",
|
||||
"ytdlp_channel_nightly": "Nightly (Günlük güncellemeler, yt-dlp tarafından önerilir)",
|
||||
"ytdlp_channel_description": "Kararlı ve nightly sürümler arasında seçim yapın. Nightly derlemeleri en son düzeltmeler ve özelliklerle günlük olarak güncellenir. İstediğiniz zaman kanal değiştirebilirsiniz.",
|
||||
"ytdlp_switching_channel": "{channel} kanalına geçiliyor...",
|
||||
"ytdlp_channel_switched": "✅ Başarıyla {channel} kanalına geçildi!",
|
||||
"ytdlp_channel_switch_failed": "❌ Kanal değiştirilemedi: {error}",
|
||||
"ytdlp_current_channel": "Mevcut kanal: {channel}",
|
||||
"app_updates_title": "YTSage Güncellemeleri",
|
||||
"check_app_updates": "Başlangıçta YTSage güncellemelerini kontrol et",
|
||||
"check_beta_updates": "Beta Güncellemelerini Al",
|
||||
"auto_update_title": "Otomatik güncelleme ayarları",
|
||||
"auto_update_header": "🔄 Otomatik güncelleme ayarları",
|
||||
"auto_update_description": "En son özelliklere ve hata düzeltmelerine sahip olmak için yt-dlp otomatik güncellemelerini yapılandırın.",
|
||||
"current_status": "Mevcut durum",
|
||||
"current_version_label": "Mevcut yt-dlp sürümü: Kontrol ediliyor...",
|
||||
"last_check_label": "Son güncelleme kontrolü: Hiç",
|
||||
"next_check_label": "Sonraki kontrol: Ayarlara göre",
|
||||
"manual_check_button": "🔍 Şimdi güncellemeleri kontrol et",
|
||||
"update_frequency_group": "Güncelleme sıklığı",
|
||||
"save_settings": "Ayarları kaydet",
|
||||
"settings_saved_successfully": "✅ Ayarlar başarıyla kaydedildi!",
|
||||
"error_saving": "❌ Ayarları kaydetme hatası: {error}",
|
||||
"output_format_settings": "Çıktı Formatı Ayarları",
|
||||
"force_output_format": "Birleştirirken çıktı formatını zorla",
|
||||
"preferred_format": "Tercih edilen format:",
|
||||
"force_format_help": "Etkinleştirildiğinde, birleştirilen videolar tercih ettiğiniz formata dönüştürülür. yt-dlp'nin otomatik karar vermesini sağlamak için devre dışı bırakın.",
|
||||
"format_mp4": "MP4 (En uyumlu)",
|
||||
"format_webm": "WebM (Modern, açık)",
|
||||
"format_mkv": "MKV (Özellik açısından zengin)",
|
||||
"audio_format_settings": "Ses Formatı Ayarları",
|
||||
"force_audio_format": "Yalnızca ses indirmeleri için ses formatını zorla",
|
||||
"audio_normalization": "Ses Normalizasyonu (EBU R128)",
|
||||
"audio_normalization_help": "Etkinleştirildiğinde ses parçaları normalleştirilecektir. Not: Bu, yeniden kodlamayı gerektirir, bu nedenle belirli bir ses formatı (MP3 veya M4A gibi) zorunlu kılınmalıdır.",
|
||||
"preferred_audio_format": "Tercih edilen ses formatı:",
|
||||
"force_audio_format_help": "Etkinleştirildiğinde, yalnızca ses indirmeleri tercih ettiğiniz formata dönüştürülecektir. Bu yalnızca ses formatları indirilirken geçerlidir.",
|
||||
"audio_format_best": "En iyi (Dönüştürme yok)",
|
||||
"audio_format_aac": "AAC (Düzenleme için iyi)",
|
||||
"audio_format_mp3": "MP3 (Evrensel)",
|
||||
"audio_format_flac": "FLAC (Kayıpsız)",
|
||||
"audio_format_wav": "WAV (Sıkıştırılmamış)",
|
||||
"audio_format_opus": "Opus (Verimli)",
|
||||
"audio_format_m4a": "M4A (Apple)",
|
||||
"audio_format_vorbis": "Vorbis (Açık)",
|
||||
"filename_format": "Çıktı Dosya Adı Formatı",
|
||||
"filename_format_help": "Kullanılabilir değişkenler: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. Standart yt-dlp çıktı şablonu sözdizimi desteklenir.",
|
||||
"tab_general": "Genel",
|
||||
"tab_format": "Biçim",
|
||||
"tab_file": "Dosya",
|
||||
"concurrent_fragments": "Eşzamanlı Bağlantılar",
|
||||
"concurrent_fragments_help": "İndirme başına bağlantı sayısı. Daha yüksek değerler sınırlamayı atlar ancak çok yüksek ayarlanırsa geçici engellemelere neden olabilir. Varsayılan: 1.",
|
||||
"defaults_settings": "Varsayılan Seçim Ayarları",
|
||||
"default_video_quality": "Varsayılan Video Çözünürlüğü (Yükseklik):",
|
||||
"default_subtitle_language": "Varsayılan Altyazı Dili/Dilleri:",
|
||||
"defaults_help": "Tercih ettiğiniz video yüksekliğini ve altyazı dillerini (virgülle ayırarak) ayarlayın. Varsa otomatik olarak seçileceklerdir."
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "YouTube video veya oynatma listesi URL'sini girin",
|
||||
"url_placeholder_generic": "Desteklenen herhangi bir siteden video veya oynatma listesi URL'si girin",
|
||||
"merge_subtitles": "Altyazıları birleştir",
|
||||
"save_thumbnail": "Küçük resmi kaydet",
|
||||
"save_description": "Açıklamayı kaydet",
|
||||
"embed_chapters": "Bölümleri göm",
|
||||
"subtitles_selected": "{count} seçildi",
|
||||
"all_selected": "Tümü seçildi",
|
||||
"select_videos_all": "Video seç... (Tümü seçildi)",
|
||||
"please_enter_url": "Lütfen önce bir URL girin",
|
||||
"error_title": "Hata",
|
||||
"cookie_file_selected_title": "Çerez dosyası seçildi",
|
||||
"cookie_file_selected_message": "Çerez dosyası seçildi: {path}",
|
||||
"browser_cookies_selected_title": "Tarayıcı çerezleri seçildi",
|
||||
"browser_cookies_selected_message": "Tarayıcı çerezleri şuradan çıkarılacak: {browser}",
|
||||
"error_no_format_info": "Hata: Format bilgisi mevcut değil.",
|
||||
"error_extract_info": "Hata: Temel video bilgileri çıkarılamadı. Lütfen bağlantınızı kontrol edin.",
|
||||
"analyzing_preparing": "İstek hazırlanıyor...",
|
||||
"analyzing_extracting_basic": "Temel bilgiler çıkarılıyor...",
|
||||
"analyzing_extracting_detailed": "Ayrıntılı bilgiler çıkarılıyor...",
|
||||
"analyzing_processing_video": "Video verisi işleniyor...",
|
||||
"analyzing_processing_formats": "Formatlar işleniyor...",
|
||||
"analyzing_loading_thumbnail": "Küçük resim yükleniyor...",
|
||||
"analyzing_processing_subtitles": "Altyazılar işleniyor...",
|
||||
"analyzing_updating_table": "Format tablosu güncelleniyor...",
|
||||
"analysis_complete": "Analiz tamamlandı!",
|
||||
"analyzing_fetching_first_video": "İlk video için formatlar alınıyor...",
|
||||
"analyzing_extracting_ytdlp": "Bilgiler çıkarılıyor...",
|
||||
"analyzing_processing_data": "Veriler işleniyor...",
|
||||
"analyzing_processing_formats_ytdlp": "Formatlar işleniyor...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "Küçük resim yükleniyor...",
|
||||
"analyzing_processing_subtitles_ytdlp": "Altyazılar işleniyor...",
|
||||
"select_subtitles": "Altyazı seç...",
|
||||
"sponsorblock_categories": "SponsorBlock kategorileri...",
|
||||
"invalid_url_or_enter": "Geçersiz URL veya lütfen bir URL girin.",
|
||||
"zero_selected": "0 seçildi",
|
||||
"analyze_first_tooltip": "Lütfen önce videoyu analiz edin",
|
||||
"audio_mode_disabled": "Yalnızca ses modunda kullanılamaz",
|
||||
"select_subtitles_first": "Lütfen önce altyazıları seçin",
|
||||
"settings_tooltip": "Mevcut Yol: {path}\nHız Sınırı: {speed_limit}",
|
||||
"speed_limit_none": "Yok",
|
||||
"open_folder_error": "Klasör açılamadı: {error}",
|
||||
"time_range_set": "Bölüm ayarlandı: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "Sponsor",
|
||||
"sponsor_desc": "Ücretli reklamlar, ücretli öneriler ve doğrudan reklamlar",
|
||||
"selfpromo": "Ücretsiz/Kendi tanıtımı",
|
||||
"selfpromo_desc": "Yaratıcının kendi içeriği için ücretsiz tanıtım",
|
||||
"interaction": "Etkileşim hatırlatıcısı",
|
||||
"interaction_desc": "İzleyicilerden beğeni, abone olma veya sosyal medya takibi istenir",
|
||||
"intro": "Giriş",
|
||||
"intro_desc": "Atlanabilir video girişi",
|
||||
"outro": "Son/Son kartları",
|
||||
"outro_desc": "Jenerik veya video bittiğinde",
|
||||
"preview": "Önizleme/Özet",
|
||||
"preview_desc": "Önceki videoların kısa özeti veya gelecek içeriğin önizlemesi",
|
||||
"music_offtopic": "Müzik dışı bölüm",
|
||||
"music_offtopic_desc": "Sadece müzik videoları için. Müzik dışı bölümleri işaretler",
|
||||
"filler": "Dolgu teğet",
|
||||
"filler_desc": "Sadece dolgu veya mizah için eklenen teğet sahneler"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "Kanal",
|
||||
"views": "👁 Görüntülenme",
|
||||
"likes": "👍 Beğeni",
|
||||
"upload_date": "📅 Yüklenme tarihi",
|
||||
"duration": "⏱ Süre",
|
||||
"unknown_channel": "Bilinmeyen kanal",
|
||||
"unknown_date": "Bilinmeyen tarih",
|
||||
"unknown_title": "Bilinmeyen başlık"
|
||||
},
|
||||
"command": {
|
||||
"running": "Çalışıyor...",
|
||||
"run_command": "Komutu çalıştır"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "0 seçildi",
|
||||
"one_selected": "1 kategori seçildi",
|
||||
"count_selected": "{count} seçildi"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Hazır",
|
||||
"file_exists": "⚠️ Dosya zaten mevcut",
|
||||
"video_file_exists": "⚠️ Video dosyası zaten mevcut",
|
||||
"audio_file_exists": "⚠️ Ses dosyası zaten mevcut",
|
||||
"subtitle_file_exists": "⚠️ Altyazı dosyası zaten mevcut",
|
||||
"cancelling": "İndirme iptal ediliyor...",
|
||||
"thumbnail_saved": "✅ Küçük resim kaydedildi: {filename}",
|
||||
"thumbnail_error": "❌ Küçük resim hatası: {error}",
|
||||
"thumbnail_no_image": "Kaydedilecek küçük resim yok"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "Hata: Oynatma listesi geçerli video içermiyor.",
|
||||
"playlist_no_url": "Hata: Oynatma listesinin ilk videosu için URL alınamadı.",
|
||||
"ytdlp_not_found": "Hata: yt-dlp çalıştırılabilir dosyası bulunamadı. Lütfen önce yt-dlp'yi yükleyin.",
|
||||
"ytdlp_not_found_path": "Hata: yt-dlp çalıştırılabilir dosyası bulunamadı. Bu, hatalı kurulum veya PATH sorunu nedeniyle olabilir.",
|
||||
"no_data_returned": "Hata: yt-dlp'den veri döndürülmedi",
|
||||
"no_format_info": "Hata: Kullanılabilir format bilgisi yok.",
|
||||
"analysis_timeout": "Hata: Analiz zaman aşımına uğradı. Lütfen tekrar deneyin.",
|
||||
"invalid_speed_limit": "❌ Hata: Ayarlarda geçersiz hız sınırı değeri ayarlandı.",
|
||||
"ytdlp_failed": "Hata: yt-dlp başarısız oldu: {error}",
|
||||
"parse_failed": "Hata: yt-dlp çıktısı ayrıştırılamadı: {error}",
|
||||
"analysis_failed": "Hata: Analiz başarısız oldu: {error}",
|
||||
"generic_error": "Hata: {error}",
|
||||
"download_failed_return_code_conflict": "İndirme {return_code} dönüş koduyla başarısız oldu. Bunun nedeni birden fazla yt-dlp kurulumunun çakışması olabilir. Sistem kurulumlu yt-dlp'yi (örn. snap veya apt) kaldırıp uygulamayı yeniden başlatmayı deneyin.",
|
||||
"download_failed_return_code": "İndirme {return_code} dönüş koduyla başarısız oldu",
|
||||
"direct_command_error": "Doğrudan komutta hata: {error}",
|
||||
"private_video": "Bu video gizli olabilir. Lütfen özel seçeneklerden çerezleri kullanın."
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Güncelleme Mevcut",
|
||||
"new_version_available": "YTSage'in yeni bir sürümü mevcut!",
|
||||
"current_version_label": "Mevcut sürüm:",
|
||||
"latest_version_label": "En son sürüm:",
|
||||
"changelog": "Değişiklik günlüğü",
|
||||
"download_update": "Güncellemeyi İndir",
|
||||
"remind_later": "Daha Sonra Hatırlat"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Bilinmeyen Oynatma Listesi",
|
||||
"total_videos": "Toplam Video: {count}",
|
||||
"display_format": "Oynatma Listesi: {title} | {count} video",
|
||||
"select_videos_title": "Oynatma Listesi Videolarını Seç",
|
||||
"save_as": "Oynatma Listesini Farklı Kaydet",
|
||||
"save_success_title": "Başarılı",
|
||||
"saved_successfully": "Oynatma listesi başarıyla kaydedildi.",
|
||||
"save_error_title": "Kaydetme Hatası",
|
||||
"no_videos_to_save": "Hiçbir oynatma listesi girdisi toplanmadı!",
|
||||
"save_error_msg": "Oynatma listesi kaydedilemedi."
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "{count} seçildi"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "Dosya zaten mevcut",
|
||||
"message": "Dosya zaten mevcut:\n{filename}",
|
||||
"info": "Bu video zaten indirilmiş."
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "Son güncelleme kontrolü: Hiç",
|
||||
"last_check": "Son güncelleme kontrolü: {time}",
|
||||
"next_check_disabled": "Sonraki kontrol: Devre dışı",
|
||||
"next_check_startup": "Sonraki kontrol: Başlangıçta",
|
||||
"next_check_overdue": "Sonraki kontrol: Şimdi (gecikmiş)",
|
||||
"next_check": "Sonraki kontrol: {time}",
|
||||
"next_check_error": "Sonraki kontrol: Hesaplama hatası",
|
||||
"checking": "🔄 Kontrol ediliyor...",
|
||||
"check_now": "🔍 Şimdi güncellemeleri kontrol et",
|
||||
"current_version": "Mevcut yt-dlp sürümü: {version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL boş olamaz",
|
||||
"invalid_format": "Geçersiz URL formatı",
|
||||
"invalid_scheme": "URL http:// veya https:// ile başlamalıdır",
|
||||
"missing_domain": "Geçersiz URL: alan adı eksik",
|
||||
"unsupported_platform": "YTSage yalnızca YouTube ve YouTube Music URL'lerini destekler.\n'{domain}' alan adı desteklenmiyor.",
|
||||
"invalid_youtu_be": "Geçersiz youtu.be URL'si: video ID'si eksik"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "Bu özel bir video. Çerezleri kullanarak hesabınıza giriş yaparak indirebilirsiniz.\nKimlik doğrulamak için 'Özel Seçenekler' → 'Çerezlerle Giriş' → 'Tarayıcıdan çerezleri çıkar' bölümüne gidin.",
|
||||
"age_restricted": "Bu video yaş kısıtlamalı. Erişmek için giriş yapmanız gerekiyor.\nHesabınızla kimlik doğrulamak için 'Özel Seçenekler' → 'Çerezlerle Giriş' kullanın.",
|
||||
"geo_blocked": "Bu video bölgenizde kullanılamıyor (coğrafi engel).\nBir VPN kullanmanız gerekebilir veya video ülkenizde kısıtlanmış olabilir.",
|
||||
"video_unavailable": "Bu video kaldırıldı veya artık kullanılamıyor.\nVideo, yükleyici tarafından silinmiş veya politika ihlalleri nedeniyle kaldırılmış olabilir.",
|
||||
"live_stream": "Bu, aktifken indirilemeyen canlı bir yayındır.\nYayın bitene kadar bekleyin, ardından arşivlenmiş sürümü indirmeyi deneyin.",
|
||||
"playlist_error": "Bu oynatma listesine erişilemiyor. Özel, silinmiş veya boş olabilir.\nOynatma listesinin var olduğunu ve herkese açık olduğunu kontrol edin.",
|
||||
"network_error": "Ağ bağlantı hatası. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin.\nSorun devam ederse, video sunucusu geçici olarak kullanılamıyor olabilir.",
|
||||
"invalid_url": "Geçersiz veya desteklenmeyen URL. Lütfen bağlantıyı kontrol edin ve tekrar deneyin.\nGeçerli bir YouTube, Vimeo veya desteklenen başka bir platform URL'si kullandığınızdan emin olun.",
|
||||
"premium_content": "Bu içerik YouTube Premium veya kanal üyeliği gerektirir.\nBu içeriğe erişimi olan bir hesapla giriş yapmanız gerekiyor.",
|
||||
"copyright_blocked": "Bu video telif hakkı iddiaları nedeniyle engellenmiş.\nİçerik sahibi bu videoya erişimi kısıtlamış.",
|
||||
"extraction_failed": "Video bilgileri çıkarılamadı. Bu geçici bir sorun olabilir.\nLütfen birkaç dakika içinde tekrar deneyin veya video bağlantısının doğru olup olmadığını kontrol edin.",
|
||||
"generic_error": "Video bilgileri çıkarılamadı. Lütfen bağlantınızı kontrol edin.\nTeknik ayrıntılar: {error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "İndirme Geçmişi",
|
||||
"clear_all": "Tümünü Temizle",
|
||||
"clear_confirm_title": "Geçmişi Temizle?",
|
||||
"clear_confirm_message": "Emin misiniz? Bu geri alınamaz.",
|
||||
"no_history": "Henüz geçmiş yok",
|
||||
"no_history_description": "İndirmeleriniz burada görünecek",
|
||||
"search_placeholder": "Ara...",
|
||||
"open_location": "Konumu Aç",
|
||||
"redownload": "Tekrar İndir",
|
||||
"remove": "Kaldır",
|
||||
"file_not_found": "Dosya bulunamadı",
|
||||
"file_not_found_message": "Dosya taşındı veya silindi:\n{path}",
|
||||
"downloaded_on": "İndirildi: {date}",
|
||||
"file_size": "Boyut: {size}",
|
||||
"audio_download": "Ses",
|
||||
"video_download": "Video",
|
||||
"remove_confirm_title": "Kaldır?",
|
||||
"remove_confirm_message": "Geçmişten kaldır?\n\n{title}",
|
||||
"item_removed": "Kaldırıldı",
|
||||
"history_cleared": "Geçmiş temizlendi",
|
||||
"entries_count": "{count} indirme",
|
||||
"one_entry": "1 indirme",
|
||||
"redownload_confirm_title": "Tekrar İndir?",
|
||||
"redownload_confirm_message": "Tekrar indir?\n\n{title}",
|
||||
"redownload_started": "İndirme başladı",
|
||||
"no_url_error": "Geçmiş kaydında URL bulunamadı",
|
||||
"redownload_failed": "Yeniden indirme başlatılamadı: {error}",
|
||||
"loading": "Loading history..."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "FFmpeg Kurulumu",
|
||||
"installation_message": "YTSage, videoları işlemek için FFmpeg'e ihtiyaç duyar.\n\nAşağıdan bir kurulum seçeneği seçin:",
|
||||
"install_button": "FFmpeg Kur",
|
||||
"manual_guide": "Manuel Kılavuz",
|
||||
"installation_failed": "FFmpeg kurulumu bir sorunla karşılaştı.",
|
||||
"already_installed": "FFmpeg zaten yüklü!",
|
||||
"installation_complete": "Kurulum tamamlandı. Bu iletişim kutusunu kapatabilir ve YTSage'i kullanmaya devam edebilirsiniz.",
|
||||
"installing": "FFmpeg kuruluyor... Lütfen bekleyin",
|
||||
"install_success": "FFmpeg başarıyla kuruldu!",
|
||||
"installation_complete_close": "Kurulum tamamlandı. Artık bu iletişim kutusunu kapatabilir ve YTSage'i kullanmaya devam edebilirsiniz.",
|
||||
"try_manual": "Lütfen bunun yerine manuel kurulum kılavuzunu kullanmayı deneyin."
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "yt-dlp Kurulumu Gerekli",
|
||||
"description": "YTSage, video indirmek için yt-dlp'ye ihtiyaç duyar.<br><br>yt-dlp uygulamanın yerel dizininde bulunamadı. YTSage'in {os_name} sisteminiz için yt-dlp'yi kurması gerekiyor.<br><br>Lütfen aşağıdan bir seçenek seçin:",
|
||||
"option_auto": "Otomatik indir (Önerilen)",
|
||||
"option_manual": "Yolu manuel seç",
|
||||
"setup_button": "yt-dlp Kur",
|
||||
"downloading": "yt-dlp indiriliyor...",
|
||||
"success": "yt-dlp başarıyla yüklendi!",
|
||||
"error": "Hata: {error}",
|
||||
"download_failed_title": "İndirme Başarısız",
|
||||
"download_failed_message": "yt-dlp indirilemedi: {error}",
|
||||
"select_executable_title": "yt-dlp çalıştırılabilir dosyasını seç",
|
||||
"copied_to": "yt-dlp başarıyla {path} konumuna kopyalandı",
|
||||
"setup_error_title": "Kurulum Hatası",
|
||||
"copy_error": "yt-dlp uygulama dizinine kopyalanırken hata: {error}",
|
||||
"invalid_executable_title": "Geçersiz Çalıştırılabilir",
|
||||
"invalid_executable_message": "Seçilen dosya geçerli bir yt-dlp çalıştırılabilir dosyası değil.",
|
||||
"verify_error": "yt-dlp çalıştırılabilir dosyası doğrulanırken hata: {error}",
|
||||
"setup_failed_title": "Kurulum Başarısız",
|
||||
"setup_failed_message": "yt-dlp kurulumu başarısız oldu. Bazı özellikler düzgün çalışmayabilir.",
|
||||
"success_dialog_title": "yt-dlp Kurulumu",
|
||||
"success_dialog_message": "yt-dlp başarıyla şu konuma yapılandırıldı:\n{path}",
|
||||
"file_filter_windows": "Çalıştırılabilir Dosyalar (*.exe)",
|
||||
"file_filter_all": "Tüm Dosyalar (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "FFmpeg Sürüm Denetçisi",
|
||||
"current_version": "Mevcut Sürüm:",
|
||||
"latest_version": "En Son Sürüm:",
|
||||
"status_up_to_date": "✓ Güncel",
|
||||
"status_update_available": "⚠ Güncelleme mevcut",
|
||||
"status_not_installed": "✗ Yüklü değil",
|
||||
"status_idle": "Başlamak için 'Sürümü Kontrol Et'e tıklayın",
|
||||
"status_checking": "🔄 Sürüm kontrol ediliyor...",
|
||||
"check_updates": "Sürümü Kontrol Et",
|
||||
"check_failed": "Sürüm kontrolu başarısız oldu. İnternet bağlantınızı kontrol edin.",
|
||||
"description": "FFmpeg sürümünüzü kontrol edin ve mevcut en son sürümle karşılaştırın.",
|
||||
"guide_info": " FFmpeg'i yüklemek veya güncellemek için, <a href='https://github.com/oop7/ffmpeg-install-guide'>kapsamlı kurulum kılavuzumuzu görüntülemek için buraya tıklayın</a>."
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "Deno Kurulumu Gerekli",
|
||||
"setup_description": "YTSage, belirli özellikleri çalıştırmak için Deno gerektirir.<br><br>Deno, uygulamadaki yerel dizinde bulunamadı. YTSage'in {os_name} sisteminiz için Deno kurması gerekiyor.<br><br>Otomatik olarak indirmek ve yüklemek için 'Deno Kur'a tıklayın.",
|
||||
"setup_button": "Deno Kur",
|
||||
"downloading": "Deno indiriliyor...",
|
||||
"extracting": "Deno çıkarılıyor...",
|
||||
"verifying": "Deno doğrulanıyor...",
|
||||
"success": "Deno başarıyla yüklendi!",
|
||||
"download_failed": "İndirme Başarısız",
|
||||
"download_error": "Deno indirilemedi: {error}",
|
||||
"verification_failed": "SHA256 doğrulama başarısız oldu. İndirilen dosya bozuk veya kurcalanmış olabilir.",
|
||||
"setup_failed": "Deno kurulamadı. Bazı özellikler düzgün çalışmayabilir.",
|
||||
"setup_error": "Kurulum Hatası"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Deno Sürüm Denetleyici ve Güncelleyici",
|
||||
"description": "Deno sürümünüzü kontrol edin ve en son sürüme güncelleyin.",
|
||||
"current_version": "Geçerli Sürüm:",
|
||||
"latest_version": "En Son Sürüm:",
|
||||
"status_idle": "Başlamak için 'Güncellemeleri Kontrol Et'e tıklayın",
|
||||
"status_checking": "🔄 Güncellemeler kontrol ediliyor...",
|
||||
"status_up_to_date": "✓ Güncel",
|
||||
"status_update_available": "⚠ Güncelleme mevcut",
|
||||
"status_not_installed": "✗ Yüklü değil",
|
||||
"check_updates": "Güncellemeleri Kontrol Et",
|
||||
"update_now": "Deno'yu Güncelle",
|
||||
"updating": "🔄 Deno güncelleniyor...",
|
||||
"update_success": "✅ Deno başarıyla güncellendi!",
|
||||
"update_failed": "❌ Güncelleme başarısız: {error}",
|
||||
"check_failed": "Güncellemeler kontrol edilemedi. Lütfen internet bağlantınızı kontrol edin."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"language": {
|
||||
"display_name": "中文 (简体) (Chinese Simplified)",
|
||||
"select_language": "选择语言:",
|
||||
"current_language": "当前语言:{language}",
|
||||
"restart_required": "语言更改将在重启应用程序后生效。",
|
||||
"english": "英语",
|
||||
"spanish": "西班牙语",
|
||||
"portuguese": "葡萄牙语",
|
||||
"russian": "俄语",
|
||||
"chinese": "中文",
|
||||
"german": "德语",
|
||||
"french": "法语",
|
||||
"hindi": "印地语",
|
||||
"indonesian": "印尼语",
|
||||
"turkish": "土耳其语",
|
||||
"polish": "波兰语",
|
||||
"italian": "意大利语",
|
||||
"arabic": "阿拉伯语",
|
||||
"japanese": "日语",
|
||||
"help_text": "选择您喜欢的界面语言。",
|
||||
"restart_notice": "语言更改将在重启应用程序后生效。"
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"version": "v{version}",
|
||||
"ready": "准备就绪"
|
||||
},
|
||||
"formats": {
|
||||
"show_formats": "显示格式:",
|
||||
"video_format": "视频格式:",
|
||||
"audio_format": "音频格式:",
|
||||
"no_formats": "无可用格式",
|
||||
"loading": "正在加载格式...",
|
||||
"select": "选择",
|
||||
"quality": "质量",
|
||||
"extension": "扩展名",
|
||||
"resolution": "分辨率",
|
||||
"file_size": "文件大小",
|
||||
"codec": "编解码器",
|
||||
"audio": "音频",
|
||||
"fps": "帧率",
|
||||
"hdr": "HDR",
|
||||
"will_merge_audio": "将合并音频",
|
||||
"has_audio": "✓ 有音频",
|
||||
"audio_only": "仅音频",
|
||||
"best_4k": "最佳 (4K)",
|
||||
"best_2k": "最佳 (2K)",
|
||||
"high_1080p": "高清 (1080p)",
|
||||
"high_720p": "高清 (720p)",
|
||||
"medium_480p": "中等 (480p)",
|
||||
"low_quality": "低质量",
|
||||
"best_audio": "最佳音频",
|
||||
"high_audio": "高质量音频",
|
||||
"medium_audio": "中等音频",
|
||||
"low_audio": "低质量音频",
|
||||
"audio_only_resolution": "仅音频"
|
||||
},
|
||||
"buttons": {
|
||||
"reset": "重置",
|
||||
"download": "下载",
|
||||
"pause": "暂停",
|
||||
"resume": "恢复",
|
||||
"cancel": "取消",
|
||||
"browse": "浏览",
|
||||
"clear": "清除",
|
||||
"ok": "确定",
|
||||
"apply": "应用",
|
||||
"close": "关闭",
|
||||
"run_command": "运行命令",
|
||||
"custom_command_help": "帮助",
|
||||
"about": "关于",
|
||||
"analyze": "分析",
|
||||
"paste_url": "粘贴网址",
|
||||
"select_videos": "选择视频...",
|
||||
"video": "视频",
|
||||
"audio_only": "仅音频",
|
||||
"custom_options": "自定义选项",
|
||||
"trim_video": "裁剪视频",
|
||||
"download_settings": "下载设置",
|
||||
"update": "更新 yt-dlp",
|
||||
"select_defaults": "选择默认值",
|
||||
"select_all": "全选",
|
||||
"deselect_all": "取消全选",
|
||||
"open_folder": "打开文件夹位置",
|
||||
"history": "历史",
|
||||
"save_playlist": "保存播放列表为"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "自定义选项",
|
||||
"settings": "设置",
|
||||
"select_folder": "选择下载文件夹",
|
||||
"sponsorblock_categories": "SponsorBlock 分类",
|
||||
"sponsorblock_description": "选择在下载期间自动删除的视频片段类型。\nSponsorBlock 使用社区提交的数据来识别这些片段。",
|
||||
"select_subtitles": "选择字幕",
|
||||
"filter_languages_placeholder": "过滤语言(例如:en, zh)...",
|
||||
"no_subtitles_available": "无可用字幕",
|
||||
"matching": "匹配",
|
||||
"ytdlp_log_title": "yt-dlp 日志",
|
||||
"filter_playlist_placeholder": "Filter videos..."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "使用 Cookie 登录",
|
||||
"custom_command": "自定义命令",
|
||||
"proxy": "代理",
|
||||
"language": "语言",
|
||||
"updater": "更新器"
|
||||
},
|
||||
"cookies": {
|
||||
"help_text": "选择如何提供 cookie 来登录。\n这允许下载私人视频和高级质量音频。",
|
||||
"cookie_source": "Cookie 来源",
|
||||
"use_cookie_file": "使用 cookie 文件",
|
||||
"extract_from_browser": "从浏览器提取",
|
||||
"recommended": "推荐",
|
||||
"cookie_file": "Cookie 文件",
|
||||
"cookie_file_placeholder": "cookies.txt 文件路径...",
|
||||
"browser_selection": "浏览器选择",
|
||||
"browser_help": "选择要从中提取 cookie 的浏览器:",
|
||||
"browser_label": "浏览器:",
|
||||
"profile_label": "配置文件:",
|
||||
"profile_placeholder": "默认",
|
||||
"browser_extract_message": "应用时将提取浏览器 cookie",
|
||||
"file_selected_message": "已选择 Cookie 文件 - 点击确定应用",
|
||||
"select_file_title": "选择 Cookie 文件",
|
||||
"file_filter": "Cookie 文件 (*.txt *.lwp)",
|
||||
"file_selected_title": "已应用 Cookie 文件",
|
||||
"file_applied_message": "已应用 Cookie 文件:{path}",
|
||||
"browser_selected_title": "已应用浏览器 Cookie",
|
||||
"browser_applied_message": "将从以下位置提取浏览器 cookie:{browser}",
|
||||
"cleared_title": "已清除 Cookie",
|
||||
"cleared_message": "Cookie 设置已被清除",
|
||||
"active_browser": "✓ Active: Browser cookies ({browser})",
|
||||
"active_file": "✓ Active: Cookie file ({file})",
|
||||
"none_active": "○ No cookies active",
|
||||
"remember_settings": "在下次启动时记住 cookie 设置"
|
||||
},
|
||||
"custom_command": {
|
||||
"help_text": "在下面输入您的自定义 yt-dlp 命令。当前网址将自动附加。<br><br>有关选项和使用示例的完整列表,<a href=\"{docs_url}\">点击此处查看官方 yt-dlp 文档</a>。<br><br>注意:下载路径和文件名模板将自动处理。",
|
||||
"input_label": "yt-dlp 参数:",
|
||||
"input_placeholder": "在此输入 yt-dlp 参数...\n\n例如:--extract-audio --audio-format mp3",
|
||||
"output_label": "命令输出:",
|
||||
"output_placeholder": "命令输出将显示在此处...",
|
||||
"command_placeholder": "输入自定义 yt-dlp 命令...",
|
||||
"command_help": "可用占位符:\n{url} - 视频网址\n{output} - 输出目录\n\n示例:--write-info-json --write-thumbnail",
|
||||
"full_command": "🔧 完整命令:{command}",
|
||||
"command_success": "✅ 自定义命令执行成功!",
|
||||
"command_failed": "❌ 命令失败,退出代码 {code}",
|
||||
"command_error": "❌ 执行自定义命令时出错:{error}",
|
||||
"error_no_url": "❌ Error: No URL provided. Please enter a URL in the main window.",
|
||||
"error_no_command": "❌ Error: No command provided. Please enter yt-dlp arguments.",
|
||||
"executing": "🚀 Executing custom yt-dlp command",
|
||||
"url_label": "📍 URL: {url}",
|
||||
"args_label": "⚙️ Arguments: {command}",
|
||||
"download_path_label": "📁 Download path: {path}",
|
||||
"separator": "=================================================="
|
||||
},
|
||||
"proxy": {
|
||||
"help_text": "配置下载的代理设置。留空使用直接连接。",
|
||||
"main_proxy": "主代理",
|
||||
"main_proxy_help": "所有下载的主代理服务器。支持 HTTP/HTTPS 和 SOCKS5 协议。",
|
||||
"proxy_url_label": "代理网址:",
|
||||
"proxy_url_placeholder": "http://proxy:port 或 socks5://proxy:port",
|
||||
"proxy_examples": "示例:\n• HTTP:http://proxy.example.com:8080\n• SOCKS5:socks5://proxy.example.com:1080",
|
||||
"geo_proxy": "地理绕过代理",
|
||||
"geo_proxy_help": "专门用于绕过地理限制的辅助代理。",
|
||||
"geo_proxy_url_label": "地理绕过代理网址:",
|
||||
"geo_proxy_url_placeholder": "http://geo-proxy:port",
|
||||
"clear_main_proxy": "清除主代理",
|
||||
"clear_geo_proxy": "清除地理代理",
|
||||
"proxy_url": "代理网址",
|
||||
"proxy_placeholder": "http://proxy:port 或 socks5://proxy:port",
|
||||
"geo_bypass": "地理绕过代理(用于地理限制)",
|
||||
"geo_bypass_placeholder": "http://proxy:port 用于绕过地理封锁",
|
||||
"invalid_main_url": "主代理网址格式无效",
|
||||
"invalid_geo_url": "地理代理网址格式无效",
|
||||
"main_configured": "主代理已配置",
|
||||
"geo_configured": "地理代理已配置",
|
||||
"set_title": "已设置代理",
|
||||
"set_message": "主代理已设置并保存: {proxy}",
|
||||
"geo_set_title": "已设置地理代理",
|
||||
"geo_set_message": "地理验证代理已设置并保存: {proxy}",
|
||||
"cleared_title": "代理设置已清除",
|
||||
"cleared_message": "所有代理设置已清除并保存。",
|
||||
"saved_main": "Saved main proxy: {proxy}",
|
||||
"saved_geo": "Saved geo proxy: {proxy}"
|
||||
},
|
||||
"download": {
|
||||
"preparing": "正在准备下载...",
|
||||
"starting": "🚀 正在开始下载...",
|
||||
"fetching_info": "🔍 正在获取视频信息...",
|
||||
"preparing_streams": "🎯 正在准备视频流...",
|
||||
"downloading_audio": "⏬ 正在下载音频...",
|
||||
"downloading_video": "⏬ 正在下载视频...",
|
||||
"downloading_subtitle": "⏬ 正在下载字幕...",
|
||||
"downloading": "⏬ 正在下载...",
|
||||
"completed": "✅ 下载完成!",
|
||||
"video_completed": "✅ 视频下载完成!",
|
||||
"audio_completed": "✅ 音频下载完成!",
|
||||
"subtitle_completed": "✅ 字幕下载完成!",
|
||||
"completed_cleaning": "✅ 下载完成!正在清理...",
|
||||
"cancelled": "下载已取消",
|
||||
"processing_playlist": "📋 正在处理播放列表数据...",
|
||||
"paused": "下载已暂停",
|
||||
"resumed": "下载已恢复",
|
||||
"merging_formats": "✨ 后处理:合并格式...",
|
||||
"removing_sponsor_segments": "✨ 后处理:移除赞助片段...",
|
||||
"speed": "速度",
|
||||
"eta": "预计时间",
|
||||
"please_enter_url": "请先输入网址。",
|
||||
"please_set_path": "请先设置下载路径。",
|
||||
"please_enter_url_and_path": "请输入网址并设置下载路径。",
|
||||
"please_select_format": "请先选择格式。",
|
||||
"downloading_fallback": "⚡ 正在下载..."
|
||||
},
|
||||
"update": {
|
||||
"title": "更新 yt-dlp",
|
||||
"checking": "正在检查更新...",
|
||||
"update_available": "有可用更新!\n当前版本:{current}\n最新版本:{latest}",
|
||||
"up_to_date": "yt-dlp 已是最新版本(版本 {version})",
|
||||
"could_not_determine": "无法确定版本。",
|
||||
"error_comparing": "比较版本时出错:{error}",
|
||||
"update_available_failed": "有可用更新!(比较失败)\n当前:{current}\n最新:{latest}",
|
||||
"updating": "正在更新...",
|
||||
"initializing": "🚀 初始化更新过程...",
|
||||
"checking_current": "🔍 检查当前安装...",
|
||||
"found_at": "📍 在以下位置找到 yt-dlp:{path}",
|
||||
"error_getting_path": "❌ 获取 yt-dlp 路径时出错:{error}",
|
||||
"updating_binary": "📦 正在更新应用程序管理的 yt-dlp 二进制文件...",
|
||||
"update_failed": "❌ 更新 yt-dlp 失败。请重试或检查您的互联网连接。",
|
||||
"binary_updated": "✅ 二进制文件更新成功!",
|
||||
"update_failed_stderr": "❌ yt-dlp 更新失败:{error}",
|
||||
"update_timeout": "❌ yt-dlp 更新超时。",
|
||||
"unexpected_error": "❌ 更新期间出现意外错误:{error}",
|
||||
"already_up_to_date": "✅ yt-dlp已是最新版本!",
|
||||
"update_success": "✅ yt-dlp 已成功更新!",
|
||||
"already_latest": "yt-dlp 是最新版本(版本 {version})",
|
||||
"network_error": "❌ 更新期间网络错误:{error}",
|
||||
"general_error": "❌ 更新失败:{error}",
|
||||
"update_in_progress_title": "正在更新",
|
||||
"update_in_progress_message": "yt-dlp 正在更新中。请稍候。"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于 YTSage",
|
||||
"version": "版本 {version}",
|
||||
"description": "具有简洁 PySide6 界面的现代 YouTube 下载器。",
|
||||
"author": "作者:{author}",
|
||||
"github": "GitHub:{repo}",
|
||||
"system_info": "系统信息",
|
||||
"loading": "🔄 正在加载系统信息...",
|
||||
"refresh": "🔄",
|
||||
"open_logs": "📂 日志",
|
||||
"logs_tooltip": "打开应用程序日志文件夹",
|
||||
"refreshing": "🔄 正在刷新...",
|
||||
"refresh_failed": "刷新失败",
|
||||
"refresh_failed_message": "无法刷新版本信息。",
|
||||
"detected": "✓ 已检测到",
|
||||
"missing": "✗ 缺失",
|
||||
"not_available": "不可用"
|
||||
},
|
||||
"time_range": {
|
||||
"title": "裁剪视频",
|
||||
"time_range_group": "时间范围",
|
||||
"start_time": "开始时间 (HH:MM:SS)",
|
||||
"end_time": "结束时间 (HH:MM:SS)",
|
||||
"start_placeholder": "00:00:00",
|
||||
"end_placeholder": "00:00:00",
|
||||
"start_time_placeholder": "开始时间 (HH:MM:SS)",
|
||||
"end_time_placeholder": "结束时间 (HH:MM:SS)",
|
||||
"force_keyframes": "在切割点强制关键帧",
|
||||
"help_text": "设置开始和结束时间来裁剪视频。\n留空下载完整视频。",
|
||||
"invalid_format": "时间格式无效。请使用 HH:MM:SS 格式。",
|
||||
"start_after_end": "开始时间不能晚于结束时间。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "下载设置",
|
||||
"download_path": "下载路径",
|
||||
"browse": "浏览...",
|
||||
"speed_limit": "速度限制",
|
||||
"speed_limit_placeholder": "无",
|
||||
"generic_mode": "通用模式",
|
||||
"enable_generic_mode": "启用通用模式(支持非 YouTube 网站)",
|
||||
"generic_mode_help": "允许从 Dailymotion、CBC Gem 以及其他受 yt-dlp 支持的网站下载。",
|
||||
"auto_update_ytdlp": "自动更新 yt-dlp",
|
||||
"enable_auto_updates": "启用 yt-dlp 自动更新",
|
||||
"update_frequency": "更新频率:",
|
||||
"check_startup": "每次启动时检查(检查间隔最少 1 小时)",
|
||||
"check_daily": "每日检查",
|
||||
"check_weekly": "每周检查",
|
||||
"check_updates_now": "更新 yt-dlp",
|
||||
"update_check_title": "更新检查",
|
||||
"could_not_determine_version": "无法确定当前 yt-dlp 版本。",
|
||||
"update_available_dialog": "有可用更新!\\n\\n当前:{current}\\n最新:{latest}\\n\\n点击确定继续更新。",
|
||||
"up_to_date_dialog": "yt-dlp 已是最新版本!\n\n当前版本:{version}",
|
||||
"error_checking_updates": "检查更新时出错:{error}",
|
||||
"settings_saved_title": "设置已保存",
|
||||
"settings_saved_message": "自动更新设置已成功保存!",
|
||||
"error_title": "错误",
|
||||
"failed_save_settings": "保存自动更新设置失败。",
|
||||
"error_saving_settings": "保存自动更新设置时出错:{error}",
|
||||
"ytdlp_channel": "yt-dlp 发布渠道",
|
||||
"ytdlp_channel_stable": "稳定版(经过测试的版本)",
|
||||
"ytdlp_channel_nightly": "夜间版(每日更新,yt-dlp 推荐)",
|
||||
"ytdlp_channel_description": "在稳定版和夜间版之间选择。夜间构建版每天更新,包含最新修复和功能。您可以随时切换渠道。",
|
||||
"ytdlp_switching_channel": "正在切换到 {channel} 渠道...",
|
||||
"ytdlp_channel_switched": "✅ 成功切换到 {channel} 渠道!",
|
||||
"ytdlp_channel_switch_failed": "❌ 切换渠道失败:{error}",
|
||||
"ytdlp_current_channel": "当前渠道:{channel}",
|
||||
"app_updates_title": "YTSage 更新",
|
||||
"check_app_updates": "启动时检查 YTSage 更新",
|
||||
"check_beta_updates": "接收测试版更新",
|
||||
"auto_update_title": "自动更新设置",
|
||||
"auto_update_header": "🔄 自动更新设置",
|
||||
"auto_update_description": "为 yt-dlp 配置自动更新,以确保您始终拥有最新功能和错误修复。",
|
||||
"current_status": "当前状态",
|
||||
"current_version_label": "当前 yt-dlp 版本:检查中...",
|
||||
"last_check_label": "上次更新检查:从未",
|
||||
"next_check_label": "下次检查:基于设置",
|
||||
"manual_check_button": "🔍 立即检查更新",
|
||||
"update_frequency_group": "更新频率",
|
||||
"save_settings": "保存设置",
|
||||
"settings_saved_successfully": "✅ 设置保存成功!",
|
||||
"error_saving": "❌ 保存设置时出错:{error}",
|
||||
"output_format_settings": "输出格式设置",
|
||||
"force_output_format": "合并时强制输出格式",
|
||||
"preferred_format": "首选格式:",
|
||||
"force_format_help": "启用后,合并的视频将转换为您的首选格式。禁用以让 yt-dlp 自动决定。",
|
||||
"format_mp4": "MP4(最兼容)",
|
||||
"format_webm": "WebM(现代、开放)",
|
||||
"format_mkv": "MKV(功能丰富)",
|
||||
"audio_format_settings": "音频格式设置",
|
||||
"force_audio_format": "强制仅音频下载的音频格式",
|
||||
"audio_normalization": "音频标准化 (EBU R128)",
|
||||
"audio_normalization_help": "启用后,音轨将被标准化。 注意:这需要重新编码,因此必须强制使用特定的音频格式(如 MP3 或 M4A)。",
|
||||
"preferred_audio_format": "首选音频格式:",
|
||||
"force_audio_format_help": "启用后,仅音频下载将转换为您的首选格式。这仅在下载音频格式时适用。",
|
||||
"audio_format_best": "最佳(不转换)",
|
||||
"audio_format_aac": "AAC(适合编辑)",
|
||||
"audio_format_mp3": "MP3(通用)",
|
||||
"audio_format_flac": "FLAC(无损)",
|
||||
"audio_format_wav": "WAV(未压缩)",
|
||||
"audio_format_opus": "Opus(高效)",
|
||||
"audio_format_m4a": "M4A(Apple)",
|
||||
"audio_format_vorbis": "Vorbis(开放)",
|
||||
"filename_format": "输出文件名格式",
|
||||
"filename_format_help": "可用变量: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s。支持标准的 yt-dlp 输出模板语法。",
|
||||
"tab_general": "一般",
|
||||
"tab_format": "格式",
|
||||
"tab_file": "文件",
|
||||
"concurrent_fragments": "并发连接数",
|
||||
"concurrent_fragments_help": "每个下载的连接数。较高的值可以绕过限速,但如果设置得太高,可能会导致临时被封。默认值:1。",
|
||||
"defaults_settings": "默认选择设置",
|
||||
"default_video_quality": "默认视频分辨率 (高度):",
|
||||
"default_subtitle_language": "默认字幕语言:",
|
||||
"defaults_help": "设置您首选的视频高度和字幕语言(用逗号分隔)。如果可用,它们将被自动选择。"
|
||||
},
|
||||
"main_ui": {
|
||||
"url_placeholder": "输入 YouTube 视频或播放列表网址",
|
||||
"url_placeholder_generic": "输入任意受支持网站的视频或播放列表网址",
|
||||
"merge_subtitles": "合并字幕",
|
||||
"save_thumbnail": "保存缩略图",
|
||||
"save_description": "保存描述",
|
||||
"embed_chapters": "嵌入章节",
|
||||
"subtitles_selected": "已选择 {count} 个",
|
||||
"all_selected": "全部已选择",
|
||||
"select_videos_all": "选择视频...(全部已选择)",
|
||||
"please_enter_url": "请先输入网址",
|
||||
"error_title": "错误",
|
||||
"cookie_file_selected_title": "已选择 Cookie 文件",
|
||||
"cookie_file_selected_message": "已选择 Cookie 文件:{path}",
|
||||
"browser_cookies_selected_title": "已选择浏览器 Cookie",
|
||||
"browser_cookies_selected_message": "将从以下浏览器提取 Cookie:{browser}",
|
||||
"error_no_format_info": "错误:无可用格式信息。",
|
||||
"error_extract_info": "错误:无法提取基本视频信息。请检查您的链接。",
|
||||
"analyzing_preparing": "正在准备请求...",
|
||||
"analyzing_extracting_basic": "正在提取基本信息...",
|
||||
"analyzing_extracting_detailed": "正在提取详细信息...",
|
||||
"analyzing_processing_video": "正在处理视频数据...",
|
||||
"analyzing_processing_formats": "正在处理格式...",
|
||||
"analyzing_loading_thumbnail": "正在加载缩略图...",
|
||||
"analyzing_processing_subtitles": "正在处理字幕...",
|
||||
"analyzing_updating_table": "正在更新格式表...",
|
||||
"analysis_complete": "分析完成!",
|
||||
"analyzing_fetching_first_video": "正在获取第一个视频的格式...",
|
||||
"analyzing_extracting_ytdlp": "提取信息...",
|
||||
"analyzing_processing_data": "正在处理数据...",
|
||||
"analyzing_processing_formats_ytdlp": "正在处理格式...",
|
||||
"analyzing_loading_thumbnail_ytdlp": "正在加载缩略图...",
|
||||
"analyzing_processing_subtitles_ytdlp": "正在处理字幕...",
|
||||
"select_subtitles": "选择字幕...",
|
||||
"sponsorblock_categories": "SponsorBlock 分类...",
|
||||
"invalid_url_or_enter": "无效的URL或请输入URL。",
|
||||
"zero_selected": "已选择 0 个",
|
||||
"analyze_first_tooltip": "请先分析视频",
|
||||
"audio_mode_disabled": "在纯音频模式下不可用",
|
||||
"select_subtitles_first": "请先选择字幕",
|
||||
"settings_tooltip": "当前路径: {path}\n速度限制: {speed_limit}",
|
||||
"speed_limit_none": "无",
|
||||
"open_folder_error": "无法打开文件夹: {error}",
|
||||
"time_range_set": "已设置区间: {section}"
|
||||
},
|
||||
"sponsorblock": {
|
||||
"sponsor": "赞助商",
|
||||
"sponsor_desc": "付费推广、付费推荐和直接广告",
|
||||
"selfpromo": "非付费/自我推广",
|
||||
"selfpromo_desc": "创作者对自己内容的非付费推广",
|
||||
"interaction": "互动提醒",
|
||||
"interaction_desc": "要求观众点赞、订阅或关注社交媒体",
|
||||
"intro": "开头",
|
||||
"intro_desc": "可以跳过的视频开头",
|
||||
"outro": "结尾/片尾卡片",
|
||||
"outro_desc": "制作人员名单或视频结束时",
|
||||
"preview": "预览/回顾",
|
||||
"preview_desc": "对以前视频的快速回顾或即将到来内容的预览",
|
||||
"music_offtopic": "非音乐部分",
|
||||
"music_offtopic_desc": "仅适用于音乐视频。标记非音乐部分",
|
||||
"filler": "填充切线",
|
||||
"filler_desc": "仅为填充或幽默而添加的切线场景"
|
||||
},
|
||||
"video_info": {
|
||||
"channel": "频道",
|
||||
"views": "👁 观看次数",
|
||||
"likes": "👍 点赞数",
|
||||
"upload_date": "📅 上传日期",
|
||||
"duration": "⏱ 时长",
|
||||
"unknown_channel": "未知频道",
|
||||
"unknown_date": "未知日期",
|
||||
"unknown_title": "未知标题"
|
||||
},
|
||||
"command": {
|
||||
"running": "正在运行...",
|
||||
"run_command": "运行命令"
|
||||
},
|
||||
"selection": {
|
||||
"none_selected": "未选择",
|
||||
"one_selected": "已选择 1 个分类",
|
||||
"count_selected": "已选择 {count} 个"
|
||||
},
|
||||
"status": {
|
||||
"ready": "准备就绪",
|
||||
"file_exists": "⚠️ 文件已存在",
|
||||
"video_file_exists": "⚠️ 视频文件已存在",
|
||||
"audio_file_exists": "⚠️ 音频文件已存在",
|
||||
"subtitle_file_exists": "⚠️ 字幕文件已存在",
|
||||
"cancelling": "正在取消下载...",
|
||||
"thumbnail_saved": "✅ 缩略图已保存: {filename}",
|
||||
"thumbnail_error": "❌ 缩略图错误: {error}",
|
||||
"thumbnail_no_image": "没有可保存的缩略图"
|
||||
},
|
||||
"errors": {
|
||||
"playlist_no_videos": "错误:播放列表不包含有效视频。",
|
||||
"playlist_no_url": "错误:无法获取播放列表第一个视频的URL。",
|
||||
"ytdlp_not_found": "错误:未找到yt-dlp可执行文件。请先安装yt-dlp。",
|
||||
"ytdlp_not_found_path": "错误:未找到yt-dlp可执行文件。这可能是由于安装不当或PATH问题。",
|
||||
"no_data_returned": "错误:yt-dlp未返回数据",
|
||||
"no_format_info": "错误:无可用格式信息。",
|
||||
"analysis_timeout": "错误:分析超时。请重试。",
|
||||
"invalid_speed_limit": "❌ 错误:设置中设置了无效的速度限制值。",
|
||||
"ytdlp_failed": "错误:yt-dlp失败:{error}",
|
||||
"parse_failed": "错误:解析yt-dlp输出失败:{error}",
|
||||
"analysis_failed": "错误:分析失败:{error}",
|
||||
"generic_error": "错误:{error}",
|
||||
"download_failed_return_code_conflict": "下载失败,返回码 {return_code}。这可能是由于存在多个 yt-dlp 安装导致冲突。请卸载系统中安装的 yt-dlp(例如通过 snap 或 apt),然后重启应用。",
|
||||
"download_failed_return_code": "下载失败,返回码 {return_code}",
|
||||
"direct_command_error": "直接命令出错:{error}",
|
||||
"private_video": "此视频可能是私享视频。请使用自定义选项中的 cookie。"
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "有可用更新",
|
||||
"new_version_available": "YTSage 有新版本可用!",
|
||||
"current_version_label": "当前版本:",
|
||||
"latest_version_label": "最新版本:",
|
||||
"changelog": "更新日志",
|
||||
"download_update": "下载更新",
|
||||
"remind_later": "稍后提醒"
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "未知播放列表",
|
||||
"total_videos": "总视频数:{count}",
|
||||
"display_format": "播放列表:{title} | {count}个视频",
|
||||
"select_videos_title": "选择播放列表视频",
|
||||
"save_as": "保存播放列表为",
|
||||
"save_success_title": "成功",
|
||||
"saved_successfully": "播放列表已成功保存。",
|
||||
"save_error_title": "保存错误",
|
||||
"no_videos_to_save": "未收集到播放列表条目!",
|
||||
"save_error_msg": "保存播放列表失败。"
|
||||
},
|
||||
"subtitle_selection": {
|
||||
"count_selected": "已选择{count}个"
|
||||
},
|
||||
"file_exists_dialog": {
|
||||
"title": "文件已存在",
|
||||
"message": "文件已存在:\n{filename}",
|
||||
"info": "该视频已被下载。"
|
||||
},
|
||||
"auto_update": {
|
||||
"last_check_never": "上次检查更新: 从未",
|
||||
"last_check": "上次检查更新: {time}",
|
||||
"next_check_disabled": "下次检查: 已禁用",
|
||||
"next_check_startup": "下次检查: 启动时",
|
||||
"next_check_overdue": "下次检查: 现在 (已逾期)",
|
||||
"next_check": "下次检查: {time}",
|
||||
"next_check_error": "下次检查: 计算错误",
|
||||
"checking": "🔄 检查中...",
|
||||
"check_now": "🔍 立即检查更新",
|
||||
"current_version": "当前 yt-dlp 版本:{version}"
|
||||
},
|
||||
"url_validation": {
|
||||
"empty_url": "URL不能为空",
|
||||
"invalid_format": "无效的URL格式",
|
||||
"invalid_scheme": "URL必须以http://或https://开头",
|
||||
"missing_domain": "无效的URL:缺少域名",
|
||||
"unsupported_platform": "YTSage仅支持YouTube和YouTube Music的URL。\n不支持域名'{domain}'。",
|
||||
"invalid_youtu_be": "无效的youtu.be URL:缺少视频ID"
|
||||
},
|
||||
"ytdlp_errors": {
|
||||
"private_video": "这是一个私人视频。您可以通过使用Cookie登录您的账户来下载它。\n转到【自定义选项】→【使用Cookie登录】→【从浏览器提取Cookie】进行身份验证。",
|
||||
"age_restricted": "此视频有年龄限制。您需要登录才能访问它。\n使用【自定义选项】→【使用Cookie登录】以使用您的账户进行身份验证。",
|
||||
"geo_blocked": "此视频在您所在的地区不可用(地理封锁)。\n您可能需要使用VPN,或者该视频在您的国家受到限制。",
|
||||
"video_unavailable": "此视频已被删除或不再可用。\n该视频可能已被上传者删除或因违反政策而被删除。",
|
||||
"live_stream": "这是一个在活动时无法下载的直播。\n等待直播结束,然后尝试下载存档版本。",
|
||||
"playlist_error": "无法访问此播放列表。它可能是私有的、已删除或为空。\n检查播放列表是否存在并且可以公开访问。",
|
||||
"network_error": "网络连接错误。请检查您的互联网连接并重试。\n如果问题仍然存在,视频服务器可能暂时不可用。",
|
||||
"invalid_url": "无效或不支持的URL。请检查链接并重试。\n确保您使用的是有效的YouTube、Vimeo或其他支持的平台URL。",
|
||||
"premium_content": "此内容需要YouTube Premium或频道会员资格。\n您需要使用有权访问此内容的账户登录。",
|
||||
"copyright_blocked": "由于版权声明,此视频已被封锁。\n内容所有者已限制对此视频的访问。",
|
||||
"extraction_failed": "提取视频信息失败。这可能是一个临时问题。\n请在几分钟后重试,或检查视频链接是否正确。",
|
||||
"generic_error": "无法提取视频信息。请检查您的链接。\n技术详情:{error}"
|
||||
},
|
||||
"history": {
|
||||
"title": "下载历史",
|
||||
"clear_all": "清除全部",
|
||||
"clear_confirm_title": "清除历史?",
|
||||
"clear_confirm_message": "确定吗?此操作无法撤消。",
|
||||
"no_history": "暂无历史",
|
||||
"no_history_description": "下载将显示在这里",
|
||||
"search_placeholder": "搜索...",
|
||||
"open_location": "打开位置",
|
||||
"redownload": "重新下载",
|
||||
"remove": "删除",
|
||||
"file_not_found": "文件未找到",
|
||||
"file_not_found_message": "文件已移动或删除:\n{path}",
|
||||
"downloaded_on": "下载于:{date}",
|
||||
"file_size": "大小:{size}",
|
||||
"audio_download": "音频",
|
||||
"video_download": "视频",
|
||||
"remove_confirm_title": "删除?",
|
||||
"remove_confirm_message": "从历史中删除?\n\n{title}",
|
||||
"item_removed": "已删除",
|
||||
"history_cleared": "历史已清除",
|
||||
"entries_count": "{count} 个下载",
|
||||
"one_entry": "1 个下载",
|
||||
"redownload_confirm_title": "重新下载?",
|
||||
"redownload_confirm_message": "重新下载?\n\n{title}",
|
||||
"redownload_started": "已开始下载",
|
||||
"no_url_error": "历史记录中未找到 URL",
|
||||
"redownload_failed": "无法开始重新下载: {error}",
|
||||
"loading": "Loading history..."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"installation_title": "FFmpeg 安装",
|
||||
"installation_message": "YTSage 需要 FFmpeg 来处理视频。\n\n请选择下面的安装选项:",
|
||||
"install_button": "安装 FFmpeg",
|
||||
"manual_guide": "手动指南",
|
||||
"installation_failed": "FFmpeg 安装遇到问题。",
|
||||
"already_installed": "FFmpeg 已安装!",
|
||||
"installation_complete": "安装完成。您可以关闭此对话框并继续使用 YTSage。",
|
||||
"installing": "正在安装 FFmpeg... 请稍候",
|
||||
"install_success": "FFmpeg 已成功安装!",
|
||||
"installation_complete_close": "安装完成。您现在可以关闭此对话框并继续使用 YTSage。",
|
||||
"try_manual": "请尝试使用手动安装指南。"
|
||||
},
|
||||
"ytdlp_setup": {
|
||||
"required_title": "需要设置 yt-dlp",
|
||||
"description": "YTSage 需要 yt-dlp 来下载视频。<br><br>在应用本地目录中未找到 yt-dlp。YTSage 需要为你的 {os_name} 系统设置 yt-dlp。<br><br>请选择下面的选项:",
|
||||
"option_auto": "自动下载(推荐)",
|
||||
"option_manual": "手动选择路径",
|
||||
"setup_button": "设置 yt-dlp",
|
||||
"downloading": "正在下载 yt-dlp...",
|
||||
"success": "yt-dlp 已成功安装!",
|
||||
"error": "错误:{error}",
|
||||
"download_failed_title": "下载失败",
|
||||
"download_failed_message": "无法下载 yt-dlp:{error}",
|
||||
"select_executable_title": "选择 yt-dlp 可执行文件",
|
||||
"copied_to": "yt-dlp 已成功复制到 {path}",
|
||||
"setup_error_title": "设置错误",
|
||||
"copy_error": "将 yt-dlp 复制到应用目录时出错:{error}",
|
||||
"invalid_executable_title": "无效的可执行文件",
|
||||
"invalid_executable_message": "所选文件似乎不是有效的 yt-dlp 可执行文件。",
|
||||
"verify_error": "验证 yt-dlp 可执行文件时出错:{error}",
|
||||
"setup_failed_title": "设置失败",
|
||||
"setup_failed_message": "无法设置 yt-dlp。某些功能可能无法正常工作。",
|
||||
"success_dialog_title": "yt-dlp 设置",
|
||||
"success_dialog_message": "yt-dlp 已成功配置到:\n{path}",
|
||||
"file_filter_windows": "可执行文件 (*.exe)",
|
||||
"file_filter_all": "所有文件 (*)"
|
||||
},
|
||||
"ffmpeg_updater": {
|
||||
"title": "FFmpeg 版本检查器",
|
||||
"current_version": "当前版本:",
|
||||
"latest_version": "最新版本:",
|
||||
"status_up_to_date": "✓ 已是最新",
|
||||
"status_update_available": "⚠ 有可用更新",
|
||||
"status_not_installed": "✗ 未安装",
|
||||
"status_idle": "点击「检查版本」开始",
|
||||
"status_checking": "🔄 正在检查版本...",
|
||||
"check_updates": "检查版本",
|
||||
"check_failed": "检查版本失败。请检查您的网络连接。",
|
||||
"description": "检查您的 FFmpeg 版本并与最新可用版本进行比较。",
|
||||
"guide_info": " 要安装或更新 FFmpeg,<a href='https://github.com/oop7/ffmpeg-install-guide'>请点击此处查看我们的综合安装指南</a>。"
|
||||
},
|
||||
"deno": {
|
||||
"setup_required": "需要设置 Deno",
|
||||
"setup_description": "YTSage 需要 Deno 来运行某些功能。<br><br>在应用程序的本地目录中找不到 Deno。YTSage 需要为您的 {os_name} 系统设置 Deno。<br><br>点击“设置 Deno”以自动下载和安装。",
|
||||
"setup_button": "设置 Deno",
|
||||
"downloading": "正在下载 Deno...",
|
||||
"extracting": "正在解压 Deno...",
|
||||
"verifying": "正在验证 Deno...",
|
||||
"success": "Deno 已成功安装!",
|
||||
"download_failed": "下载失败",
|
||||
"download_error": "下载 Deno 失败:{error}",
|
||||
"verification_failed": "SHA256 验证失败。下载的文件可能已损坏或被篡改。",
|
||||
"setup_failed": "设置 Deno 失败。某些功能可能无法正常工作。",
|
||||
"setup_error": "设置错误"
|
||||
},
|
||||
"deno_updater": {
|
||||
"title": "Deno 版本检查器与更新器",
|
||||
"description": "检查您的 Deno 版本并更新到最新版本。",
|
||||
"current_version": "当前版本:",
|
||||
"latest_version": "最新版本:",
|
||||
"status_idle": "点击“检查更新”开始",
|
||||
"status_checking": "🔄 正在检查更新...",
|
||||
"status_up_to_date": "✓ 已是最新",
|
||||
"status_update_available": "⚠ 有可用更新",
|
||||
"status_not_installed": "✗ 未安装",
|
||||
"check_updates": "检查更新",
|
||||
"update_now": "更新 Deno",
|
||||
"updating": "🔄 正在更新 Deno...",
|
||||
"update_success": "✅ Deno 已成功更新!",
|
||||
"update_failed": "❌ 更新失败:{error}",
|
||||
"check_failed": "检查更新失败。请检查您的网络连接。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, Qt
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from .utils.ytsage_logger import logger
|
||||
from .gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
|
||||
|
||||
|
||||
def _configure_opengl() -> None:
|
||||
"""
|
||||
Must run before QApplication exists -- both settings are ignored afterwards.
|
||||
|
||||
AA_ShareOpenGLContexts puts every widget context in one sharing group, so
|
||||
a reparent no longer loses GL *resources*. It does not stop Qt destroying
|
||||
and recreating the widget's own context (MpvRenderWidget handles that);
|
||||
it removes a whole second class of failure around it.
|
||||
|
||||
The surface format is deliberately minimal. mpv renders into our FBO and
|
||||
needs no depth, stencil or alpha from the default framebuffer, and asking
|
||||
for them is what produced `OpenGL error INVALID_ENUM` on some drivers. No
|
||||
GL version or profile is requested on purpose: pinning a core profile
|
||||
breaks software and GLES stacks that libmpv would otherwise accept.
|
||||
"""
|
||||
QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
|
||||
|
||||
fmt = QSurfaceFormat()
|
||||
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
|
||||
fmt.setSwapInterval(1)
|
||||
fmt.setDepthBufferSize(0)
|
||||
fmt.setStencilBufferSize(0)
|
||||
fmt.setAlphaBufferSize(0)
|
||||
QSurfaceFormat.setDefaultFormat(fmt)
|
||||
|
||||
|
||||
def show_error_dialog(message):
|
||||
# A QMessageBox needs a live QApplication; if startup failed before (or
|
||||
# while) creating one, constructing the dialog would abort the process
|
||||
# and swallow the real error.
|
||||
if QApplication.instance() is None:
|
||||
print(f"Application Error: {message}", file=sys.stderr)
|
||||
return
|
||||
error_dialog = QMessageBox()
|
||||
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
error_dialog.setText("Application Error")
|
||||
error_dialog.setInformativeText(message)
|
||||
error_dialog.setWindowTitle("Error")
|
||||
error_dialog.exec()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
logger.info("Starting SageTube application")
|
||||
_configure_opengl()
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("SageTube")
|
||||
app.setDesktopFileName("sagetube")
|
||||
|
||||
# Before the main window: this retro-fits icons, tooltips and
|
||||
# accessible names onto every button as it is first polished.
|
||||
from .gui.ytsage_ui_polish import install as install_ui_polish
|
||||
|
||||
install_ui_polish(app)
|
||||
|
||||
window = YTSageApp() # Instantiate the main application class
|
||||
window.show()
|
||||
logger.info("Application window shown, entering main loop")
|
||||
sys.exit(app.exec())
|
||||
except Exception as e:
|
||||
logger.critical(f"Critical application error: {e}", exc_info=True)
|
||||
show_error_dialog(f"Critical error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Utility modules for YTSage.
|
||||
|
||||
This package contains shared utilities, constants, logging, and configuration management.
|
||||
"""
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Config Manager Module
|
||||
=====================
|
||||
|
||||
This module provides **thread-safe** centralized management for application
|
||||
configuration in YTSage. It handles reading, writing, and managing settings
|
||||
stored in a JSON file, with support for nested keys via dot notation.
|
||||
|
||||
Thread safety is ensured using a reentrant lock (`RLock`), so multiple threads
|
||||
can safely access or modify settings concurrently.
|
||||
|
||||
Features
|
||||
--------
|
||||
- Thread-safe operations for getting, setting, and deleting configuration values.
|
||||
- Loads settings from a JSON config file (`APP_CONFIG_FILE`).
|
||||
- Creates the config file with default values if missing or corrupt.
|
||||
- Retrieves, sets, and deletes settings using dot-separated keys.
|
||||
- Provides safe error handling with logging instead of raising exceptions.
|
||||
- Persists updates back to disk automatically.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from .ytsage_config_manager import ConfigManager
|
||||
|
||||
# Load settings (auto-loads if not already loaded)
|
||||
download_path = ConfigManager.get("download_path")
|
||||
|
||||
# Update a value
|
||||
ConfigManager.set("download_path", "D:/Downloads")
|
||||
|
||||
# Retrieve nested value
|
||||
last_check = ConfigManager.get("cached_versions.ytdlp.last_check")
|
||||
|
||||
# Delete a key
|
||||
ConfigManager.delete("cached_versions.ffmpeg.path")
|
||||
|
||||
Design Notes
|
||||
------------
|
||||
- Settings are stored in `ConfigManager.settings` (a dict).
|
||||
- Default values are defined in `ConfigManager.default_config`.
|
||||
- All modifications trigger a save (`_save`) to keep JSON in sync.
|
||||
- Logs actions and errors using the app's central logger.
|
||||
- Uses `RLock` to allow safe concurrent access from multiple threads.
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
- Any issues during file I/O (permissions, disk errors, JSON corruption)
|
||||
are caught and logged. The application continues running with defaults
|
||||
when possible.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .ytsage_constants import APP_CONFIG_FILE, USER_HOME_DIR
|
||||
from .ytsage_logger import logger
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Thread-safe configuration manager for YTSage.
|
||||
|
||||
Provides methods to load, save, get, set, and delete settings stored in a JSON file.
|
||||
Supports nested keys via dot notation and automatically persists changes.
|
||||
"""
|
||||
|
||||
_lock: threading.RLock = threading.RLock()
|
||||
_config_file: Path = APP_CONFIG_FILE
|
||||
_settings: Dict[str, Any] = {}
|
||||
_default_config: Dict[str, Any] = {
|
||||
# Bumped whenever a stored config needs rewriting rather than merely
|
||||
# merging; see _migrate(). Absent means "written before 5.4.0".
|
||||
"config_version": 2,
|
||||
"download_path": str(USER_HOME_DIR / "Downloads"),
|
||||
"generic_mode": True,
|
||||
"speed_limit_value": None,
|
||||
"speed_limit_unit_index": 0,
|
||||
"cookie_source": "browser", # "browser" or "file"
|
||||
"cookie_browser": "chrome",
|
||||
"cookie_browser_profile": "",
|
||||
"cookie_file_path": None,
|
||||
"cookie_active": False, # True only if user explicitly applied cookies
|
||||
"last_used_cookie_file": None,
|
||||
"proxy_url": None,
|
||||
"geo_proxy_url": None,
|
||||
"auto_update_ytdlp": True,
|
||||
"auto_update_frequency": "daily",
|
||||
"check_app_updates": False, # fork updates come from Gitea, not the upstream PyPI package
|
||||
"check_beta_updates": False,
|
||||
"last_update_check": 0,
|
||||
"skipped_update_version": None, # set by the update dialog's "Skip this version"
|
||||
"concurrent_fragments": 1,
|
||||
"language": "en",
|
||||
"ytdlp_channel": "stable",
|
||||
"force_output_format": False,
|
||||
"preferred_output_format": "mp4",
|
||||
"force_audio_format": False,
|
||||
"preferred_audio_format": "best",
|
||||
"audio_normalization": False,
|
||||
"filename_format": "%(title)s_%(resolution)s_[%(id)s].%(ext)s",
|
||||
"window_geometry": None,
|
||||
"window_state": None,
|
||||
"cached_versions": {
|
||||
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||
},
|
||||
"advanced": {
|
||||
# Allow falling back to a system-installed yt-dlp from PATH when
|
||||
# the app-managed, SHA256-verified binary is absent
|
||||
"allow_system_ytdlp": False,
|
||||
},
|
||||
"player": {
|
||||
"default_quality": 1080, # max stream height; None = auto/best
|
||||
"volume": 100,
|
||||
"resume": "auto", # auto | off
|
||||
"source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs)
|
||||
# "auto" picks a platform list (vaapi on Linux, d3d11va on Windows,
|
||||
# videotoolbox on macOS), each ending in "no" so a broken interop
|
||||
# falls back to software rather than a black frame. Any other value
|
||||
# is passed to mpv verbatim; "no" forces software decoding.
|
||||
"hwdec": "auto",
|
||||
},
|
||||
"feed": {
|
||||
"mode": "local", # local (per-channel aggregation) | account (cookies)
|
||||
"per_channel_items": 15,
|
||||
"auto_refresh_minutes": 0, # 0 = manual refresh only
|
||||
# Opening the Feed tab refreshes channels not seen for this long.
|
||||
# 0 disables it. A separate key from auto_refresh_minutes above so
|
||||
# existing configs, which store that as 0, are not read as opting
|
||||
# out of a feature that did not exist when they were written.
|
||||
"auto_refresh_on_open_minutes": 30,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _deep_merge(cls, defaults: Dict[str, Any], stored: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively layer `stored` on top of `defaults`.
|
||||
|
||||
A shallow `dict.update()` here would be a data-loss bug: a config file
|
||||
written by an older build contains a *partial* "player" or "feed"
|
||||
object, and updating shallowly replaces the whole nested default with
|
||||
it -- so every key added since would come back missing.
|
||||
|
||||
Rules:
|
||||
- dict + dict recurse
|
||||
- anything else (lists, scalars, type mismatches) replaces wholesale
|
||||
- keys present only in `stored` are kept, never pruned; a user who
|
||||
downgrades must not have their settings destroyed by the older build
|
||||
"""
|
||||
merged = copy.deepcopy(defaults)
|
||||
for key, value in stored.items():
|
||||
existing = merged.get(key)
|
||||
if isinstance(value, dict) and isinstance(existing, dict):
|
||||
merged[key] = cls._deep_merge(existing, value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
return merged
|
||||
|
||||
@classmethod
|
||||
def _migrate(cls, stored: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Bring a stored config up to CONFIG_VERSION in place.
|
||||
|
||||
Returns True when something changed, so the caller knows to re-save.
|
||||
Migrations must be idempotent and must never raise -- a config that
|
||||
cannot be migrated is still a config the app has to start with.
|
||||
"""
|
||||
changed = False
|
||||
version = stored.get("config_version")
|
||||
|
||||
if not isinstance(version, int) or version < 2:
|
||||
# Pre-5.4.0, and possibly inherited from an upstream YTSage
|
||||
# install. Such a file carries "check_app_updates": true, which
|
||||
# used to point at PyPI's `ytsage` package and oop7/YTSage's
|
||||
# releases -- neither of which is this application. Dropping the
|
||||
# stored value lets SageTube's own default decide.
|
||||
if stored.pop("check_app_updates", None) is not None:
|
||||
logger.info("Config migration: cleared inherited 'check_app_updates'.")
|
||||
stored["config_version"] = 2
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
"""
|
||||
Loads configuration settings from a JSON file if it exists and is valid.
|
||||
If the file is missing or corrupt, loads default settings and creates or overwrites the config file as needed.
|
||||
Logs actions and errors during the process.
|
||||
"""
|
||||
with cls._lock:
|
||||
if cls._config_file.exists():
|
||||
try:
|
||||
with open(cls._config_file, "r", encoding="utf-8") as f:
|
||||
stored = json.load(f)
|
||||
if not isinstance(stored, dict):
|
||||
raise json.JSONDecodeError("config root is not an object", "", 0)
|
||||
|
||||
migrated = False
|
||||
try:
|
||||
migrated = cls._migrate(stored)
|
||||
except Exception as e:
|
||||
logger.exception(f"Config migration failed, continuing unmigrated: {e}")
|
||||
|
||||
# Merge on top of defaults so keys added in newer versions
|
||||
# exist without call sites needing `or <default>` fallbacks
|
||||
cls._settings = cls._deep_merge(cls._default_config, stored)
|
||||
logger.info("Config loaded from file.")
|
||||
if migrated:
|
||||
cls._save()
|
||||
except json.JSONDecodeError:
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
logger.warning("Config file corrupt, loaded defaults.")
|
||||
else:
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
cls._save()
|
||||
logger.info("Config file not found, created default config.")
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> None:
|
||||
"""
|
||||
Save current settings to JSON file.
|
||||
|
||||
Note:
|
||||
May raise exceptions if the file cannot be written due to permission issues,
|
||||
disk errors, or other I/O problems.
|
||||
"""
|
||||
with cls._lock:
|
||||
try:
|
||||
# Atomic write: a crash mid-save must not truncate the config
|
||||
tmp_file = cls._config_file.with_suffix(".json.tmp")
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cls._settings, f, indent=4)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_file, cls._config_file)
|
||||
logger.debug("Config saved to file.")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.exception(f"Failed to save config: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error while saving config: {e}")
|
||||
|
||||
@classmethod
|
||||
def get(cls, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Retrieve a configuration value using a dotted key notation.
|
||||
Args:
|
||||
key (str): The dotted key string representing the path to the desired setting (e.g., "database.host").
|
||||
Optional[Any]: The value associated with the given key, or None if the key does not exist.
|
||||
Notes:
|
||||
- If the configuration settings are not loaded, this method will load them before attempting retrieval.
|
||||
- If any part of the dotted key path is missing, None is returned and a debug message is logged.
|
||||
"""
|
||||
with cls._lock:
|
||||
if not cls._settings:
|
||||
cls._load()
|
||||
parts: list[str] = key.split(".")
|
||||
value: Any = cls._settings
|
||||
for part in parts:
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
logger.debug(f"Config key '{key}' not found.")
|
||||
return None
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def set(cls, key: str, value: Any) -> None:
|
||||
"""
|
||||
Sets a configuration value for a given key.
|
||||
If the configuration settings are not loaded, loads them first.
|
||||
Supports nested keys using dot notation (e.g., "database.host").
|
||||
Updates the configuration dictionary with the provided value,
|
||||
saves the updated settings, and logs the change.
|
||||
Args:
|
||||
key (str): The configuration key, possibly nested using dots.
|
||||
value (Any): The value to set for the specified key.
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
with cls._lock:
|
||||
if not cls._settings:
|
||||
cls._load()
|
||||
parts: list[str] = key.split(".")
|
||||
d: Dict[str, Any] = cls._settings
|
||||
for part in parts[:-1]:
|
||||
d = d.setdefault(part, {})
|
||||
d[parts[-1]] = value
|
||||
cls._save()
|
||||
logger.info(f"Config key '{key}' set to '{value}'.")
|
||||
|
||||
@classmethod
|
||||
def delete(cls, key: str) -> None:
|
||||
"""
|
||||
Deletes a configuration key from the settings.
|
||||
If the key is nested (dot-separated), traverses the settings dictionary accordingly.
|
||||
If the key exists, removes it and saves the updated settings.
|
||||
Logs the deletion or if the key was not found.
|
||||
Args:
|
||||
key (str): The dot-separated configuration key to delete.
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
with cls._lock:
|
||||
if not cls._settings:
|
||||
cls._load()
|
||||
parts: list[str] = key.split(".")
|
||||
d: Any = cls._settings
|
||||
for part in parts[:-1]:
|
||||
if part not in d:
|
||||
logger.debug(f"Config key '{key}' not found for deletion.")
|
||||
return
|
||||
d = d[part]
|
||||
if parts[-1] in d:
|
||||
d.pop(parts[-1], None)
|
||||
cls._save()
|
||||
logger.info(f"Config key '{key}' deleted.")
|
||||
else:
|
||||
logger.debug(f"Config key '{key}' not found for deletion.")
|
||||