mirror of
https://github.com/yattee/yattee.git
synced 2026-06-11 17:24:20 +00:00
Compare commits
509 Commits
1.5.2-201
...
rewrite/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71332f69f | ||
|
|
806be7d808 | ||
|
|
e4936873ca | ||
|
|
a6b95e9dad | ||
|
|
c52f035729 | ||
|
|
aa5e78a244 | ||
|
|
dac81e1ee8 | ||
|
|
6e5714dd86 | ||
|
|
82d2830208 | ||
|
|
6a343311ea | ||
|
|
100e762d4b | ||
|
|
4935fbdb83 | ||
|
|
c778ca5d06 | ||
|
|
d0297a5e89 | ||
|
|
06ae5ac053 | ||
|
|
d49591eaf4 | ||
|
|
c64f13a0e6 | ||
|
|
1f0f3a8cf0 | ||
|
|
aabf5313fa | ||
|
|
8a3f76bb1d | ||
|
|
42621b8193 | ||
|
|
9287f5906d | ||
|
|
9e13bffa8c | ||
|
|
80838db9cc | ||
|
|
6173f63221 | ||
|
|
b6c3f0e71b | ||
|
|
7c1549ed35 | ||
|
|
5ab9e3d5bf | ||
|
|
f80ba26277 | ||
|
|
5b9cd8c521 | ||
|
|
10bd7d09af | ||
|
|
765d322ee1 | ||
|
|
c8e716be94 | ||
|
|
9b85ae2b13 | ||
|
|
b7b7c5ac62 | ||
|
|
5d88ed9743 | ||
|
|
df0f144ced | ||
|
|
b163864628 | ||
|
|
4f763373c1 | ||
|
|
c2758b0d0c | ||
|
|
c8bb13e229 | ||
|
|
158d518e3a | ||
|
|
16477641ab | ||
|
|
823faee012 | ||
|
|
6673d478c2 | ||
|
|
51108738aa | ||
|
|
cc109043b3 | ||
|
|
39beb45cff | ||
|
|
5c7429abf3 | ||
|
|
38242edf0c | ||
|
|
411fcba037 | ||
|
|
e3f4d764cc | ||
|
|
6f8aa9a1b3 | ||
|
|
11841d7b41 | ||
|
|
fac297e4d6 | ||
|
|
93240b4314 | ||
|
|
73e3d8164b | ||
|
|
8d85749354 | ||
|
|
3b9144cd28 | ||
|
|
85223894ff | ||
|
|
6df80c0e79 | ||
|
|
fd0eab7784 | ||
|
|
6eb215f59c | ||
|
|
664eeadba2 | ||
|
|
20b88a811e | ||
|
|
a32582e171 | ||
|
|
cda983651e | ||
|
|
b23dfde602 | ||
|
|
e0ad43ca0b | ||
|
|
f804cc1521 | ||
|
|
5a839da1bd | ||
|
|
d38b781858 | ||
|
|
29900b758d | ||
|
|
b5bab10694 | ||
|
|
a2a4691957 | ||
|
|
29c67d3276 | ||
|
|
6e91069ff3 | ||
|
|
397fc46629 | ||
|
|
4d45f6870e | ||
|
|
b54c32edad | ||
|
|
3afd0bdf78 | ||
|
|
111c3d7360 | ||
|
|
f873aad9b9 | ||
|
|
bdd9f7f489 | ||
|
|
b275dbd7c0 | ||
|
|
22b9cb7135 | ||
|
|
7ff889c132 | ||
|
|
07a1e0f81d | ||
|
|
79459b8f2e | ||
|
|
a5f8bdacfb | ||
|
|
48963a9e2e | ||
|
|
72778870e1 | ||
|
|
f173dd1c39 | ||
|
|
60be0f8b53 | ||
|
|
c27fb3be34 | ||
|
|
9912327448 | ||
|
|
bb8fb28998 | ||
|
|
14b874022b | ||
|
|
fef9a07aa9 | ||
|
|
d9e4736547 | ||
|
|
49cdfb74af | ||
|
|
7d95a11286 | ||
|
|
d2b6a158db | ||
|
|
b0f9bb2229 | ||
|
|
bb9ec2fc2a | ||
|
|
d8f10e984a | ||
|
|
267f770274 | ||
|
|
508069cecf | ||
|
|
e0e1e8cbd7 | ||
|
|
8ff5eccca9 | ||
|
|
1ae73789a4 | ||
|
|
ad075319ee | ||
|
|
31b244880b | ||
|
|
88a7c713fa | ||
|
|
a3ad20fdf0 | ||
|
|
fe78261866 | ||
|
|
5e205e4a4c | ||
|
|
68890b1f8a | ||
|
|
cee2793399 | ||
|
|
cedefb5c97 | ||
|
|
181cf2f73a | ||
|
|
80942dba69 | ||
|
|
52a2a26f2f | ||
|
|
e231be9c90 | ||
|
|
e3ee528d66 | ||
|
|
796b646cb2 | ||
|
|
7c78715c32 | ||
|
|
b10dd431d1 | ||
|
|
3a45a3e28e | ||
|
|
d0b4d0e64e | ||
|
|
f60a6e3eec | ||
|
|
823b8ae686 | ||
|
|
d1010507d9 | ||
|
|
8f00fe012f | ||
|
|
13ade8aad3 | ||
|
|
096df34f64 | ||
|
|
c0184712a9 | ||
|
|
2761fcbcfb | ||
|
|
55f27e7f54 | ||
|
|
bece7b35c7 | ||
|
|
ee0666f5f7 | ||
|
|
c03cd0bd19 | ||
|
|
663e96c859 | ||
|
|
5cbcceba9a | ||
|
|
0fe7194d68 | ||
|
|
6acfff6451 | ||
|
|
fb14ed8ae9 | ||
|
|
9f86ff0667 | ||
|
|
90c88728c4 | ||
|
|
e609e48449 | ||
|
|
6eec42241d | ||
|
|
a0015086a2 | ||
|
|
2efa0708c8 | ||
|
|
abd432fd0e | ||
|
|
6090454707 | ||
|
|
42fe76836c | ||
|
|
39580d713b | ||
|
|
4c8a3ee5ba | ||
|
|
53144293c8 | ||
|
|
f2a5069cd2 | ||
|
|
060cff1449 | ||
|
|
a66b2191d1 | ||
|
|
281f5e0f13 | ||
|
|
025dc73e59 | ||
|
|
b479d63295 | ||
|
|
3126f5bc3e | ||
|
|
d903eb6920 | ||
|
|
546ecf632e | ||
|
|
a660591e8d | ||
|
|
f8ca23308d | ||
|
|
cea8fcfe64 | ||
|
|
5ef40e24bf | ||
|
|
7a55f8ac3a | ||
|
|
f302682a03 | ||
|
|
a3275f4cd7 | ||
|
|
afc4125bee | ||
|
|
87965d654d | ||
|
|
e583aa3fd7 | ||
|
|
6d3bea7678 | ||
|
|
10a27a8105 | ||
|
|
43039513c1 | ||
|
|
77d982f422 | ||
|
|
71dd956f18 | ||
|
|
e2f3107833 | ||
|
|
df232ad69a | ||
|
|
f52ece330e | ||
|
|
033c93e542 | ||
|
|
70a5375b7e | ||
|
|
65724ae201 | ||
|
|
68ab994798 | ||
|
|
a6cfccf5ed | ||
|
|
b8390577cc | ||
|
|
29e8d64c35 | ||
|
|
82a8ac2afa | ||
|
|
58f1b8c1ad | ||
|
|
a6d1c840f9 | ||
|
|
f2748bead6 | ||
|
|
f49dfd6246 | ||
|
|
9b55ee7127 | ||
|
|
fb2db35fe8 | ||
|
|
0aac9168cb | ||
|
|
6a45ed7d0f | ||
|
|
d4f8cade90 | ||
|
|
851c7e2ebf | ||
|
|
944f849929 | ||
|
|
eb697b7bbc | ||
|
|
758f4a678d | ||
|
|
c52796db75 | ||
|
|
d422bf13e5 | ||
|
|
e141a168f0 | ||
|
|
43f62d997f | ||
|
|
7067413b9b | ||
|
|
c3de87a12e | ||
|
|
4837fc6548 | ||
|
|
f2c2a86d47 | ||
|
|
29782035f7 | ||
|
|
9aeb329b64 | ||
|
|
24a728e692 | ||
|
|
bfc646a73f | ||
|
|
4c29ca9455 | ||
|
|
9260d48f4c | ||
|
|
2dcfe52bfb | ||
|
|
c7d1f1c20b | ||
|
|
f5ddcd0fa5 | ||
|
|
0d5a733b0b | ||
|
|
c7942ef555 | ||
|
|
4f9285686a | ||
|
|
d111f93462 | ||
|
|
39a04ba7a4 | ||
|
|
babaca74f2 | ||
|
|
2c49a5e65a | ||
|
|
9e95a91284 | ||
|
|
f4605e7390 | ||
|
|
8253b1a247 | ||
|
|
454f10b3ab | ||
|
|
d8722e6150 | ||
|
|
885c478857 | ||
|
|
c3a2f7a965 | ||
|
|
5417374275 | ||
|
|
debfdef26f | ||
|
|
84db5d0c42 | ||
|
|
310869fad8 | ||
|
|
58c3bdc0b6 | ||
|
|
338127c692 | ||
|
|
893878c8a3 | ||
|
|
d62ba1e143 | ||
|
|
3c7581de1a | ||
|
|
831773a609 | ||
|
|
bcb0864fca | ||
|
|
bbeb38ecf0 | ||
|
|
5ae1fc3f29 | ||
|
|
4b245ec176 | ||
|
|
b9a6d76ab3 | ||
|
|
7c28e86d96 | ||
|
|
56cd60a8ba | ||
|
|
eefd49f743 | ||
|
|
3dd4073db7 | ||
|
|
222b53d520 | ||
|
|
63f1cb1f25 | ||
|
|
aed78c13fb | ||
|
|
8cd3aca96c | ||
|
|
240cf23693 | ||
|
|
0071e1b117 | ||
|
|
00ba029a92 | ||
|
|
88b095eb32 | ||
|
|
89162741f7 | ||
|
|
9267504e26 | ||
|
|
d6d15df105 | ||
|
|
9b734f49ad | ||
|
|
4e8959d2df | ||
|
|
f3061763da | ||
|
|
a7e5ebb068 | ||
|
|
8e5947c558 | ||
|
|
21da76a9ea | ||
|
|
4edb012181 | ||
|
|
f8da242968 | ||
|
|
924f62f5ef | ||
|
|
59ccef950b | ||
|
|
acb6fb284a | ||
|
|
1e45333d1e | ||
|
|
1c18d893af | ||
|
|
8c24b12b9a | ||
|
|
64193911c7 | ||
|
|
e956075f3c | ||
|
|
54f175b294 | ||
|
|
d1e63e85a4 | ||
|
|
7c2a205e74 | ||
|
|
6298b38cba | ||
|
|
2e37873a12 | ||
|
|
013514adc3 | ||
|
|
52bb32afdf | ||
|
|
40212fb34c | ||
|
|
109d165dac | ||
|
|
c2ee65cacd | ||
|
|
c53b0b3386 | ||
|
|
bf1ed95281 | ||
|
|
03bf8d2654 | ||
|
|
aca9ab6a0b | ||
|
|
2aca95e3fa | ||
|
|
0529c9105c | ||
|
|
161be24ad3 | ||
|
|
3b605020ed | ||
|
|
ee49671ea2 | ||
|
|
7dd2ee1582 | ||
|
|
bb1e7ddd68 | ||
|
|
2f1e699623 | ||
|
|
e6834b6eff | ||
|
|
07003a36d7 | ||
|
|
e38e4cca3a | ||
|
|
904e4366fb | ||
|
|
fae390cff6 | ||
|
|
1ac4e089fc | ||
|
|
7c33f7e9f3 | ||
|
|
7cea57c343 | ||
|
|
d045a64b63 | ||
|
|
7abd3a86fc | ||
|
|
5c82c37339 | ||
|
|
e4d275ad42 | ||
|
|
2faae65e8b | ||
|
|
49a44da90e | ||
|
|
fa0536549a | ||
|
|
ef3cddefeb | ||
|
|
e3606dbb3a | ||
|
|
02de3d0bd5 | ||
|
|
41c11d8839 | ||
|
|
aaf53ef9d1 | ||
|
|
f010650e5e | ||
|
|
5f00f4934c | ||
|
|
ecaf553326 | ||
|
|
0e0922dad0 | ||
|
|
7b43184a38 | ||
|
|
307d6f7350 | ||
|
|
3666154510 | ||
|
|
37c75f25b0 | ||
|
|
f022b3dc30 | ||
|
|
11a8c79e21 | ||
|
|
425a2c590d | ||
|
|
100df744d9 | ||
|
|
d94a50f8c3 | ||
|
|
b7edbe5683 | ||
|
|
42849c1aae | ||
|
|
11f0dff4e2 | ||
|
|
a26044cc04 | ||
|
|
c978ec6b89 | ||
|
|
4a69172bed | ||
|
|
0db1c08b98 | ||
|
|
f9ecfcd3dd | ||
|
|
b9351b502c | ||
|
|
f28fdcec96 | ||
|
|
ba3da4fc03 | ||
|
|
627ee48325 | ||
|
|
f23b010241 | ||
|
|
9a5d377ae0 | ||
|
|
b789e320e0 | ||
|
|
6bdb187d18 | ||
|
|
ca36254661 | ||
|
|
3312e1df82 | ||
|
|
a484aaf889 | ||
|
|
20d0cfc0c7 | ||
|
|
7fe99b09ef | ||
|
|
78f155a3b9 | ||
|
|
6f696c9262 | ||
|
|
b38bd3f444 | ||
|
|
d8e079ac90 | ||
|
|
75812906c1 | ||
|
|
82570b7f34 | ||
|
|
e43eddc8e7 | ||
|
|
c5137a8af8 | ||
|
|
9177abb0ec | ||
|
|
65e86d30ec | ||
|
|
0c4609bcf1 | ||
|
|
36190e62f5 | ||
|
|
e6e69eb757 | ||
|
|
41a33634ee | ||
|
|
aa703f6531 | ||
|
|
db80b6adbb | ||
|
|
6591d503d4 | ||
|
|
1eba731283 | ||
|
|
0913c6d73c | ||
|
|
997de6468d | ||
|
|
1397a2fee6 | ||
|
|
660891f2a5 | ||
|
|
2e27dcd2cf | ||
|
|
5f53e53c7a | ||
|
|
73295e726a | ||
|
|
b0dfd2f9d2 | ||
|
|
735e7d62b6 | ||
|
|
320c16fcc7 | ||
|
|
8c5c503df2 | ||
|
|
36738572da | ||
|
|
9a8ccc366c | ||
|
|
e9ca36f1db | ||
|
|
5b607687d9 | ||
|
|
e723bb9147 | ||
|
|
a3747a0975 | ||
|
|
bb2bd86c07 | ||
|
|
680ac9a8a0 | ||
|
|
c1b23d20f2 | ||
|
|
b8f6dabbc9 | ||
|
|
1c168bd982 | ||
|
|
42d53c30db | ||
|
|
a55adb2e65 | ||
|
|
cea296c4b7 | ||
|
|
ea0ea427e7 | ||
|
|
f685e180d0 | ||
|
|
a37f3e4a07 | ||
|
|
33377f7e0e | ||
|
|
4b577a296b | ||
|
|
e882d0264b | ||
|
|
45f72ce4a1 | ||
|
|
3536370798 | ||
|
|
a5275fd800 | ||
|
|
49278e13cd | ||
|
|
13d7a8d0a6 | ||
|
|
50efe94839 | ||
|
|
1e7656a9eb | ||
|
|
4c5b801c45 | ||
|
|
e6b6778ba1 | ||
|
|
9c15393ab4 | ||
|
|
5cfdd36237 | ||
|
|
e0cf927ebb | ||
|
|
2f0966973c | ||
|
|
460fd9cfc4 | ||
|
|
09e02477f0 | ||
|
|
349b42b2f7 | ||
|
|
e793e6c48b | ||
|
|
3588bbd7e7 | ||
|
|
21da42f23b | ||
|
|
5758417293 | ||
|
|
97ae843013 | ||
|
|
8b889da2ef | ||
|
|
25a07aa666 | ||
|
|
ada4189aea | ||
|
|
98bdd5d6a5 | ||
|
|
1fc609057e | ||
|
|
adf282d0e2 | ||
|
|
7812fc6a8d | ||
|
|
0fcdf2398e | ||
|
|
a464b15e29 | ||
|
|
bc8adc6348 | ||
|
|
caeea2a1cd | ||
|
|
ccdfdf781d | ||
|
|
c47c52f8f3 | ||
|
|
d5f9a24efa | ||
|
|
2ca08c8af5 | ||
|
|
fcb97a5591 | ||
|
|
763580203b | ||
|
|
b88169c7dd | ||
|
|
ddf997ee58 | ||
|
|
9d8fb0cfa2 | ||
|
|
a0a54bced9 | ||
|
|
6c3da98465 | ||
|
|
6aef3f10b1 | ||
|
|
500c787063 | ||
|
|
8f97c40257 | ||
|
|
b8cde410c5 | ||
|
|
6511d4c9ba | ||
|
|
b6df73f949 | ||
|
|
11a2ef207c | ||
|
|
a9fcc5ce99 | ||
|
|
7bfb212e6d | ||
|
|
e91eac0522 | ||
|
|
bec29668a0 | ||
|
|
86b74d53ca | ||
|
|
797ba61ddd | ||
|
|
2959f0b387 | ||
|
|
bf40f527ea | ||
|
|
469e9a4eb9 | ||
|
|
ce7ba207ea | ||
|
|
b0aaf0080b | ||
|
|
3c63aa51be | ||
|
|
946d4c4f16 | ||
|
|
161282af0b | ||
|
|
37c6f6abbe | ||
|
|
6129d724c5 | ||
|
|
be4e1adb9b | ||
|
|
4840c9a05f | ||
|
|
e0ca48fd44 | ||
|
|
495dcec874 | ||
|
|
8123770614 | ||
|
|
cb2d9729ea | ||
|
|
f4d4daccd0 | ||
|
|
2d73c57426 | ||
|
|
757b4cb671 | ||
|
|
73d9581449 | ||
|
|
62d5a86146 | ||
|
|
86e843305f | ||
|
|
5b18c3c114 | ||
|
|
735fc0cb6c | ||
|
|
874b976da9 | ||
|
|
de43cc8322 | ||
|
|
080af7467e | ||
|
|
2d852b38a5 | ||
|
|
f03189e973 | ||
|
|
e4e80b021b | ||
|
|
e10a7cfc41 | ||
|
|
9e0bf59774 | ||
|
|
1a36f1f338 | ||
|
|
a93e0182ca | ||
|
|
89fba29710 | ||
|
|
d567b1f03e | ||
|
|
7293969604 | ||
|
|
b981d34d28 | ||
|
|
b2d2ac143b | ||
|
|
e77d5a0a7c | ||
|
|
8655312eeb | ||
|
|
3268110913 | ||
|
|
3449b30117 | ||
|
|
d76c82eb65 |
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Yattee UI Test Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Invidious credentials for UI tests (required for import_subscriptions tests)
|
||||
INVIDIOUS_EMAIL=
|
||||
INVIDIOUS_PASSWORD=
|
||||
|
||||
# Piped credentials for UI tests (required for import tests)
|
||||
PIPED_USERNAME=
|
||||
PIPED_PASSWORD=
|
||||
|
||||
# Optional: Override default test URLs
|
||||
# YATTEE_SERVER_URL=https://yp.home.arekf.net
|
||||
# INVIDIOUS_URL=https://invidious.home.arekf.net
|
||||
# PIPED_URL=https://pipedapi.home.arekf.net
|
||||
34
.github/workflows/bump-build.yml
vendored
34
.github/workflows/bump-build.yml
vendored
@@ -1,34 +0,0 @@
|
||||
name: Bump build number
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
APP_NAME: Yattee
|
||||
|
||||
jobs:
|
||||
bump_build:
|
||||
name: Bump build number
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: bump_build
|
||||
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
token: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
branch: actions/bump-build-to-${{ env.BUILD_NUMBER }}
|
||||
base: main
|
||||
title: Bump build number to ${{ env.BUILD_NUMBER }}
|
||||
|
||||
|
||||
337
.github/workflows/release.yml
vendored
337
.github/workflows/release.yml
vendored
@@ -1,6 +1,38 @@
|
||||
name: Build and release to TestFlight and GitHub
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_ios:
|
||||
description: 'Build iOS (TestFlight)'
|
||||
type: boolean
|
||||
default: true
|
||||
build_tvos:
|
||||
description: 'Build tvOS (TestFlight)'
|
||||
type: boolean
|
||||
default: true
|
||||
build_mac_beta:
|
||||
description: 'Build macOS (TestFlight)'
|
||||
type: boolean
|
||||
default: false
|
||||
build_mac_notarized:
|
||||
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
|
||||
type: boolean
|
||||
default: true
|
||||
release_channel:
|
||||
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
|
||||
type: choice
|
||||
options:
|
||||
- beta
|
||||
- stable
|
||||
default: beta
|
||||
create_release:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
APP_NAME: Yattee
|
||||
@@ -20,85 +52,294 @@ env:
|
||||
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
strategy:
|
||||
matrix:
|
||||
# disabled mac beta lane
|
||||
# lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
lane: ['ios beta', 'tvos beta']
|
||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||
runs-on: macos-latest
|
||||
determine_build_number:
|
||||
name: Determine build number
|
||||
runs-on: macos-26
|
||||
outputs:
|
||||
build_number: ${{ steps.calc.outputs.build_number }}
|
||||
version_number: ${{ steps.version.outputs.version_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to AppStore
|
||||
run: |
|
||||
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ${{ matrix.lane }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.lane }} build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
if-no-files-found: ignore
|
||||
mac_notarized:
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-latest
|
||||
lane: latest_build_number
|
||||
- name: Calculate next build number
|
||||
id: calc
|
||||
run: |
|
||||
LATEST=$(cat latest_build_number.txt)
|
||||
NEXT=$((LATEST + 1))
|
||||
echo "build_number=$NEXT" >> $GITHUB_OUTPUT
|
||||
- name: Read version number
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -m 1 MARKETING_VERSION Yattee.xcodeproj/project.pbxproj | cut -d' ' -f3 | sed 's/;//g')
|
||||
echo "version_number=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
ios_beta:
|
||||
if: ${{ inputs.build_ios }}
|
||||
needs: [determine_build_number]
|
||||
name: Release iOS to TestFlight
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to Direct with Developer ID
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
lane: ios beta
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-beta-build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
if-no-files-found: ignore
|
||||
|
||||
tvos_beta:
|
||||
if: ${{ inputs.build_tvos }}
|
||||
needs: [determine_build_number]
|
||||
name: Release tvOS to TestFlight
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: tvos beta
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tvos-beta-build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
if-no-files-found: ignore
|
||||
|
||||
mac_beta:
|
||||
if: ${{ inputs.build_mac_beta }}
|
||||
needs: [determine_build_number]
|
||||
name: Release macOS to TestFlight
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac beta
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-beta-build
|
||||
path: fastlane/builds/**/*.pkg
|
||||
if-no-files-found: ignore
|
||||
|
||||
mac_notarized:
|
||||
if: ${{ inputs.build_mac_notarized }}
|
||||
needs: [determine_build_number]
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-26
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
- run: |
|
||||
echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- run: |
|
||||
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
- name: ZIP build
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- name: Resolve artifact paths
|
||||
run: |
|
||||
DIR="fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS"
|
||||
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
echo "DMG_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.dmg" >> $GITHUB_ENV
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac notarized build
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
name: mac-notarized-build
|
||||
path: |
|
||||
${{ env.ZIP_PATH }}
|
||||
${{ env.DMG_PATH }}
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: ['testflight', 'mac_notarized']
|
||||
if: ${{ inputs.create_release && !cancelled() && !failure() }}
|
||||
needs: [determine_build_number, ios_beta, tvos_beta, mac_beta, mac_notarized]
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
with:
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Commit build number
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
sed -i 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
|
||||
git add Yattee.xcodeproj/project.pbxproj
|
||||
git diff --cached --quiet && echo "Build number already up to date" || {
|
||||
git commit -m "Bump build number to ${{ env.BUILD_NUMBER }}"
|
||||
git push origin ${{ github.ref_name }}
|
||||
}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- name: Compute release tag
|
||||
id: compute_tag
|
||||
run: |
|
||||
if [ "$RELEASE_CHANNEL" = "beta" ]; then
|
||||
echo "tag=${VERSION_NUMBER}-beta.${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${VERSION_NUMBER}-${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip
|
||||
commit: main
|
||||
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
|
||||
prerelease: true
|
||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg,artifacts/**/*.dmg
|
||||
commit: ${{ github.ref_name }}
|
||||
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
|
||||
bodyFile: CHANGELOG.md
|
||||
|
||||
publish_appcast:
|
||||
if: ${{ inputs.build_mac_notarized && inputs.create_release && !cancelled() && !failure() }}
|
||||
needs: [determine_build_number, mac_notarized, release]
|
||||
name: Publish Sparkle appcast
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||
RELEASE_TAG: ${{ needs.release.outputs.tag }}
|
||||
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Guard — secret configured
|
||||
run: |
|
||||
if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then
|
||||
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is not set. Configure it with the base64-encoded private key exported via 'generate_keys -x'."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Download notarized mac artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mac-notarized-build
|
||||
path: mac-artifacts
|
||||
- name: Locate sign_update binary
|
||||
id: find_sign_update
|
||||
run: |
|
||||
# Sparkle's `sign_update` ships as a package artifact. We need SPM to
|
||||
# resolve the Sparkle package so the binary is present on disk.
|
||||
xcodebuild -resolvePackageDependencies -project Yattee.xcodeproj -scheme Yattee >/dev/null
|
||||
SIGN=$(find "$HOME/Library/Developer/Xcode/DerivedData" -name sign_update -type f 2>/dev/null | head -1)
|
||||
if [ -z "$SIGN" ]; then
|
||||
SIGN=$(find ~ -name sign_update -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$SIGN" ]; then
|
||||
echo "::error::Could not locate sign_update binary"
|
||||
exit 1
|
||||
fi
|
||||
echo "sign_update=$SIGN" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout gh-pages (create if missing)
|
||||
run: |
|
||||
git fetch origin gh-pages || true
|
||||
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
|
||||
git worktree add gh-pages origin/gh-pages
|
||||
else
|
||||
# First run — create orphan gh-pages with only appcast scaffolding.
|
||||
git worktree add --detach gh-pages HEAD
|
||||
cd gh-pages
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf . >/dev/null 2>&1 || true
|
||||
cp ../scripts/sparkle/appcast_template.xml appcast.xml
|
||||
cd ..
|
||||
fi
|
||||
- name: Write private key to a temp file
|
||||
id: ed_key
|
||||
run: |
|
||||
KEY_FILE=$(mktemp)
|
||||
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
|
||||
echo "path=$KEY_FILE" >> "$GITHUB_OUTPUT"
|
||||
- name: Sign update and update appcast.xml
|
||||
run: |
|
||||
ZIP=$(find mac-artifacts -name '*.zip' | head -1)
|
||||
if [ -z "$ZIP" ]; then
|
||||
echo "::error::No .zip found in mac-artifacts"
|
||||
exit 1
|
||||
fi
|
||||
./scripts/sparkle/update_appcast.rb \
|
||||
--zip "$ZIP" \
|
||||
--version "$VERSION_NUMBER" \
|
||||
--build "$BUILD_NUMBER" \
|
||||
--channel "$RELEASE_CHANNEL" \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--sign-update-bin "${{ steps.find_sign_update.outputs.sign_update }}" \
|
||||
--ed-key-file "${{ steps.ed_key.outputs.path }}" \
|
||||
--appcast gh-pages/appcast.xml \
|
||||
--repo "$REPO"
|
||||
- name: Scrub private key
|
||||
if: always()
|
||||
run: rm -f "${{ steps.ed_key.outputs.path }}"
|
||||
- name: Commit & push appcast.xml
|
||||
run: |
|
||||
cd gh-pages
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add appcast.xml
|
||||
if git diff --cached --quiet; then
|
||||
echo "No appcast changes to publish"
|
||||
else
|
||||
git commit -m "Publish Sparkle appcast: ${VERSION_NUMBER} (${BUILD_NUMBER}) [${RELEASE_CHANNEL}]"
|
||||
git push origin gh-pages
|
||||
fi
|
||||
|
||||
update_altstore:
|
||||
needs: [release]
|
||||
uses: ./.github/workflows/update-altstore.yml
|
||||
|
||||
55
.github/workflows/update-altstore.yml
vendored
Normal file
55
.github/workflows/update-altstore.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Update AltStore source
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
update_altstore:
|
||||
name: Update AltStore source
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
- name: Get version info from latest release
|
||||
run: |
|
||||
TAG=$(gh release view --json tagName --jq '.tagName')
|
||||
echo "TAG=${TAG}" >> $GITHUB_ENV
|
||||
echo "VERSION_NUMBER=${TAG%-*}" >> $GITHUB_ENV
|
||||
echo "BUILD_NUMBER=${TAG##*-}" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Get IPA size from release
|
||||
run: |
|
||||
SIZE=$(gh release view "${{ env.TAG }}" --json assets --jq '.assets[] | select(.name == "Yattee.ipa") | .size')
|
||||
echo "IPA_SIZE=${SIZE:-0}" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Update altstore-source.json
|
||||
run: |
|
||||
DATE=$(date -u +"%Y-%m-%dT%H:%M:%S+00:00")
|
||||
jq --arg version "${{ env.VERSION_NUMBER }}" \
|
||||
--arg build "${{ env.BUILD_NUMBER }}" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/yattee/yattee/releases/download/${{ env.TAG }}/Yattee.ipa" \
|
||||
--argjson size "${{ env.IPA_SIZE }}" \
|
||||
'.apps[0].versions = [{
|
||||
version: $version,
|
||||
buildVersion: $build,
|
||||
date: $date,
|
||||
localizedDescription: "",
|
||||
downloadURL: $url,
|
||||
size: $size,
|
||||
minOSVersion: "18.0"
|
||||
}] + [.apps[0].versions[] | select(.version != $version or .buildVersion != $build)]' \
|
||||
altstore-source.json > altstore-source.tmp && mv altstore-source.tmp altstore-source.json
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add altstore-source.json
|
||||
git diff --cached --quiet && echo "No changes to commit" && exit 0
|
||||
git commit -m "Update AltStore source for ${{ env.VERSION_NUMBER }} (${{ env.BUILD_NUMBER }})"
|
||||
git push
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -101,3 +101,14 @@ Xcode-config/DEVELOPMENT_TEAM.xcconfig
|
||||
# Bundler
|
||||
.bundle/
|
||||
Vendor/bundle/
|
||||
|
||||
# Code Coverage
|
||||
coverage/
|
||||
|
||||
# UI Test Snapshots (keep baseline/, ignore generated files)
|
||||
spec/ui_snapshots/current/
|
||||
spec/ui_snapshots/diff/
|
||||
|
||||
# Environment variables (contains secrets)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
3
.periphery.yml
Normal file
3
.periphery.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
project: Yattee.xcodeproj
|
||||
schemes:
|
||||
- Yattee
|
||||
120
.rubocop.yml
Normal file
120
.rubocop.yml
Normal file
@@ -0,0 +1,120 @@
|
||||
# RuboCop configuration for Yattee UI tests
|
||||
# Relaxed configuration matching existing code style
|
||||
|
||||
plugins:
|
||||
- rubocop-rspec
|
||||
|
||||
AllCops:
|
||||
TargetRubyVersion: 3.4
|
||||
NewCops: enable
|
||||
Include:
|
||||
- 'spec/**/*.rb'
|
||||
Exclude:
|
||||
- 'vendor/**/*'
|
||||
- 'Gemfile'
|
||||
|
||||
# ============================================
|
||||
# Metrics - Relaxed for UI test complexity
|
||||
# ============================================
|
||||
|
||||
# RSpec blocks are naturally long
|
||||
Metrics/BlockLength:
|
||||
Enabled: false
|
||||
|
||||
# UI test methods can be longer
|
||||
Metrics/MethodLength:
|
||||
Max: 120
|
||||
|
||||
# Classes can be larger in test support code
|
||||
Metrics/ClassLength:
|
||||
Max: 500
|
||||
|
||||
# Allow higher complexity for UI test helpers
|
||||
Metrics/AbcSize:
|
||||
Max: 100
|
||||
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 40
|
||||
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 40
|
||||
|
||||
# ============================================
|
||||
# Layout
|
||||
# ============================================
|
||||
|
||||
# Relaxed line length for readability
|
||||
Layout/LineLength:
|
||||
Max: 140
|
||||
|
||||
# ============================================
|
||||
# Naming
|
||||
# ============================================
|
||||
|
||||
# Allow methods like find_element, ensure_invidious without ? suffix
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
# Allow set_ prefix for methods like set_status_bar_overrides
|
||||
Naming/AccessorMethodName:
|
||||
Enabled: false
|
||||
|
||||
# ============================================
|
||||
# Lint
|
||||
# ============================================
|
||||
|
||||
# Allow duplicate branches in case statements (UI state machines)
|
||||
Lint/DuplicateBranch:
|
||||
Enabled: false
|
||||
|
||||
# ============================================
|
||||
# Style
|
||||
# ============================================
|
||||
|
||||
# Not needed for test files
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
||||
# Allow multi-line block chains (common in RSpec)
|
||||
Style/MultilineBlockChain:
|
||||
Enabled: false
|
||||
|
||||
# Allow single-line if statements (don't force modifier style)
|
||||
Style/IfUnlessModifier:
|
||||
Enabled: false
|
||||
|
||||
# Allow short parameter names in UI test helpers
|
||||
Naming/MethodParameterName:
|
||||
Enabled: false
|
||||
|
||||
# ============================================
|
||||
# RSpec - Relaxed for UI testing patterns
|
||||
# ============================================
|
||||
|
||||
# UI tests may need more steps
|
||||
RSpec/ExampleLength:
|
||||
Max: 30
|
||||
|
||||
# Allow before(:all) for simulator lifecycle management
|
||||
RSpec/BeforeAfterAll:
|
||||
Enabled: false
|
||||
|
||||
# Allow instance variables shared across examples (@axe, @udid)
|
||||
RSpec/InstanceVariable:
|
||||
Enabled: false
|
||||
|
||||
# UI tests often batch multiple checks
|
||||
RSpec/MultipleExpectations:
|
||||
Enabled: false
|
||||
|
||||
# Allow deeper nesting for describe/context blocks
|
||||
RSpec/NestedGroups:
|
||||
Max: 5
|
||||
|
||||
# Feature/smoke specs use string descriptions, not class names
|
||||
RSpec/DescribeClass:
|
||||
Enabled: false
|
||||
|
||||
# Allow expect in before hooks for setup verification
|
||||
RSpec/ExpectInHook:
|
||||
Enabled: false
|
||||
1
.ruby-version
Normal file
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
||||
3.4.8
|
||||
16
.slather.yml
Normal file
16
.slather.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Slather Code Coverage Configuration
|
||||
# https://github.com/SlatherOrg/slather
|
||||
|
||||
proj: Yattee.xcodeproj
|
||||
scheme: Yattee
|
||||
build-directory: DerivedData
|
||||
output-directory: coverage
|
||||
|
||||
# Coverage format (options: html, cobertura, llvm-cov, json, sonarqube, gutter-json)
|
||||
coverage_service: html
|
||||
|
||||
# Ignore patterns - adjust as needed
|
||||
ignore:
|
||||
- "**/YatteeTests/**"
|
||||
- "**/Vendor/**"
|
||||
- "**/*.generated.swift"
|
||||
@@ -1 +0,0 @@
|
||||
5
|
||||
@@ -1,2 +0,0 @@
|
||||
--disable trailingCommas
|
||||
--exclude Tests*
|
||||
@@ -1,14 +0,0 @@
|
||||
parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml
|
||||
|
||||
disabled_rules:
|
||||
- conditional_returns_on_newline
|
||||
- identifier_name
|
||||
- opening_brace
|
||||
- number_separator
|
||||
- multiline_arguments
|
||||
- implicit_return
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
- Tests iOS
|
||||
- Tests macOS
|
||||
71
AGENTS.md
Normal file
71
AGENTS.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Yattee Development Guide for AI Agents
|
||||
|
||||
## Deployment Targets
|
||||
|
||||
**iOS:** 18.0+ | **macOS:** 15.0+ | **tvOS:** 18.0+
|
||||
|
||||
This project targets the latest OS versions only - use newest APIs freely without availability checks.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
**Build:** `xcodebuild -scheme Yattee -configuration Debug`
|
||||
**Test (all):** `xcodebuild test -scheme Yattee -destination 'platform=macOS'`
|
||||
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
|
||||
**Lint:** `periphery scan` (config: `.periphery.yml`)
|
||||
|
||||
## Build Configurations
|
||||
|
||||
Three configurations exist, mapped to distribution channels:
|
||||
|
||||
| Configuration | Sparkle (`#if SPARKLE`) | Used for |
|
||||
|---|---|---|
|
||||
| `Debug` | off | local development, tests |
|
||||
| `Release` | off | App Store / TestFlight (`fastlane mac beta`) — must stay Sparkle-free, App Review rejects auto-update frameworks |
|
||||
| `Release-DeveloperID` | **on** | Developer ID notarized build (`fastlane mac build_and_notarize`), distributed via GitHub Releases + Homebrew cask, receives Sparkle updates |
|
||||
|
||||
All Sparkle-dependent code must be wrapped in `#if SPARKLE ... #endif` so the `Release` variant links zero Sparkle symbols. When adding new Sparkle features, test both configs build clean on macOS.
|
||||
|
||||
## Code Style
|
||||
|
||||
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)
|
||||
**UI:** SwiftUI with `@Observable` macro for view models (not `ObservableObject`)
|
||||
**Concurrency:** Use `actor` for services, `@MainActor` for UI-related code, `async/await` everywhere
|
||||
**Testing:** Swift Testing framework (`@Test`, `@Suite`, `#expect`) - NOT XCTest
|
||||
|
||||
## Imports & Organization
|
||||
|
||||
**Import order:** Foundation first, then SwiftUI, then @testable imports
|
||||
**File headers:** Include `// FileName.swift`, `// Yattee`, and brief comment describing purpose
|
||||
**MARK comments:** Use `// MARK: - Section Name` to organize code sections
|
||||
**Sendable:** All models, errors, and actors must conform to `Sendable`
|
||||
|
||||
## Types & Naming
|
||||
|
||||
**Models:** Immutable structs with `Codable, Hashable, Sendable` conformance
|
||||
**Services:** Use `actor` for thread-safe services, `final class` for `@Observable` view models
|
||||
**Enums:** Use associated values for typed errors (see `APIError.swift`)
|
||||
**Optionals:** Prefer guard-let unwrapping; use `if let` for simple cases
|
||||
**Naming:** camelCase for variables/functions, PascalCase for types, clear descriptive names
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Errors:** Define typed enum errors conforming to `Error, LocalizedError, Equatable, Sendable`
|
||||
**Async throws:** All async network/IO operations should throw typed errors
|
||||
**Logging:** Use `LoggingService.shared` for all logging (see `HTTPClient.swift` for patterns)
|
||||
**User feedback:** Provide localized error descriptions via `errorDescription`
|
||||
|
||||
## Testing & Debugging
|
||||
|
||||
**Add logging/visual clues** (borders, backgrounds) when debugging issues - then ask user for results
|
||||
**If first fix doesn't work:** Add debug code before second attempt to understand the issue better
|
||||
|
||||
## UI Testing (Ruby/RSpec with AXe CLI)
|
||||
|
||||
**Run UI tests:** `./bin/ui-test --skip-build --keep-simulator`
|
||||
**Run single spec:** `SKIP_BUILD=1 KEEP_SIMULATOR=1 bundle exec rspec spec/ui/smoke/search_spec.rb`
|
||||
|
||||
**Accessibility labels vs identifiers:** On iOS 26+, `.accessibilityIdentifier()` doesn't work reliably on `Group`, `ScrollView`, and some container views (AXUniqueId comes back empty). Use `.accessibilityLabel()` instead, which maps to `AXLabel` and can be detected via AXe's `text_visible?()` method.
|
||||
|
||||
**iOS 26 TabView search:** The search field is integrated into the bottom tab bar with `Tab(role: .search)`. Typing `\n` doesn't submit - use hardware key press via `press_return` (AXe key 40).
|
||||
|
||||
**ScrollView children:** Video rows inside `LazyVStack`/`ScrollView` aren't exposed in the accessibility tree. Use coordinate-based tapping instead.
|
||||
@@ -1,13 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct Backport<Content> {
|
||||
public let content: Content
|
||||
|
||||
public init(_ content: Content) {
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
var backport: Backport<Self> { Backport(self) }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func badge(_ count: Text?) -> some View {
|
||||
#if os(tvOS)
|
||||
content
|
||||
#else
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.badge(count)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func listRowSeparator(_ visible: Bool) -> some View {
|
||||
if #available(iOS 15, macOS 13, *) {
|
||||
content
|
||||
#if !os(tvOS)
|
||||
.listRowSeparator(visible ? .visible : .hidden)
|
||||
#endif
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func persistentSystemOverlays(_ visible: Bool) -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.persistentSystemOverlays(visible ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func refreshable(action: @Sendable @escaping () async -> Void) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.refreshable(action: action)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollContentBackground(_ visibility: Bool) -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollContentBackground(visibility ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollDismissesKeyboardImmediately() -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func scrollDismissesKeyboardInteractively() -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.interactively)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func tint(_ color: Color?) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.tint(color)
|
||||
} else {
|
||||
content.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarBackground(_ color: Color) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(color, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarColorScheme(colorScheme, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
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 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.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
public struct VisualEffectBlur<Content: View>: View {
|
||||
/// Defaults to .systemMaterial
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
|
||||
/// Defaults to nil
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
|
||||
var content: Content
|
||||
|
||||
public init(blurStyle: UIBlurEffect.Style = .systemMaterial, vibrancyStyle: UIVibrancyEffectStyle? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.blurStyle = blurStyle
|
||||
self.vibrancyStyle = vibrancyStyle
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(blurStyle: blurStyle, vibrancyStyle: vibrancyStyle, content: ZStack { content })
|
||||
.accessibility(hidden: Content.self == EmptyView.self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable<Content: View>: UIViewRepresentable {
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
var content: Content
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
context.coordinator.blurView
|
||||
}
|
||||
|
||||
func updateUIView(_: UIVisualEffectView, context: Context) {
|
||||
context.coordinator.update(content: content, blurStyle: blurStyle, vibrancyStyle: vibrancyStyle)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(content: content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
extension VisualEffectBlur.Representable {
|
||||
class Coordinator {
|
||||
let blurView = UIVisualEffectView()
|
||||
let vibrancyView = UIVisualEffectView()
|
||||
let hostingController: UIHostingController<Content>
|
||||
|
||||
init(content: Content) {
|
||||
hostingController = UIHostingController(rootView: content)
|
||||
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
hostingController.view.backgroundColor = nil
|
||||
blurView.contentView.addSubview(vibrancyView)
|
||||
|
||||
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
vibrancyView.contentView.addSubview(hostingController.view)
|
||||
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
}
|
||||
|
||||
func update(content: Content, blurStyle: UIBlurEffect.Style, vibrancyStyle: UIVibrancyEffectStyle?) {
|
||||
hostingController.rootView = content
|
||||
|
||||
let blurEffect = UIBlurEffect(style: blurStyle)
|
||||
blurView.effect = blurEffect
|
||||
|
||||
if let vibrancyStyle {
|
||||
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
|
||||
} else {
|
||||
vibrancyView.effect = nil
|
||||
}
|
||||
|
||||
hostingController.view.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VisualEffectBlur where Content == EmptyView {
|
||||
init(blurStyle: UIBlurEffect.Style = .systemMaterial) {
|
||||
self.init(blurStyle: blurStyle, vibrancyStyle: nil) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
|
||||
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 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.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
public struct VisualEffectBlur: View {
|
||||
private var material: NSVisualEffectView.Material
|
||||
private var blendingMode: NSVisualEffectView.BlendingMode
|
||||
private var state: NSVisualEffectView.State
|
||||
|
||||
public init(
|
||||
material: NSVisualEffectView.Material = .headerView,
|
||||
blendingMode: NSVisualEffectView.BlendingMode = .withinWindow,
|
||||
state: NSVisualEffectView.State = .followsWindowActiveState
|
||||
) {
|
||||
self.material = material
|
||||
self.blendingMode = blendingMode
|
||||
self.state = state
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(
|
||||
material: material,
|
||||
blendingMode: blendingMode,
|
||||
state: state
|
||||
).accessibility(hidden: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable: NSViewRepresentable {
|
||||
var material: NSVisualEffectView.Material
|
||||
var blendingMode: NSVisualEffectView.BlendingMode
|
||||
var state: NSVisualEffectView.State
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
context.coordinator.visualEffectView
|
||||
}
|
||||
|
||||
func updateNSView(_: NSVisualEffectView, context: Context) {
|
||||
context.coordinator.update(material: material)
|
||||
context.coordinator.update(blendingMode: blendingMode)
|
||||
context.coordinator.update(state: state)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
|
||||
init() {
|
||||
visualEffectView.blendingMode = .withinWindow
|
||||
}
|
||||
|
||||
func update(material: NSVisualEffectView.Material) {
|
||||
visualEffectView.material = material
|
||||
}
|
||||
|
||||
func update(blendingMode: NSVisualEffectView.BlendingMode) {
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
}
|
||||
|
||||
func update(state: NSVisualEffectView.State) {
|
||||
visualEffectView.state = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
197
CHANGELOG.md
197
CHANGELOG.md
@@ -1,125 +1,78 @@
|
||||
## Build 201
|
||||
|
||||
## What's Changed
|
||||
* MPV audio track switching and fix default audio language by @n3d1117 in https://github.com/yattee/yattee/pull/874
|
||||
* Feat: Added caption support for Piped backend by @craftycorvid in https://github.com/yattee/yattee/pull/867
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/877
|
||||
|
||||
## Previous builds
|
||||
* Add support for invidious companion by @lifo9 in https://github.com/yattee/yattee/pull/863
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
* Added Settings Import/Export
|
||||
* Export all settings, instances and accounts
|
||||
* Import selected elements from the file
|
||||
* Include unencrypted passwords in the export or provide them during the import
|
||||
* Import via URL for tvOS
|
||||
* Added Controls setting "Action button labels" icon or icon and text
|
||||
* Added Advanced setting for MPV: "deinterlace"
|
||||
* Add help text to all header buttons (by @rickykresslein)
|
||||
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
|
||||
* Fix issues with empty comments (by @stonerl)
|
||||
* Improved Invidious comments (by @stonerl)
|
||||
* Allow import of accounts to manually added (not imported) instances
|
||||
* Add import export of missing settings
|
||||
* macOS: Fix settings windows layout
|
||||
* Fix seek OSD layout on tvOS, revert OSD position
|
||||
* Allow users to disable fullscreen swipe gesture by @stonerl in https://github.com/yattee/yattee/pull/814
|
||||
* Proper audio interrupt and route change handling by @stonerl in https://github.com/yattee/yattee/pull/815
|
||||
* Improved subtitle handling by @stonerl in https://github.com/yattee/yattee/pull/817
|
||||
* Improvements to MPVGLView by @stonerl in https://github.com/yattee/yattee/pull/818
|
||||
* Add drag gestures to video details by @stonerl in https://github.com/yattee/yattee/pull/820
|
||||
* Fix uneven playback when using MPV and not syncing refreshrate by @blennster in https://github.com/yattee/yattee/pull/833
|
||||
* Norwegian Language by @mmaalo in https://github.com/yattee/yattee/pull/834
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/836
|
||||
* Update MPVKit to v0.39.0 by @stonerl in https://github.com/yattee/yattee/pull/824
|
||||
* Update SwiftUI-Introspect by @stonerl in https://github.com/yattee/yattee/pull/813
|
||||
* Orientation/Fullscreen fixes and cleanup by @stonerl in https://github.com/yattee/yattee/pull/806
|
||||
* More robust resolution handling by @stonerl in https://github.com/yattee/yattee/pull/807
|
||||
* MPV: improved A/V sync by @stonerl in https://github.com/yattee/yattee/pull/805
|
||||
* Retry loading video before presenting error by @stonerl in https://github.com/yattee/yattee/pull/810
|
||||
* Refactor Search by @stonerl in https://github.com/yattee/yattee/pull/809
|
||||
* iOS: Simplified fullscreen and orientation by @stonerl in https://github.com/yattee/yattee/pull/786
|
||||
* macOS: only apply player shortcuts when window is active by @stonerl in https://github.com/yattee/yattee/pull/802
|
||||
* player controls: add background opacity selection by @stonerl in https://github.com/yattee/yattee/pull/799
|
||||
* add missing Shorts resolutions by @stonerl in https://github.com/yattee/yattee/pull/797
|
||||
* use -O1 on macOS by @stonerl in https://github.com/yattee/yattee/pull/801
|
||||
* Gestures: swipe up toggles fullscreen by @stonerl in https://github.com/yattee/yattee/pull/778
|
||||
* don’t open video when dismissing context menu by @stonerl in https://github.com/yattee/yattee/pull/780
|
||||
* mpv: remove video layer when entering background by @stonerl in https://github.com/yattee/yattee/pull/793
|
||||
* hi-res invidious logos by @stonerl in https://github.com/yattee/yattee/pull/791
|
||||
* enable -O3 by @stonerl in https://github.com/yattee/yattee/pull/794
|
||||
* Better audio ducking by @stonerl in https://github.com/yattee/yattee/pull/779
|
||||
* fix picture in picture by @stonerl in https://github.com/yattee/yattee/pull/789
|
||||
* Invidious: propper HTTP basic auth support by @stonerl in https://github.com/yattee/yattee/pull/762
|
||||
* Apply correct orientation by @stonerl in https://github.com/yattee/yattee/pull/770
|
||||
* Circular Invidious logo by @stonerl in https://github.com/yattee/yattee/pull/769
|
||||
* Video Thumbnails: retry 3 times fetching from URL by @stonerl in https://github.com/yattee/yattee/pull/768
|
||||
* Make thumbnail fill the view in music mode by @stonerl in https://github.com/yattee/yattee/pull/766
|
||||
* Changes to defaults by @stonerl in https://github.com/yattee/yattee/pull/767
|
||||
* Fixed fullscreen handling for backgrounding by @stonerl in https://github.com/yattee/yattee/pull/772
|
||||
* Update now playing info when using system controls – Partial fix for 503 by @stonerl in https://github.com/yattee/yattee/pull/765
|
||||
* Stop making videos with unknown length shorts. by @derspyy in https://github.com/yattee/yattee/pull/849
|
||||
* Add Hungarian to locales list
|
||||
* Fix crash on HLS live playback by @stonerl in https://github.com/yattee/yattee/pull/775
|
||||
* Fix mpv crashing on macOS by @stonerl in https://github.com/yattee/yattee/pull/754
|
||||
* Refreshed icons for iOS and macOS by @stonerl in https://github.com/yattee/yattee/pull/752
|
||||
* Add new MPVKit repo by @stonerl in https://github.com/yattee/yattee/pull/753
|
||||
* Add Chinese (Simplified) - zh-Hans to LanguageCodes by @stonerl in https://github.com/yattee/yattee/pull/757
|
||||
* Color changes to VideoActions by @stonerl in https://github.com/yattee/yattee/pull/759
|
||||
* Hide VideoActions Bar when no buttons is visible by @stonerl in https://github.com/yattee/yattee/pull/760
|
||||
* Improved stream resolution handling by @stonerl in https://github.com/yattee/yattee/pull/747
|
||||
* Fix some potential crashes by @stonerl in https://github.com/yattee/yattee/pull/748
|
||||
* Fix regression and improve curentChapter handling by @stonerl in https://github.com/yattee/yattee/pull/749
|
||||
* Refined chapter font scaling by @stonerl in https://github.com/yattee/yattee/pull/750
|
||||
* Improved thumbnail handling by @stonerl in https://github.com/yattee/yattee/pull/740
|
||||
* iOS: make timestamps in comments touchable by @stonerl in https://github.com/yattee/yattee/pull/741
|
||||
* Improvements to opening channels from Videos by @stonerl in https://github.com/yattee/yattee/pull/742
|
||||
* Allow hiding comments by @stonerl in https://github.com/yattee/yattee/pull/744
|
||||
* Add option to exit fullscreen on end by @stonerl in https://github.com/yattee/yattee/pull/570
|
||||
* Only updateWatch status while video is playing by @stonerl in https://github.com/yattee/yattee/pull/745
|
||||
* Xcode 16 - update recommended settings by @stonerl in https://github.com/yattee/yattee/pull/737
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/724
|
||||
* tvOS: Allow account picker by long pressing channels button in subscriptions view by @patelhiren in https://github.com/yattee/yattee/pull/704
|
||||
* tvOS: Refined Subscriptions View by @patelhiren in https://github.com/yattee/yattee/pull/697
|
||||
* More responsive UI when Favorites are used. by @stonerl in https://github.com/yattee/yattee/pull/695
|
||||
* Improved conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/696
|
||||
* Don't show related in sidebar when disabled in settings by @stonerl in https://github.com/yattee/yattee/pull/635
|
||||
* Handle audio session interrupts by other media by @stonerl in https://github.com/yattee/yattee/pull/640
|
||||
* Only show Queue header in sidebar view by @stonerl in https://github.com/yattee/yattee/pull/642
|
||||
* SponsorBlock Improvements by @stonerl in https://github.com/yattee/yattee/pull/639
|
||||
* Chapter title on jump by @stonerl in https://github.com/yattee/yattee/pull/655
|
||||
* Restart finished video by @stonerl in https://github.com/yattee/yattee/pull/646
|
||||
* SponsorBlock jump to end instead of pausing by @stonerl in https://github.com/yattee/yattee/pull/648
|
||||
* Call correct class of SDImageAWebPCoder by @stonerl in https://github.com/yattee/yattee/pull/664
|
||||
* Fix handling and displaying captions by @stonerl in https://github.com/yattee/yattee/pull/636
|
||||
* Advanced settings: make number fields .numPad by @stonerl in https://github.com/yattee/yattee/pull/661
|
||||
* Preserve time on stream change by @stonerl in https://github.com/yattee/yattee/pull/651
|
||||
* Switch to previous backend when leaving PiP by @stonerl in https://github.com/yattee/yattee/pull/641
|
||||
* Handle deep links by @timonus in https://github.com/yattee/yattee/pull/645
|
||||
* Music Mode: don't bindPlayerToLayer when entering foreground by @stonerl in https://github.com/yattee/yattee/pull/644
|
||||
* Allow user to disable thumbnails and jump to current chapter in horizontal view by @stonerl in https://github.com/yattee/yattee/pull/665
|
||||
* Rework qualitiy settings by @stonerl in https://github.com/yattee/yattee/pull/650
|
||||
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
|
||||
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
|
||||
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
|
||||
* Conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/662
|
||||
* HomeView: Changes to Favourites and History Widget by @stonerl in https://github.com/yattee/yattee/pull/672
|
||||
* Snappy UI - Offloading non UI task to background threads by @stonerl in https://github.com/yattee/yattee/pull/671
|
||||
* Fix PiP Mode Not Working Using MPV by @stonerl in https://github.com/yattee/yattee/pull/676
|
||||
* Fix thumbnails failing to load on tvOS by @patelhiren in https://github.com/yattee/yattee/pull/688
|
||||
* speed up sorting for Stream by @stonerl in https://github.com/yattee/yattee/pull/681
|
||||
* faster chapter extraction by @stonerl in https://github.com/yattee/yattee/pull/682
|
||||
* Invidious: add images to chapters by @stonerl in https://github.com/yattee/yattee/pull/685
|
||||
* Improved Captions handling by @stonerl in https://github.com/yattee/yattee/pull/684
|
||||
* Add User-Agent to request by @stonerl in https://github.com/yattee/yattee/pull/680
|
||||
* MPV: speed up playback start by @stonerl in https://github.com/yattee/yattee/pull/689
|
||||
* Advanced Settings: cache-pause-initial by @stonerl in https://github.com/yattee/yattee/pull/679
|
||||
* Changed description for Format reordering by @stonerl in https://github.com/yattee/yattee/pull/677
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
* Upgraded dependencies
|
||||
* Fixed reported crash
|
||||
* Other minor changes and improvements
|
||||
### General
|
||||
|
||||
**Big thanks to the current, past and future project contributors!**
|
||||
#### New Features
|
||||
|
||||
* Add Allow Software-Decoded Formats playback setting
|
||||
* Add Show Sidebar toggle to Subscriptions view options
|
||||
* Render clickable links and timestamps in comment text
|
||||
* Route YouTube links tapped in descriptions through in-app playback
|
||||
* Resolve URL shorteners and prompt for ambiguous description links
|
||||
* Rename YouTube Enhancements settings to Integrations and move above Advanced
|
||||
* Show watch progress bar on thumbnails in playlist, channel, and search views
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
* Resume and seek when reopening the currently-loaded video
|
||||
* Refresh track list when advancing to the next queued video
|
||||
* Suppress stale player error after switching videos mid-retry
|
||||
* Surface mpv error details on stream load failure
|
||||
* Fix local folder playback after app container UUID changes
|
||||
* Skip local-folder watches from iCloud sync
|
||||
|
||||
#### Sources & Backends
|
||||
|
||||
* Surface clearer error when adding a Piped frontend URL
|
||||
* Send Piped session token in the Authorization header again
|
||||
* Block HTTP Basic Auth proxy for Piped sources
|
||||
* Cache and prewarm Invidious proxy auto-detection
|
||||
* Route Yattee Server playback through `/proxy/relay` when "Proxy Videos" is on
|
||||
|
||||
#### Improvements
|
||||
|
||||
* Prefetch fresh video thumbnail before swapping it into the info view
|
||||
* Stabilize thumbnail cache across rotating URL tokens to avoid reloads
|
||||
* Tweak Subscriptions view options sheet layout
|
||||
|
||||
### iOS
|
||||
|
||||
* Add channels sidebar to Subscriptions on iPad
|
||||
* Round player seek bar and show the scrubber only while dragging
|
||||
* Add interactive swipe-to-dismiss for toasts
|
||||
|
||||
### tvOS
|
||||
|
||||
#### New Features
|
||||
|
||||
* Add press-and-hold continuous seek on the d-pad
|
||||
* Expose Background Playback toggle (default off)
|
||||
* Add Show Sidebar toggle to the Subscriptions view
|
||||
* Add display frame rate and dynamic range matching
|
||||
* Show cached channel header while the channel loads
|
||||
* Live-seek the scrubber and auto-commit on idle; pause playback on entering scrub mode
|
||||
* Keep player controls visible on pause via an on-screen button
|
||||
* Show playback failure overlay; dismiss player panels when playback fails
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
* Fix MPV startup playback stability
|
||||
* Fix MPV Options focus and Add/Edit sheet layout
|
||||
* Fix pickers
|
||||
* Fix soft-lock in import views when no rows are focusable
|
||||
* Unstick focus dead-ends in channel views
|
||||
* Make detail dismiss button opt-in and unstick more views
|
||||
* Dismiss sidebar detail pages when sidebar selection changes
|
||||
* Suppress Now Playing while an AirPlay/HomePod route is active
|
||||
* Hide feed channel filter strip
|
||||
* Enforce minimum 2 grid columns
|
||||
* Prevent focus shadow from clipping between Home sections
|
||||
|
||||
#### Improvements
|
||||
|
||||
* Convert settings and queue to half-screen panels; constrain details panel to the right half
|
||||
* Make the watched checkmark more prominent on thumbnails
|
||||
* Use light glass background for player control buttons; black icons on focused buttons for legibility
|
||||
* Match play button background to prev/next transport buttons
|
||||
* Remove the close button from the MPV debug stats overlay
|
||||
* Present instance login as a full-screen cover
|
||||
|
||||
162
CLAUDE.md
Normal file
162
CLAUDE.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Yattee Development Notes
|
||||
|
||||
## Testing Instances
|
||||
|
||||
- **Invidious**: `https://invidious.home.arekf.net/` - Use this instance for testing API calls
|
||||
- **Yattee Server**: `https://main.s.yattee.stream` - Local self-hosted Yattee server for backend testing
|
||||
|
||||
## Related Projects
|
||||
|
||||
### Yattee Server
|
||||
|
||||
Location: `~/Developer/yattee-server`
|
||||
|
||||
A self-hosted API server powered by yt-dlp that provides an Invidious-compatible API for YouTube content. Used as an alternative backend when Invidious/Piped instances are blocked or unavailable.
|
||||
|
||||
**Key features:**
|
||||
- Invidious-compatible API endpoints (`/api/v1/videos`, `/api/v1/channels`, `/api/v1/search`, etc.)
|
||||
- Uses yt-dlp with deno for YouTube JS challenge solving
|
||||
- Returns direct YouTube CDN stream URLs
|
||||
- Optional backing Invidious instance for trending, popular, and search suggestions
|
||||
|
||||
**API endpoints:**
|
||||
- `GET /api/v1/videos/{video_id}` - Video metadata and streams
|
||||
- `GET /api/v1/channels/{channel_id}` - Channel info
|
||||
- `GET /api/v1/channels/{channel_id}/videos` - Channel videos
|
||||
- `GET /api/v1/search?q={query}` - Search
|
||||
- `GET /api/v1/playlists/{playlist_id}` - Playlist info
|
||||
|
||||
**Limitations:**
|
||||
- No comments support
|
||||
- Stream URLs expire after a few hours
|
||||
- Trending/popular/suggestions require backing Invidious instance
|
||||
- scheme name to build is Yattee. use generic platform build instead of specific sim/device id
|
||||
|
||||
## UI Testing with AXe
|
||||
|
||||
The project uses a Ruby/RSpec-based UI testing framework with [AXe](https://github.com/cameroncooke/AXe) for simulator automation and visual regression testing.
|
||||
|
||||
### Running UI Tests
|
||||
|
||||
```bash
|
||||
# Install dependencies (first time)
|
||||
bundle install
|
||||
|
||||
# Run all UI tests
|
||||
./bin/ui-test
|
||||
|
||||
# Skip build (faster iteration)
|
||||
./bin/ui-test --skip-build
|
||||
|
||||
# Keep simulator running after tests
|
||||
./bin/ui-test --keep-simulator
|
||||
|
||||
# Generate new baseline screenshots
|
||||
./bin/ui-test --generate-baseline
|
||||
|
||||
# Run on a different device
|
||||
./bin/ui-test --device "iPad Pro 13-inch (M5)"
|
||||
```
|
||||
|
||||
### Creating Tests for New Features
|
||||
|
||||
When implementing a new feature, create a UI test to verify it works:
|
||||
|
||||
1. **Create a new spec file** in `spec/ui/smoke/`:
|
||||
```ruby
|
||||
# spec/ui/smoke/my_feature_spec.rb
|
||||
require_relative '../spec_helper'
|
||||
|
||||
RSpec.describe 'My New Feature', :smoke do
|
||||
before(:all) do
|
||||
@udid = UITest::Simulator.boot(UITest::Config.device)
|
||||
UITest::App.build(device: UITest::Config.device, skip: UITest::Config.skip_build?)
|
||||
UITest::App.install(udid: @udid)
|
||||
UITest::App.launch(udid: @udid)
|
||||
sleep UITest::Config.app_launch_wait
|
||||
@axe = UITest::Axe.new(@udid)
|
||||
end
|
||||
|
||||
after(:all) do
|
||||
UITest::App.terminate(udid: @udid, silent: true) if @udid
|
||||
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
|
||||
end
|
||||
|
||||
it 'displays the new feature element' do
|
||||
# Navigate to the feature if needed
|
||||
@axe.tap_label('Settings')
|
||||
sleep 1
|
||||
|
||||
# Check for expected elements
|
||||
expect(@axe).to have_text('My New Feature')
|
||||
end
|
||||
|
||||
it 'matches baseline screenshot', :visual do
|
||||
screenshot = @axe.screenshot('my-feature-screen')
|
||||
expect(screenshot).to match_baseline
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
2. **Available AXe actions:**
|
||||
```ruby
|
||||
@axe.tap_label('Button Text') # Tap by accessibility label
|
||||
@axe.tap_id('accessibilityId') # Tap by accessibility identifier
|
||||
@axe.tap_coordinates(x: 100, y: 200)
|
||||
@axe.swipe(start_x: 200, start_y: 400, end_x: 200, end_y: 100)
|
||||
@axe.gesture('scroll-down') # Presets: scroll-up, scroll-down, scroll-left, scroll-right
|
||||
@axe.type('search text') # Type text
|
||||
@axe.home_button # Press home
|
||||
@axe.screenshot('name') # Take screenshot
|
||||
```
|
||||
|
||||
3. **Available matchers:**
|
||||
```ruby
|
||||
expect(@axe).to have_element('AXUniqueId') # Check by accessibility identifier
|
||||
expect(@axe).to have_text('Visible Text') # Check by accessibility label
|
||||
expect(screenshot_path).to match_baseline # Visual comparison (2% threshold)
|
||||
```
|
||||
|
||||
4. **Run with baseline generation:**
|
||||
```bash
|
||||
./bin/ui-test --generate-baseline --keep-simulator
|
||||
```
|
||||
|
||||
5. **Inspect accessibility tree** to find element identifiers:
|
||||
```bash
|
||||
# Boot simulator and launch app first, then:
|
||||
axe describe-ui --udid <SIMULATOR_UDID>
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
spec/
|
||||
├── ui/
|
||||
│ ├── spec_helper.rb # RSpec configuration
|
||||
│ ├── support/
|
||||
│ │ ├── config.rb # Test configuration
|
||||
│ │ ├── simulator.rb # Simulator management
|
||||
│ │ ├── app.rb # App build/install/launch
|
||||
│ │ ├── axe.rb # AXe CLI wrapper
|
||||
│ │ ├── axe_matchers.rb # Custom RSpec matchers
|
||||
│ │ └── screenshot_comparison.rb
|
||||
│ └── smoke/
|
||||
│ └── app_launch_spec.rb # Example test
|
||||
└── ui_snapshots/
|
||||
├── baseline/ # Reference screenshots (by device/iOS version)
|
||||
│ └── iPhone_17_Pro/
|
||||
│ └── iOS_26_2/
|
||||
│ └── app-launch-library.png
|
||||
├── current/ # Current test run screenshots
|
||||
├── diff/ # Visual diff images
|
||||
└── false_positives.yml # Mark expected differences
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- Use `have_text` matcher for most checks - it's more reliable than `have_element` since iOS doesn't always expose accessibility identifiers
|
||||
- Add `sleep 1` after navigation actions to let UI settle
|
||||
- Use `--keep-simulator` during development to speed up iteration
|
||||
- Check `spec/ui_snapshots/diff/` for visual diff images when tests fail
|
||||
- Add entries to `false_positives.yml` for screenshots with expected dynamic content
|
||||
@@ -1,11 +0,0 @@
|
||||
import AVKit
|
||||
|
||||
extension AVPlayerViewController {
|
||||
func enterFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
extension Array where Element: Equatable {
|
||||
func next(after element: Element?) -> Element? {
|
||||
if element.isNil {
|
||||
return first
|
||||
}
|
||||
|
||||
let idx = firstIndex(of: element!)
|
||||
|
||||
if idx.isNil {
|
||||
return first
|
||||
}
|
||||
|
||||
let next = index(after: idx!)
|
||||
|
||||
return self[next == endIndex ? startIndex : next]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
extension CMTime {
|
||||
static let defaultTimescale: CMTimeScale = 1_000_000
|
||||
|
||||
static func secondsInDefaultTimescale(_ seconds: TimeInterval) -> CMTime {
|
||||
CMTime(seconds: seconds, preferredTimescale: CMTime.defaultTimescale)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
extension CaseIterable where Self: Equatable {
|
||||
func next(nilAtEnd: Bool = false) -> Self! {
|
||||
let all = Self.allCases
|
||||
let index = all.firstIndex(of: self)!
|
||||
let next = all.index(after: index)
|
||||
|
||||
if nilAtEnd == true {
|
||||
if next == all.endIndex {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return all[next == all.endIndex ? all.startIndex : next]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
#if os(macOS)
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
#elseif os(iOS)
|
||||
static let background = Color(UIColor.systemBackground)
|
||||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||||
#else
|
||||
static func background(scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? .black : .init(white: 0.8)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
static var debug: Color {
|
||||
#if DEBUG
|
||||
return Color(
|
||||
red: .random(in: 0 ... 1),
|
||||
green: .random(in: 0 ... 1),
|
||||
blue: .random(in: 0 ... 1)
|
||||
)
|
||||
#else
|
||||
return Color(.clear)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Comparable {
|
||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
||||
min(max(self, limits.lowerBound), limits.upperBound)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func formattedAsPlaybackTime(allowZero: Bool = false, forceHours: Bool = false) -> String? {
|
||||
guard allowZero || !isZero, isFinite else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
|
||||
formatter.unitsStyle = .positional
|
||||
formatter.allowedUnits = self >= (60 * 60) || forceHours ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
func formattedAsRelativeTime() -> String? {
|
||||
let date = Date(timeIntervalSince1970: self)
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.dateTimeStyle = .named
|
||||
formatter.unitsStyle = .short
|
||||
formatter.formattingContext = .standalone
|
||||
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Int {
|
||||
func formattedAsAbbreviation() -> String {
|
||||
let num = fabs(Double(self))
|
||||
|
||||
guard num >= 1000.0 else {
|
||||
return String(self)
|
||||
}
|
||||
|
||||
let exp = Int(log10(num) / 3.0)
|
||||
let units = ["K", "M", "B", "T", "X"]
|
||||
let unit = units[exp - 1]
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
|
||||
formatter.positiveSuffix = unit
|
||||
formatter.negativeSuffix = unit
|
||||
formatter.allowsFloats = true
|
||||
formatter.minimumIntegerDigits = 1
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 1
|
||||
|
||||
let roundedNum = round(10 * num / pow(1000.0, Double(exp))) / 10
|
||||
return formatter.string(from: NSNumber(value: roundedNum))!
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import CoreData
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
/// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date.
|
||||
///
|
||||
/// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute.
|
||||
/// - Throws: An error if anything went wrong executing the batch deletion.
|
||||
func executeAndMergeChanges(_ batchDeleteRequest: NSBatchDeleteRequest) throws {
|
||||
batchDeleteRequest.resultType = .resultTypeObjectIDs
|
||||
let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult
|
||||
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
extension NSObject {
|
||||
class func swizzle(origSelector: Selector, withSelector: Selector, forClass: AnyClass) {
|
||||
let originalMethod = class_getInstanceMethod(forClass, origSelector)
|
||||
let swizzledMethod = class_getInstanceMethod(forClass, withSelector)
|
||||
method_exchangeImplementations(originalMethod!, swizzledMethod!)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Sequence where Iterator.Element: Hashable {
|
||||
func unique() -> [Iterator.Element] {
|
||||
var seen: Set<Iterator.Element> = []
|
||||
return filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func replacingFirstOccurrence(of target: String, with replacement: String) -> String {
|
||||
guard let range = range(of: target) else {
|
||||
return self
|
||||
}
|
||||
return replacingCharacters(in: range, with: replacement)
|
||||
}
|
||||
|
||||
func replacingMatches(regex: String, replacementStringClosure: (String) -> String?) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: regex) else {
|
||||
return self
|
||||
}
|
||||
|
||||
let results = regex.matches(in: self, range: NSRange(startIndex..., in: self))
|
||||
|
||||
var outputText = self
|
||||
|
||||
for match in results.reversed() {
|
||||
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
|
||||
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
|
||||
let rangeBounds = match.range(at: rangeIndex)
|
||||
|
||||
guard let range = Range(rangeBounds, in: self) else {
|
||||
continue
|
||||
}
|
||||
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
|
||||
|
||||
outputText = outputText.replacingOccurrences(of: matchingGroup, with: replacement, range: range)
|
||||
}
|
||||
}
|
||||
return outputText
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func localized(_ comment: String = "") -> Self {
|
||||
NSLocalizedString(self, tableName: "Localizable", bundle: .main, comment: comment)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var replacingHTMLEntities: String {
|
||||
do {
|
||||
return try NSAttributedString(data: Data(utf8), options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
], documentAttributes: nil).string
|
||||
} catch {
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
extension TypedContentAccessors {
|
||||
var json: JSON { typedContent(ifNone: JSON.null) }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension UIDevice {
|
||||
/// A Boolean value indicating whether the device has cellular data capabilities (true) or not (false).
|
||||
var hasCellularCapabilites: Bool {
|
||||
var addrs: UnsafeMutablePointer<ifaddrs>?
|
||||
var cursor: UnsafeMutablePointer<ifaddrs>?
|
||||
|
||||
defer { freeifaddrs(addrs) }
|
||||
|
||||
guard getifaddrs(&addrs) == 0 else { return false }
|
||||
cursor = addrs
|
||||
|
||||
while cursor != nil {
|
||||
guard
|
||||
let utf8String = cursor?.pointee.ifa_name,
|
||||
let name = NSString(utf8String: utf8String),
|
||||
name == "pdp_ip0"
|
||||
else {
|
||||
cursor = cursor?.pointee.ifa_next
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
extension UIViewController {
|
||||
@objc var swizzle_prefersHomeIndicatorAutoHidden: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
public class func swizzleHomeIndicatorProperty() {
|
||||
swizzle(
|
||||
origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
||||
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
||||
forClass: UIViewController.self
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
|
||||
var urlAbsoluteString = absoluteString
|
||||
|
||||
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
|
||||
return self
|
||||
}
|
||||
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
|
||||
if absoluteString.contains("://") {
|
||||
return URL(string: urlAbsoluteString)
|
||||
}
|
||||
|
||||
return URL(string: "\(urlProtocol)://\(urlAbsoluteString)")
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func borderTop(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
verticalEdgeBorder(.top, height: height, color: color)
|
||||
}
|
||||
|
||||
func borderBottom(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
verticalEdgeBorder(.bottom, height: height, color: color)
|
||||
}
|
||||
|
||||
func borderLeading(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.leading, width: width, color: color)
|
||||
}
|
||||
|
||||
func borderTrailing(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.trailing, width: width, color: color)
|
||||
}
|
||||
|
||||
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
|
||||
overlay(
|
||||
Rectangle()
|
||||
.frame(width: nil, height: height, alignment: .top)
|
||||
.foregroundColor(color)
|
||||
.ignoresSafeArea(.all, edges: .horizontal),
|
||||
alignment: edge
|
||||
)
|
||||
}
|
||||
|
||||
private func horizontalEdgeBorder(_ edge: Alignment, width: Double, color: Color) -> some View {
|
||||
overlay(
|
||||
Rectangle()
|
||||
.frame(width: width, height: nil, alignment: .leading)
|
||||
.foregroundColor(color),
|
||||
alignment: edge
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension ChannelPlaylist {
|
||||
static var fixture: ChannelPlaylist {
|
||||
ChannelPlaylist(
|
||||
id: "fixture-channel-playlist",
|
||||
title: "Playlist with a very long title that will not fit easily in the screen",
|
||||
thumbnailURL: URL(string: "https://i.ytimg.com/vi/hT_nvWreIhg/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLAAD21_-Bo6Td1z3cV-UFyoi1flEg")!,
|
||||
channel: Video.fixture.channel,
|
||||
videos: Video.allFixtures
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Comment {
|
||||
static var fixture: Comment {
|
||||
Comment(
|
||||
id: UUID().uuidString,
|
||||
author: "The Author",
|
||||
authorAvatarURL: "https://pipedproxy-ams-2.kavin.rocks/Si7ZhtmpX84wj6MoJYLs8kwALw2Hm53wzbrPamoU-z3qvCKs2X3zPNYKMSJEvPDLUHzbvTfLcg=s176-c-k-c0x00ffffff-no-rw?host=yt3.ggpht.com",
|
||||
time: "2 months ago",
|
||||
pinned: true,
|
||||
hearted: true,
|
||||
likeCount: 30032,
|
||||
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ",
|
||||
repliesPage: "some url",
|
||||
channel: .init(app: .invidious, id: "", name: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Instance {
|
||||
static var fixture: Instance {
|
||||
Instance(app: .invidious, name: "Home", apiURLString: "https://invidious.home.net")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Playlist {
|
||||
static var fixture: Playlist {
|
||||
Playlist(id: UUID().uuidString, title: "Relaxing music", visibility: .public, updated: 1)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Thumbnail {
|
||||
static func fixture(videoId: String, quality: Thumbnail.Quality = .maxres) -> Thumbnail {
|
||||
Thumbnail(url: fixtureUrl(videoId: videoId, quality: quality), quality: quality)
|
||||
}
|
||||
|
||||
static func fixturesForAllQualities(videoId: String) -> [Thumbnail] {
|
||||
Thumbnail.Quality.allCases.map { fixture(videoId: videoId, quality: $0) }
|
||||
}
|
||||
|
||||
private static var fixturesHost: String {
|
||||
"https://invidious.snopyta.org"
|
||||
}
|
||||
|
||||
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
|
||||
URL(string: "\(fixturesHost)/vi/\(videoId)/\(quality.filename).jpg")!
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixtureID: Video.ID = "video-fixture"
|
||||
static var fixtureChannelID: Channel.ID = "channel-fixture"
|
||||
|
||||
static var fixture: Video {
|
||||
let bannerURL = "https://yt3.ggpht.com/SQiRareBDrV2Z6A30HSD0iUABOGysanmKLtaJq7lJ_ME-MtoLb3O61QdlJfH2KhSOA0eKPr_=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj"
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!
|
||||
|
||||
return Video(
|
||||
app: .invidious,
|
||||
videoID: fixtureID,
|
||||
title: "Relaxing Piano Music to feel good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
published: "7 years ago",
|
||||
views: 21534,
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
app: .invidious,
|
||||
id: fixtureChannelID,
|
||||
name: "The Channel",
|
||||
bannerURL: URL(string: bannerURL)!,
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
description: "The best channel that ever existed.\nThe best channel that ever existed. The best channel that ever existed. The best channel that ever existed. The best channel that ever existed. ",
|
||||
subscriptionsCount: 2300,
|
||||
totalViews: 3_260_378_817,
|
||||
videos: []
|
||||
),
|
||||
thumbnails: [],
|
||||
live: false,
|
||||
upcoming: false,
|
||||
publishedAt: Date(),
|
||||
likes: 37333,
|
||||
dislikes: 30,
|
||||
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"],
|
||||
related: [.otherFixture],
|
||||
chapters: [
|
||||
.init(title: "A good chapter name", image: chapterImageURL, start: 20),
|
||||
.init(title: "Other fine but incredibly too long chapter name, I don't know what else to say", image: chapterImageURL, start: 30),
|
||||
.init(title: "Short", image: chapterImageURL, start: 60)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static var otherFixture: Video {
|
||||
let bannerURL = "https://yt3.ggpht.com/SQiRareBDrV2Z6A30HSD0iUABOGysanmKLtaJq7lJ_ME-MtoLb3O61QdlJfH2KhSOA0eKPr_=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj"
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!
|
||||
|
||||
return Video(
|
||||
app: .invidious,
|
||||
videoID: fixtureID + fixtureID,
|
||||
title: "Relaxing Piano Music to feel good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
published: "7 years ago",
|
||||
views: 21534,
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
app: .invidious,
|
||||
id: fixtureChannelID + fixtureChannelID,
|
||||
name: "The Channel",
|
||||
bannerURL: URL(string: bannerURL)!,
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
subscriptionsCount: 2300,
|
||||
totalViews: 3_260_378_817,
|
||||
videos: []
|
||||
),
|
||||
thumbnails: [],
|
||||
live: false,
|
||||
upcoming: false,
|
||||
publishedAt: Date(),
|
||||
likes: 37333,
|
||||
dislikes: 30,
|
||||
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"],
|
||||
chapters: [
|
||||
.init(title: "A good chapter name", image: chapterImageURL, start: 20),
|
||||
.init(title: "Other fine but incredibly too long chapter name, I don't know what else to say", image: chapterImageURL, start: 30),
|
||||
.init(title: "Short", image: chapterImageURL, start: 60)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static var fixtureLiveWithoutPublishedOrViews: Video {
|
||||
var video = fixture
|
||||
|
||||
video.title = "\(video.title) \(video.title) \(video.title) \(video.title) \(video.title)"
|
||||
video.published = "0 seconds ago"
|
||||
video.views = 0
|
||||
video.live = true
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
static var fixtureUpcomingWithoutPublishedOrViews: Video {
|
||||
var video = fixtureLiveWithoutPublishedOrViews
|
||||
|
||||
video.live = false
|
||||
video.upcoming = true
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
static var allFixtures: [Video] {
|
||||
[fixture, fixtureLiveWithoutPublishedOrViews, fixtureUpcomingWithoutPublishedOrViews]
|
||||
}
|
||||
|
||||
static func fixtures(_ count: Int) -> [Video] {
|
||||
var result = [Video]()
|
||||
while result.count < count {
|
||||
result.append(allFixtures.shuffled().first!)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func injectFixtureEnvironmentObjects() -> some View {
|
||||
modifier(FixtureEnvironmentObjectsModifier())
|
||||
}
|
||||
}
|
||||
20
Gemfile
20
Gemfile
@@ -1,6 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem 'fastlane'
|
||||
# Fastlane for build automation and distribution
|
||||
gem 'fastlane', '~> 2.225'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
# Load environment variables from .env files
|
||||
# Note: fastlane requires dotenv < 3.0, so we use 2.x
|
||||
gem 'dotenv', '~> 2.8'
|
||||
|
||||
group :test do
|
||||
# RSpec for UI testing framework
|
||||
gem 'rspec', '~> 3.13'
|
||||
# Retry flaky UI tests automatically
|
||||
gem 'rspec-retry', '~> 0.6'
|
||||
# Code linting
|
||||
gem 'rubocop', '~> 1.69', require: false
|
||||
gem 'rubocop-rspec', '~> 3.3', require: false
|
||||
end
|
||||
|
||||
198
Gemfile.lock
198
Gemfile.lock
@@ -1,46 +1,51 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.9)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
ast (2.4.3)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1072.0)
|
||||
aws-sdk-core (3.220.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1231.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.99.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.182.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-s3 (1.217.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
diff-lcs (1.6.2)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@@ -52,32 +57,36 @@ GEM
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.1.0)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.227.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
@@ -89,16 +98,20 @@ GEM
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
@@ -109,43 +122,44 @@ GEM
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
google-apis-androidpublisher_v3 (0.98.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
@@ -156,41 +170,85 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.10.2)
|
||||
jwt (2.10.1)
|
||||
json (2.19.3)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.2.1)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.6.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.1)
|
||||
rake (13.2.1)
|
||||
prism (1.9.0)
|
||||
public_suffix (7.0.5)
|
||||
racc (1.8.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
regexp_parser (2.11.3)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.1)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rspec (3.13.2)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-retry (0.6.2)
|
||||
rspec-core (> 3.3)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.86.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-rspec (3.9.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.81)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.19.0)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
@@ -209,22 +267,22 @@ GEM
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
arm64-darwin-23
|
||||
arm64-darwin-24
|
||||
x86_64-darwin-19
|
||||
x86_64-darwin-20
|
||||
x86_64-darwin-21
|
||||
x86_64-linux
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
dotenv (~> 2.8)
|
||||
fastlane (~> 2.225)
|
||||
rspec (~> 3.13)
|
||||
rspec-retry (~> 0.6)
|
||||
rubocop (~> 1.69)
|
||||
rubocop-rspec (~> 3.3)
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.6
|
||||
2.6.3
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
static var bridge = AccountsBridge()
|
||||
|
||||
let id: String
|
||||
var app: VideosApp?
|
||||
let instanceID: String?
|
||||
var name: String
|
||||
let urlString: String
|
||||
var username: String
|
||||
var password: String?
|
||||
let anonymous: Bool
|
||||
let country: String?
|
||||
let region: String?
|
||||
|
||||
init(
|
||||
id: String? = nil,
|
||||
app: VideosApp? = nil,
|
||||
instanceID: String? = nil,
|
||||
name: String? = nil,
|
||||
urlString: String? = nil,
|
||||
username: String? = nil,
|
||||
password: String? = nil,
|
||||
anonymous: Bool = false,
|
||||
country: String? = nil,
|
||||
region: String? = nil
|
||||
) {
|
||||
self.anonymous = anonymous
|
||||
|
||||
self.id = id ?? (anonymous ? "anonymous-\(instanceID ?? urlString ?? UUID().uuidString)" : UUID().uuidString)
|
||||
self.instanceID = instanceID
|
||||
self.name = name ?? ""
|
||||
self.urlString = urlString ?? ""
|
||||
self.username = username ?? ""
|
||||
self.password = password ?? ""
|
||||
self.country = country
|
||||
self.region = region
|
||||
self.app = app ?? instance.app
|
||||
}
|
||||
|
||||
var url: URL! {
|
||||
URL(string: urlString)
|
||||
}
|
||||
|
||||
var token: String? {
|
||||
KeychainModel.shared.getAccountKey(self, "token")
|
||||
}
|
||||
|
||||
var credentials: (String?, String?) {
|
||||
AccountsModel.getCredentials(self)
|
||||
}
|
||||
|
||||
var instance: Instance! {
|
||||
InstancesModel.shared.find(instanceID) ?? Instance(app: app ?? .invidious, name: urlString, apiURLString: urlString)
|
||||
}
|
||||
|
||||
var isPublic: Bool {
|
||||
instanceID.isNil
|
||||
}
|
||||
|
||||
var isPublicAddedToCustom: Bool {
|
||||
InstancesModel.shared.findByURLString(urlString) != nil
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard !isPublic else {
|
||||
return name
|
||||
}
|
||||
|
||||
let (username, _) = credentials
|
||||
return username ?? name
|
||||
}
|
||||
|
||||
var urlHost: String {
|
||||
URLComponents(url: url, resolvingAgainstBaseURL: false)?.host ?? ""
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(username)
|
||||
}
|
||||
|
||||
var feedCacheKey: String {
|
||||
"feed-\(id)"
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class AccountValidator: Service {
|
||||
let app: Binding<VideosApp?>
|
||||
let url: String
|
||||
let account: Account!
|
||||
|
||||
var formObjectID: Binding<String>
|
||||
var isValid: Binding<Bool>
|
||||
var isValidated: Binding<Bool>
|
||||
var isValidating: Binding<Bool>
|
||||
var error: Binding<String?>?
|
||||
|
||||
private var appsToValidateInstance = VideosApp.allCases
|
||||
|
||||
init(
|
||||
app: Binding<VideosApp?>,
|
||||
url: String,
|
||||
account: Account? = nil,
|
||||
id: Binding<String>,
|
||||
isValid: Binding<Bool>,
|
||||
isValidated: Binding<Bool>,
|
||||
isValidating: Binding<Bool>,
|
||||
error: Binding<String?>? = nil
|
||||
) {
|
||||
self.app = app
|
||||
self.url = url
|
||||
self.account = account
|
||||
formObjectID = id
|
||||
self.isValid = isValid
|
||||
self.isValidated = isValidated
|
||||
self.isValidating = isValidating
|
||||
self.error = error
|
||||
|
||||
super.init(baseURL: url)
|
||||
configure()
|
||||
}
|
||||
|
||||
func configure() {
|
||||
configure {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure("/login", requestMethods: [.post]) {
|
||||
$0.headers["Content-Type"] = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
func instanceValidationResource(_ app: VideosApp) -> Resource {
|
||||
switch app {
|
||||
case .invidious:
|
||||
return resource("/api/v1/videos/dQw4w9WgXcQ")
|
||||
|
||||
case .piped:
|
||||
return resource("/streams/dQw4w9WgXcQ")
|
||||
|
||||
case .peerTube:
|
||||
// TODO: fixme
|
||||
return resource("")
|
||||
|
||||
case .local:
|
||||
return resource("")
|
||||
}
|
||||
}
|
||||
|
||||
func validateInstance() {
|
||||
reset()
|
||||
|
||||
guard let app = appsToValidateInstance.popLast() else { return }
|
||||
tryValidatingUsing(app)
|
||||
}
|
||||
|
||||
func tryValidatingUsing(_ app: VideosApp) {
|
||||
instanceValidationResource(app)
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
guard self.url == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !response.json.isEmpty else {
|
||||
if app == .piped {
|
||||
if response.text.contains("property=\"og:title\" content=\"Piped\"") {
|
||||
self.isValid.wrappedValue = false
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
self.error?.wrappedValue = "Trying to use Piped front-end URL, you need to use URL for Piped API instead"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let nextApp = self.appsToValidateInstance.popLast() else {
|
||||
self.isValid.wrappedValue = false
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
return
|
||||
}
|
||||
|
||||
self.tryValidatingUsing(nextApp)
|
||||
return
|
||||
}
|
||||
|
||||
let json = response.json.dictionaryValue
|
||||
let author = app == .invidious ? json["author"] : json["uploader"]
|
||||
|
||||
if author == "Rick Astley" {
|
||||
self.app.wrappedValue = app
|
||||
self.isValid.wrappedValue = true
|
||||
self.error?.wrappedValue = nil
|
||||
} else {
|
||||
self.isValid.wrappedValue = false
|
||||
}
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
}
|
||||
.onFailure { error in
|
||||
guard self.url == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
if self.appsToValidateInstance.isEmpty {
|
||||
self.isValidating.wrappedValue = false
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValid.wrappedValue = false
|
||||
self.error?.wrappedValue = error.userMessage
|
||||
} else {
|
||||
guard let app = self.appsToValidateInstance.popLast() else { return }
|
||||
self.tryValidatingUsing(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateAccount() {
|
||||
reset()
|
||||
|
||||
switch app.wrappedValue {
|
||||
case .invidious:
|
||||
validateInvidiousAccount()
|
||||
case .piped:
|
||||
validatePipedAccount()
|
||||
default:
|
||||
setValidationResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
func validateInvidiousAccount() {
|
||||
guard let username = account?.username,
|
||||
let password = account?.password
|
||||
else {
|
||||
setValidationResult(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
AF
|
||||
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
|
||||
.redirect(using: .doNotFollow)
|
||||
.response { response in
|
||||
guard let headers = response.response?.headers,
|
||||
let cookies = headers["Set-Cookie"]
|
||||
else {
|
||||
self.setValidationResult(false)
|
||||
return
|
||||
}
|
||||
|
||||
let sidRegex = #"SID=(?<sid>[^;]*);"#
|
||||
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
|
||||
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
|
||||
else {
|
||||
self.setValidationResult(false)
|
||||
return
|
||||
}
|
||||
|
||||
let matchRange = match.range(withName: "sid")
|
||||
|
||||
if let substringRange = Range(matchRange, in: cookies) {
|
||||
let sid = String(cookies[substringRange])
|
||||
if !sid.isEmpty {
|
||||
self.setValidationResult(true)
|
||||
}
|
||||
} else {
|
||||
self.setValidationResult(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validatePipedAccount() {
|
||||
guard let request = accountRequest else {
|
||||
setValidationResult(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
request.onSuccess { response in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
switch self.app.wrappedValue {
|
||||
case .invidious:
|
||||
self.isValid.wrappedValue = true
|
||||
case .piped:
|
||||
let error = response.json.dictionaryValue["error"]?.string
|
||||
let token = response.json.dictionaryValue["token"]?.string
|
||||
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
|
||||
self.error!.wrappedValue = error
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isValid.wrappedValue = false
|
||||
}
|
||||
.onCompletion { _ in
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
func setValidationResult(_ result: Bool) {
|
||||
isValid.wrappedValue = result
|
||||
isValidated.wrappedValue = true
|
||||
isValidating.wrappedValue = false
|
||||
}
|
||||
|
||||
var accountRequest: Siesta.Request? {
|
||||
switch app.wrappedValue {
|
||||
case .invidious:
|
||||
guard let password = account.password else { return nil }
|
||||
return login.request(.post, urlEncoded: ["email": account.username, "password": password])
|
||||
case .piped:
|
||||
return login.request(.post, json: ["username": account.username, "password": account.password])
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
appsToValidateInstance = VideosApp.allCases
|
||||
app.wrappedValue = nil
|
||||
isValid.wrappedValue = false
|
||||
isValidated.wrappedValue = false
|
||||
isValidating.wrappedValue = false
|
||||
error?.wrappedValue = nil
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource("/login")
|
||||
}
|
||||
|
||||
var videoResourceBasePath: String {
|
||||
app.wrappedValue == .invidious ? "/api/v1/videos" : "/streams"
|
||||
}
|
||||
|
||||
var neverGonnaGiveYouUp: Resource {
|
||||
resource("\(videoResourceBasePath)/dQw4w9WgXcQ")
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct AccountsBridge: Defaults.Bridge {
|
||||
typealias Value = Account
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the urlString to check for embedded username and password
|
||||
var sanitizedUrlString = value.urlString
|
||||
if var urlComponents = URLComponents(string: value.urlString) {
|
||||
if let user = urlComponents.user, let password = urlComponents.password {
|
||||
// Sanitize the embedded username and password
|
||||
let sanitizedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user
|
||||
let sanitizedPassword = password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password
|
||||
|
||||
// Update the URL components with sanitized credentials
|
||||
urlComponents.user = sanitizedUser
|
||||
urlComponents.password = sanitizedPassword
|
||||
|
||||
// Reconstruct the sanitized URL
|
||||
sanitizedUrlString = urlComponents.string ?? value.urlString
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID ?? "",
|
||||
"name": value.name,
|
||||
"apiURL": sanitizedUrlString,
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object,
|
||||
let id = object["id"],
|
||||
let instanceID = object["instanceID"],
|
||||
let url = object["apiURL"],
|
||||
let username = object["username"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
let password = object["password"]
|
||||
|
||||
return Account(id: id, instanceID: instanceID, name: name, urlString: url, username: username, password: password)
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class AccountsModel: ObservableObject {
|
||||
static let shared = AccountsModel()
|
||||
|
||||
@Published private(set) var current: Account!
|
||||
|
||||
@Published private var invidious = InvidiousAPI()
|
||||
@Published private var piped = PipedAPI()
|
||||
@Published private var peerTube = PeerTubeAPI()
|
||||
|
||||
@Published var publicAccount: Account?
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
var all: [Account] {
|
||||
Defaults[.accounts]
|
||||
}
|
||||
|
||||
var lastUsed: Account? {
|
||||
guard let id = Defaults[.lastAccountID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Self.find(id)
|
||||
}
|
||||
|
||||
var any: Account? {
|
||||
lastUsed ?? all.randomElement()
|
||||
}
|
||||
|
||||
var app: VideosApp {
|
||||
current?.instance?.app ?? .local
|
||||
}
|
||||
|
||||
var api: VideosAPI! {
|
||||
switch app {
|
||||
case .piped:
|
||||
return piped
|
||||
case .invidious:
|
||||
return invidious
|
||||
default:
|
||||
return peerTube
|
||||
}
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
current.isNil
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
!isEmpty && !current.anonymous && api.signedIn
|
||||
}
|
||||
|
||||
init() {
|
||||
cancellables.append(
|
||||
invidious.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
)
|
||||
|
||||
cancellables.append(
|
||||
piped.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
)
|
||||
}
|
||||
|
||||
func find(_ id: Account.ID) -> Account? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
func configureAccount() {
|
||||
if let account = lastUsed ??
|
||||
InstancesModel.shared.lastUsed?.anonymousAccount ??
|
||||
InstancesModel.shared.all.first?.anonymousAccount
|
||||
{
|
||||
setCurrent(account)
|
||||
}
|
||||
}
|
||||
|
||||
func setCurrent(_ account: Account! = nil) {
|
||||
guard account != current else {
|
||||
return
|
||||
}
|
||||
|
||||
current = account
|
||||
|
||||
guard !account.isNil else {
|
||||
current = nil
|
||||
return
|
||||
}
|
||||
|
||||
switch account.instance.app {
|
||||
case .local:
|
||||
return
|
||||
case .invidious:
|
||||
invidious.setAccount(account)
|
||||
case .piped:
|
||||
piped.setAccount(account)
|
||||
case .peerTube:
|
||||
peerTube.setAccount(account)
|
||||
}
|
||||
|
||||
Defaults[.lastAccountIsPublic] = account.isPublic
|
||||
|
||||
if !account.isPublic {
|
||||
Defaults[.lastAccountID] = account.anonymous ? nil : account.id
|
||||
Defaults[.lastInstanceID] = account.instanceID
|
||||
}
|
||||
}
|
||||
|
||||
static func find(_ id: Account.ID) -> Account? {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, id: String? = UUID().uuidString, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(id: id, instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
setCredentials(account, username: username, password: password)
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
static func remove(_ account: Account) {
|
||||
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
|
||||
let account = Defaults[.accounts][accountIndex]
|
||||
KeychainModel.shared.removeAccountKeys(account)
|
||||
Defaults[.accounts].remove(at: accountIndex)
|
||||
}
|
||||
}
|
||||
|
||||
static func setToken(_ account: Account, _ token: String) {
|
||||
KeychainModel.shared.updateAccountKey(account, "token", token)
|
||||
}
|
||||
|
||||
static func setCredentials(_ account: Account, username: String, password: String) {
|
||||
KeychainModel.shared.updateAccountKey(account, "username", username)
|
||||
KeychainModel.shared.updateAccountKey(account, "password", password)
|
||||
}
|
||||
|
||||
static func getCredentials(_ account: Account) -> (String?, String?) {
|
||||
(
|
||||
KeychainModel.shared.getAccountKey(account, "username"),
|
||||
KeychainModel.shared.getAccountKey(account, "password")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
static var bridge = InstancesBridge()
|
||||
|
||||
let app: VideosApp
|
||||
let id: String
|
||||
let name: String
|
||||
let apiURLString: String
|
||||
var frontendURL: String?
|
||||
var proxiesVideos: Bool
|
||||
var invidiousCompanion: Bool
|
||||
|
||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false) {
|
||||
self.app = app
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.name = name ?? app.rawValue
|
||||
self.apiURLString = apiURLString
|
||||
self.frontendURL = frontendURL
|
||||
self.proxiesVideos = proxiesVideos
|
||||
self.invidiousCompanion = invidiousCompanion
|
||||
}
|
||||
|
||||
var apiURL: URL! {
|
||||
URL(string: apiURLString)
|
||||
}
|
||||
|
||||
var anonymous: VideosAPI! {
|
||||
switch app {
|
||||
case .invidious:
|
||||
return InvidiousAPI(account: anonymousAccount)
|
||||
case .piped:
|
||||
return PipedAPI(account: anonymousAccount)
|
||||
case .peerTube:
|
||||
return PeerTubeAPI(account: anonymousAccount)
|
||||
case .local:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(app.name) - \(shortDescription)"
|
||||
}
|
||||
|
||||
var longDescription: String {
|
||||
name.isEmpty ? "\(app.name) - \(apiURLString)" : "\(app.name) - \(name) (\(apiURLString))"
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
name.isEmpty ? apiURLString : name
|
||||
}
|
||||
|
||||
var anonymousAccount: Account {
|
||||
Account(instanceID: id, name: "Anonymous".localized(), urlString: apiURLString, anonymous: true)
|
||||
}
|
||||
|
||||
var urlComponents: URLComponents {
|
||||
URLComponents(url: apiURL, resolvingAgainstBaseURL: false)!
|
||||
}
|
||||
|
||||
var frontendHost: String? {
|
||||
guard let url = app == .invidious ? apiURLString : frontendURL else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URLComponents(string: url)?.host
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(apiURL)
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
AccountsModel.shared.all.filter { $0.instanceID == id }
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct InstancesBridge: Defaults.Bridge {
|
||||
typealias Value = Instance
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return [
|
||||
"app": value.app.rawValue,
|
||||
"id": value.id,
|
||||
"name": value.name,
|
||||
"apiURL": value.apiURLString,
|
||||
"frontendURL": value.frontendURL ?? "",
|
||||
"proxiesVideos": value.proxiesVideos ? "true" : "false",
|
||||
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object,
|
||||
let app = VideosApp(rawValue: object["app"] ?? ""),
|
||||
let id = object["id"],
|
||||
let apiURL = object["apiURL"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
let proxiesVideos = object["proxiesVideos"] == "true"
|
||||
let invidiousCompanion = object["invidiousCompanion"] == "true"
|
||||
|
||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class InstancesModel: ObservableObject {
|
||||
static var shared = InstancesModel()
|
||||
|
||||
var all: [Instance] {
|
||||
Defaults[.instances]
|
||||
}
|
||||
|
||||
var forPlayer: Instance? {
|
||||
guard let id = Defaults[.playerInstanceID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Self.shared.find(id)
|
||||
}
|
||||
|
||||
var lastUsed: Instance? {
|
||||
guard let id = Defaults[.lastInstanceID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Self.shared.find(id)
|
||||
}
|
||||
|
||||
func find(_ id: Instance.ID?) -> Instance? {
|
||||
guard id != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Defaults[.instances].first { $0.id == id }
|
||||
}
|
||||
|
||||
func findByURLString(_ urlString: String?) -> Instance? {
|
||||
guard let urlString else { return nil }
|
||||
|
||||
return Defaults[.instances].first { $0.apiURLString == urlString }
|
||||
}
|
||||
|
||||
func accounts(_ id: Instance.ID?) -> [Account] {
|
||||
Defaults[.accounts].filter { $0.instanceID == id }
|
||||
}
|
||||
|
||||
func add(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
let instance = Instance(
|
||||
app: app, id: id, name: name, apiURLString: standardizedURL(url)
|
||||
)
|
||||
Defaults[.instances].append(instance)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
func insert(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
if let instance = Defaults[.instances].first(where: { $0.apiURL.absoluteString == standardizedURL(url) }) {
|
||||
return instance
|
||||
}
|
||||
|
||||
return add(id: id, app: app, name: name, url: url)
|
||||
}
|
||||
|
||||
func setFrontendURL(_ instance: Instance, _ url: String) {
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.frontendURL = standardizedURL(url)
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
}
|
||||
|
||||
func setProxiesVideos(_ instance: Instance, _ proxiesVideos: Bool) {
|
||||
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.proxiesVideos = proxiesVideos
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func setInvidiousCompanion(_ instance: Instance, _ invidiousCompanion: Bool) {
|
||||
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.invidiousCompanion = invidiousCompanion
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func remove(_ instance: Instance) {
|
||||
let accounts = accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
Defaults[.instances].remove(at: index)
|
||||
accounts.forEach { AccountsModel.remove($0) }
|
||||
}
|
||||
}
|
||||
|
||||
func standardizedURL(_ url: String) -> String {
|
||||
if url.count > 7, url.last == "/" {
|
||||
return String(url.dropLast())
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -1,882 +0,0 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
static let basePath = "/api/v1"
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> InvidiousAPI {
|
||||
.init(account: Instance(app: .invidious, apiURLString: url.absoluteString).anonymousAccount)
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
guard let account else { return false }
|
||||
|
||||
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard !account.isNil else {
|
||||
self.account = .init(name: "Empty")
|
||||
return
|
||||
}
|
||||
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
configure()
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
if let cookie = self.cookieHeader {
|
||||
$0.headers["Cookie"] = cookie
|
||||
}
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure("**", requestMethods: [.post]) {
|
||||
$0.pipeline[.parsing].removeTransformers()
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
|
||||
let type = json.dictionaryValue["type"]?.string
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: self.extractChannel(from: json))
|
||||
}
|
||||
if type == "playlist" {
|
||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||
}
|
||||
if type == "video" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||
return suggestions.arrayValue.map(\.stringValue).map(\.replacingHTMLEntities)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.map(self.extractPlaylist)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
||||
self.extractPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
||||
self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
||||
return feedVideos.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map(self.extractChannel)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json, forceNotLast: true)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
||||
}
|
||||
|
||||
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
|
||||
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
}
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["continuation"]?.string
|
||||
let disabled = !details["error"].isNil
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
if account.token.isNil || account.token!.isEmpty {
|
||||
updateToken()
|
||||
} else {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
}
|
||||
}
|
||||
|
||||
func updateToken(force: Bool = false) {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let username,
|
||||
let password,
|
||||
!username.isEmpty,
|
||||
!password.isEmpty
|
||||
else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let presentTokenUpdateFailedAlert: (AFDataResponse<Data?>?, String?) -> Void = { response, message in
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
|
||||
)
|
||||
}
|
||||
|
||||
AF
|
||||
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
|
||||
.redirect(using: .doNotFollow)
|
||||
.response { response in
|
||||
guard let headers = response.response?.headers,
|
||||
let cookies = headers["Set-Cookie"]
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(response, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let sidRegex = #"SID=(?<sid>[^;]*);"#
|
||||
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
|
||||
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
return
|
||||
}
|
||||
|
||||
let matchRange = match.range(withName: "sid")
|
||||
|
||||
if let substringRange = Range(matchRange, in: cookies) {
|
||||
let sid = String(cookies[substringRange])
|
||||
AccountsModel.setToken(self.account, sid)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
}
|
||||
|
||||
self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private func basePathAppending(_ path: String) -> String {
|
||||
"\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private var cookieHeader: String? {
|
||||
guard let token = account?.token, !token.isEmpty else { return nil }
|
||||
return "SID=\(token)"
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
||||
.withParam("type", category?.type)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
var home: Resource? {
|
||||
resource(baseURL: account.url, path: "/feed/subscriptions")
|
||||
}
|
||||
|
||||
func feed(_ page: Int?) -> Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
.withParam("page", String(page ?? 1))
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page: String?) -> Resource {
|
||||
if page.isNil, contentType == .videos {
|
||||
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
|
||||
}
|
||||
|
||||
var resource = resource(baseURL: account.url, path: basePathAppending("channels/\(id)/\(contentType.invidiousID)"))
|
||||
|
||||
if let page, !page.isEmpty {
|
||||
resource = resource.withParam("continuation", page)
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
func channelByName(_: String) -> Resource? {
|
||||
nil
|
||||
}
|
||||
|
||||
func channelByUsername(_: String) -> Resource? {
|
||||
nil
|
||||
}
|
||||
|
||||
func channelVideos(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
|
||||
}
|
||||
|
||||
func video(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
|
||||
}
|
||||
|
||||
var playlists: Resource? {
|
||||
if account.isNil || account.anonymous {
|
||||
return nil
|
||||
}
|
||||
|
||||
return resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
}
|
||||
|
||||
func playlistVideos(_ id: String) -> Resource? {
|
||||
playlist(id)?.child("videos")
|
||||
}
|
||||
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = playlistVideo(playlistID, index)
|
||||
|
||||
resource?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["title": name, "privacy": visibility]
|
||||
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
||||
|
||||
resource?
|
||||
.request(!playlist.isNil ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
self.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource {
|
||||
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
|
||||
.withParam("q", searchQuery(query.query))
|
||||
.withParam("sort_by", query.sortBy.parameter)
|
||||
.withParam("type", "all")
|
||||
|
||||
if let date = query.date, date != .any {
|
||||
resource = resource.withParam("date", date.rawValue)
|
||||
}
|
||||
|
||||
if let duration = query.duration, duration != .any {
|
||||
resource = resource.withParam("duration", duration.rawValue)
|
||||
}
|
||||
|
||||
if let page {
|
||||
resource = resource.withParam("page", page)
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let resource = resource(baseURL: account.url, path: basePathAppending("comments/\(id)"))
|
||||
guard let page else { return resource }
|
||||
|
||||
return resource.withParam("continuation", page)
|
||||
}
|
||||
|
||||
private func searchQuery(_ query: String) -> String {
|
||||
var searchQuery = query
|
||||
|
||||
let url = URLComponents(string: query)
|
||||
|
||||
if url != nil,
|
||||
url!.host == "youtu.be"
|
||||
{
|
||||
searchQuery = url!.path.replacingOccurrences(of: "/", with: "")
|
||||
}
|
||||
|
||||
let queryItem = url?.queryItems?.first { item in item.name == "v" }
|
||||
if let id = queryItem?.value {
|
||||
searchQuery = id
|
||||
}
|
||||
|
||||
return searchQuery
|
||||
}
|
||||
|
||||
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
|
||||
guard let instanceURLComponents = URLComponents(url: instance.apiURL, resolvingAgainstBaseURL: false),
|
||||
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
|
||||
|
||||
urlComponents.scheme = instanceURLComponents.scheme
|
||||
urlComponents.host = instanceURLComponents.host
|
||||
urlComponents.user = instanceURLComponents.user
|
||||
urlComponents.password = instanceURLComponents.password
|
||||
urlComponents.port = instanceURLComponents.port
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
|
||||
func extractVideo(from json: JSON) -> Video {
|
||||
let indexID: String?
|
||||
var id: Video.ID
|
||||
var published = json["publishedText"].stringValue
|
||||
var publishedAt: Date?
|
||||
|
||||
if let publishedInterval = json["published"].double {
|
||||
publishedAt = Date(timeIntervalSince1970: publishedInterval)
|
||||
published = ""
|
||||
}
|
||||
|
||||
let videoID = json["videoId"].stringValue
|
||||
|
||||
if let index = json["indexId"].string {
|
||||
indexID = index
|
||||
id = videoID + index
|
||||
} else {
|
||||
indexID = nil
|
||||
id = videoID
|
||||
}
|
||||
|
||||
let description = json["description"].stringValue
|
||||
let length = json["lengthSeconds"].doubleValue
|
||||
|
||||
return Video(
|
||||
instanceID: account.instanceID,
|
||||
app: .invidious,
|
||||
instanceURL: account.instance.apiURL,
|
||||
id: id,
|
||||
videoID: videoID,
|
||||
title: json["title"].stringValue,
|
||||
author: json["author"].stringValue,
|
||||
length: length,
|
||||
published: published,
|
||||
views: json["viewCount"].intValue,
|
||||
description: description,
|
||||
genre: json["genre"].stringValue,
|
||||
channel: extractChannel(from: json),
|
||||
thumbnails: extractThumbnails(from: json),
|
||||
indexID: indexID,
|
||||
live: json["liveNow"].boolValue,
|
||||
upcoming: json["isUpcoming"].boolValue,
|
||||
short: length <= Video.shortLength && length != 0.0,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likeCount"].int,
|
||||
dislikes: json["dislikeCount"].int,
|
||||
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json),
|
||||
chapters: createChapters(from: description, thumbnails: json),
|
||||
captions: extractCaptions(from: json)
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannel(from json: JSON) -> Channel {
|
||||
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
|
||||
// append protocol to unproxied thumbnail URL if it's missing
|
||||
if thumbnailURL.count > 2,
|
||||
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//",
|
||||
let accountUrlComponents = URLComponents(string: account.urlString)
|
||||
{
|
||||
thumbnailURL = "\(accountUrlComponents.scheme ?? "https"):\(thumbnailURL)"
|
||||
}
|
||||
|
||||
let tabs = json["tabs"].arrayValue.compactMap { name in
|
||||
if let name = name.string, let type = Channel.ContentType.from(name) {
|
||||
return Channel.Tab(contentType: type, data: "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return Channel(
|
||||
app: .invidious,
|
||||
id: json["authorId"].stringValue,
|
||||
name: json["author"].stringValue,
|
||||
bannerURL: json["authorBanners"].arrayValue.first?.dictionaryValue["url"]?.url,
|
||||
thumbnailURL: URL(string: thumbnailURL),
|
||||
description: json["description"].stringValue,
|
||||
subscriptionsCount: json["subCount"].int,
|
||||
subscriptionsText: json["subCountText"].string,
|
||||
totalViews: json["totalViews"].int,
|
||||
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [],
|
||||
tabs: tabs
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||
let details = json.dictionaryValue
|
||||
return ChannelPlaylist(
|
||||
id: details["playlistId"]?.string ?? details["mixId"]?.string ?? UUID().uuidString,
|
||||
title: details["title"]?.stringValue ?? "",
|
||||
thumbnailURL: details["playlistThumbnail"]?.url,
|
||||
channel: extractChannel(from: json),
|
||||
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [],
|
||||
videosCount: details["videoCount"]?.int
|
||||
)
|
||||
}
|
||||
|
||||
// Determines if the request requires Basic Auth credentials to be removed
|
||||
private func needsBasicAuthRemoval(for path: String) -> Bool {
|
||||
return path.hasPrefix("\(Self.basePath)/auth/")
|
||||
}
|
||||
|
||||
// Creates a resource URL with consideration for removing Basic Auth credentials
|
||||
private func createResourceURL(baseURL: URL, path: String) -> URL {
|
||||
var resourceURL = baseURL
|
||||
|
||||
// Remove Basic Auth credentials if required
|
||||
if needsBasicAuthRemoval(for: path), var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) {
|
||||
urlComponents.user = nil
|
||||
urlComponents.password = nil
|
||||
resourceURL = urlComponents.url ?? baseURL
|
||||
}
|
||||
|
||||
return resourceURL.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
func resourceWithAuthCheck(baseURL: URL, path: String) -> Resource {
|
||||
let sanitizedURL = createResourceURL(baseURL: baseURL, path: path)
|
||||
return super.resource(absoluteURL: sanitizedURL)
|
||||
}
|
||||
|
||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
details["videoThumbnails"].arrayValue.compactMap { json in
|
||||
guard let url = json["url"].url,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let quality = json["quality"].string,
|
||||
let accountUrlComponents = URLComponents(string: account.urlString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Some instances are not configured properly and return thumbnail links
|
||||
// with an incorrect scheme or a missing port.
|
||||
components.scheme = accountUrlComponents.scheme
|
||||
components.port = accountUrlComponents.port
|
||||
|
||||
// If basic HTTP authentication is used,
|
||||
// the username and password need to be prepended to the URL.
|
||||
components.user = accountUrlComponents.user
|
||||
components.password = accountUrlComponents.password
|
||||
|
||||
guard let thumbnailUrl = components.url else {
|
||||
return nil
|
||||
}
|
||||
print("Final thumbnail URL: \(thumbnailUrl)")
|
||||
|
||||
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
|
||||
}
|
||||
}
|
||||
|
||||
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
|
||||
var chapters = extractChapters(from: description)
|
||||
|
||||
if !chapters.isEmpty {
|
||||
let thumbnailsData = extractThumbnails(from: thumbnails)
|
||||
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
|
||||
|
||||
for chapter in chapters.indices {
|
||||
if let url = thumbnailURL {
|
||||
chapters[chapter].image = url
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
||||
|
||||
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
||||
let nextPage = json.dictionaryValue["continuation"]?.string
|
||||
var contentItems = [ContentItem]()
|
||||
|
||||
if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }),
|
||||
let items = json.dictionaryValue[key]
|
||||
{
|
||||
contentItems = extractContentItems(from: items)
|
||||
}
|
||||
|
||||
var last = false
|
||||
if !forceNotLast {
|
||||
last = nextPage?.isEmpty ?? true
|
||||
}
|
||||
|
||||
return ChannelPage(
|
||||
results: contentItems,
|
||||
channel: extractChannel(from: json),
|
||||
nextPage: nextPage,
|
||||
last: last
|
||||
)
|
||||
}
|
||||
|
||||
private func extractStreams(from json: JSON) -> [Stream] {
|
||||
let hls = extractHLSStreams(from: json)
|
||||
if json["liveNow"].boolValue {
|
||||
return hls
|
||||
}
|
||||
let videoId = json["videoId"].stringValue
|
||||
|
||||
return extractFormatStreams(from: json["formatStreams"].arrayValue, videoId: videoId) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue, videoId: videoId) +
|
||||
hls
|
||||
}
|
||||
|
||||
private func extractFormatStreams(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
streams.compactMap { stream in
|
||||
guard let streamURL = stream["url"].url else {
|
||||
return nil
|
||||
}
|
||||
let finalURL: URL
|
||||
if let videoId, let itag = stream["itag"].string, account.instance.invidiousCompanion {
|
||||
let companionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(itag)"
|
||||
finalURL = URL(string: companionURLString) ?? streamURL
|
||||
} else {
|
||||
finalURL = streamURL
|
||||
}
|
||||
|
||||
return SingleAssetStream(
|
||||
instance: account.instance,
|
||||
avAsset: AVURLAsset(url: finalURL),
|
||||
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
||||
kind: .stream,
|
||||
encoding: stream["encoding"].string ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func extractXTags(from urlString: String) -> [String: String] {
|
||||
guard let urlComponents = URLComponents(string: urlString),
|
||||
let queryItems = urlComponents.queryItems,
|
||||
let xtagsValue = queryItems.first(where: { $0.name == "xtags" })?.value else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
guard let decoded = xtagsValue.removingPercentEncoding else { return [:] }
|
||||
|
||||
// Parse key-value pairs (format: key1=value1:key2=value2)
|
||||
// Example: "acont=dubbed-auto:lang=en-US"
|
||||
let pairs = decoded.split(separator: ":")
|
||||
var result: [String: String] = [:]
|
||||
for pair in pairs {
|
||||
let parts = pair.split(separator: "=", maxSplits: 1)
|
||||
if parts.count == 2 {
|
||||
result[String(parts[0])] = String(parts[1])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
let audioTracks = streams
|
||||
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
}
|
||||
.compactMap { audioStream -> Stream.AudioTrack? in
|
||||
guard let url = audioStream["url"].url,
|
||||
let audioItag = audioStream["itag"].string
|
||||
else { return nil }
|
||||
|
||||
let finalURL: URL
|
||||
if let videoId, account.instance.invidiousCompanion {
|
||||
let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)"
|
||||
finalURL = URL(string: audioCompanionURLString) ?? url
|
||||
} else {
|
||||
finalURL = url
|
||||
}
|
||||
|
||||
let xTags = extractXTags(from: url.absoluteString)
|
||||
|
||||
return Stream.AudioTrack(
|
||||
url: finalURL,
|
||||
content: xTags["acont"],
|
||||
language: xTags["lang"]
|
||||
)
|
||||
}
|
||||
.sorted {
|
||||
/// Always prefer original audio streams over dubbed ones
|
||||
!$0.isDubbed && $1.isDubbed
|
||||
}
|
||||
|
||||
guard !audioTracks.isEmpty else {
|
||||
return .init()
|
||||
}
|
||||
|
||||
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||
|
||||
return videoStreams.compactMap { videoStream in
|
||||
guard let videoAssetURL = videoStream["url"].url,
|
||||
let videoItag = videoStream["itag"].string
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let finalVideoURL: URL
|
||||
|
||||
if let videoId, account.instance.invidiousCompanion {
|
||||
let videoCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(videoItag)"
|
||||
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
|
||||
} else {
|
||||
finalVideoURL = videoAssetURL
|
||||
}
|
||||
|
||||
return Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: AVURLAsset(url: audioTracks[0].url),
|
||||
videoAsset: AVURLAsset(url: finalVideoURL),
|
||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int,
|
||||
requestRange: videoStream["init"].string ?? videoStream["index"].string,
|
||||
audioTracks: audioTracks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractHLSStreams(from content: JSON) -> [Stream] {
|
||||
if let hlsURL = content.dictionaryValue["hlsUrl"]?.url {
|
||||
return [Stream(instance: account.instance, hlsURL: hlsURL)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||
let id = content["playlistId"].stringValue
|
||||
return Playlist(
|
||||
id: id,
|
||||
title: content["title"].stringValue,
|
||||
visibility: content["isListed"].boolValue ? .public : .private,
|
||||
editable: id.starts(with: "IV"),
|
||||
updated: content["updated"].doubleValue,
|
||||
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
let htmlContent = details["contentHtml"]?.string ?? ""
|
||||
let decodedContent = decodeHtml(htmlContent)
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: authorAvatarURL,
|
||||
time: details["publishedText"]?.string ?? "",
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: decodedContent,
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
channel: Channel(app: .invidious, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeHtml(_ htmlEncodedString: String) -> String {
|
||||
if let data = htmlEncodedString.data(using: .utf8) {
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
return htmlEncodedString
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { details in
|
||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||
|
||||
return Captions(
|
||||
label: details["label"].stringValue,
|
||||
code: details["language_code"].stringValue,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractContentItems(from json: JSON) -> [ContentItem] {
|
||||
json.arrayValue.compactMap { extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
private func extractContentItem(from json: JSON) -> ContentItem? {
|
||||
let type = json.dictionaryValue["type"]?.string
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: extractChannel(from: json))
|
||||
}
|
||||
if type == "playlist" {
|
||||
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
||||
}
|
||||
if type == "video" {
|
||||
return ContentItem(video: extractVideo(from: json))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Channel.ContentType {
|
||||
var invidiousID: String {
|
||||
switch self {
|
||||
case .livestreams:
|
||||
return "streams"
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
static let basePath = "/api/v1"
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
@Published var validInstance = true
|
||||
|
||||
var signedIn: Bool {
|
||||
guard let account else { return false }
|
||||
|
||||
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> PeerTubeAPI {
|
||||
.init(account: Instance(app: .peerTube, apiURLString: url.absoluteString).anonymousAccount)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard !account.isNil else {
|
||||
self.account = .init(name: "Empty")
|
||||
return
|
||||
}
|
||||
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
validInstance = account.anonymous
|
||||
|
||||
configure()
|
||||
|
||||
if !account.anonymous {
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
validateInstance()
|
||||
validateSID()
|
||||
}
|
||||
|
||||
func validateInstance() {
|
||||
guard !validInstance else {
|
||||
return
|
||||
}
|
||||
|
||||
home?
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
self.validInstance = true
|
||||
}
|
||||
.onFailure { _ in
|
||||
self.validInstance = false
|
||||
}
|
||||
}
|
||||
|
||||
func validateSID() {
|
||||
guard signedIn, !(account.token?.isEmpty ?? true) else {
|
||||
return
|
||||
}
|
||||
|
||||
feed(1)?
|
||||
.load()
|
||||
.onFailure { _ in
|
||||
self.updateToken(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
if let cookie = self.cookieHeader {
|
||||
$0.headers["Cookie"] = cookie
|
||||
}
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure("**", requestMethods: [.post]) {
|
||||
$0.pipeline[.parsing].removeTransformers()
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.dictionaryValue["data"]?.arrayValue.map(self.extractVideo) ?? []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
let results = content.json.dictionaryValue["data"]?.arrayValue.compactMap { json -> ContentItem in .init(video: self.extractVideo(from: json)) } ?? []
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||
return suggestions.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.map(self.extractPlaylist)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
||||
self.extractPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
||||
self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
||||
return feedVideos.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map(self.extractChannel)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
let playlists = (content.json.dictionaryValue["playlists"]?.arrayValue ?? []).compactMap { self.extractChannelPlaylist(from: $0) }
|
||||
return ContentItem.array(of: playlists)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["continuation"]?.string
|
||||
let disabled = !details["error"].isNil
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
updateToken()
|
||||
}
|
||||
|
||||
func updateToken(force: Bool = false) {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let username,
|
||||
let password,
|
||||
!username.isEmpty,
|
||||
!password.isEmpty
|
||||
else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let presentTokenUpdateFailedAlert: (AFDataResponse<Data?>?, String?) -> Void = { response, message in
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
|
||||
)
|
||||
}
|
||||
|
||||
AF
|
||||
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
|
||||
.redirect(using: .doNotFollow)
|
||||
.response { response in
|
||||
guard let headers = response.response?.headers,
|
||||
let cookies = headers["Set-Cookie"]
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(response, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let sidRegex = #"SID=(?<sid>[^;]*);"#
|
||||
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
|
||||
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
return
|
||||
}
|
||||
|
||||
let matchRange = match.range(withName: "sid")
|
||||
|
||||
if let substringRange = Range(matchRange, in: cookies) {
|
||||
let sid = String(cookies[substringRange])
|
||||
AccountsModel.setToken(self.account, sid)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
}
|
||||
|
||||
self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private func basePathAppending(_ path: String) -> String {
|
||||
"\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private var cookieHeader: String? {
|
||||
guard let token = account?.token, !token.isEmpty else { return nil }
|
||||
return "SID=\(token)"
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(country _: Country, category _: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/videos")
|
||||
.withParam("isLocal", "true")
|
||||
// .withParam("type", category?.name)
|
||||
// .withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
var home: Resource? {
|
||||
resource(baseURL: account.url, path: "/feed/subscriptions")
|
||||
}
|
||||
|
||||
func feed(_ page: Int?) -> Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
.withParam("page", String(page ?? 1))
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page _: String?) -> Resource {
|
||||
if contentType == .playlists {
|
||||
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists"))
|
||||
}
|
||||
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
|
||||
}
|
||||
|
||||
func channelByName(_: String) -> Resource? {
|
||||
nil
|
||||
}
|
||||
|
||||
func channelByUsername(_: String) -> Resource? {
|
||||
nil
|
||||
}
|
||||
|
||||
func channelVideos(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
|
||||
}
|
||||
|
||||
func video(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
|
||||
}
|
||||
|
||||
var playlists: Resource? {
|
||||
if account.isNil || account.anonymous {
|
||||
return nil
|
||||
}
|
||||
|
||||
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
}
|
||||
|
||||
func playlistVideos(_ id: String) -> Resource? {
|
||||
playlist(id)?.child("videos")
|
||||
}
|
||||
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = playlistVideo(playlistID, index)
|
||||
|
||||
resource?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["title": name, "privacy": visibility]
|
||||
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
||||
|
||||
resource?
|
||||
.request(!playlist.isNil ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
self.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery, page _: String?) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("search/videos"))
|
||||
.withParam("search", query.query)
|
||||
// .withParam("sort_by", query.sortBy.parameter)
|
||||
// .withParam("type", "all")
|
||||
//
|
||||
// if let date = query.date, date != .any {
|
||||
// resource = resource.withParam("date", date.rawValue)
|
||||
// }
|
||||
//
|
||||
// if let duration = query.duration, duration != .any {
|
||||
// resource = resource.withParam("duration", duration.rawValue)
|
||||
// }
|
||||
//
|
||||
// if let page {
|
||||
// resource = resource.withParam("page", page)
|
||||
// }
|
||||
|
||||
// return resource
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let resource = resource(baseURL: account.url, path: basePathAppending("comments/\(id)"))
|
||||
guard let page else { return resource }
|
||||
|
||||
return resource.withParam("continuation", page)
|
||||
}
|
||||
|
||||
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
|
||||
guard let instanceURLComponents = URLComponents(string: instance.apiURLString),
|
||||
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
|
||||
|
||||
urlComponents.scheme = instanceURLComponents.scheme
|
||||
urlComponents.host = instanceURLComponents.host
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
|
||||
func extractVideo(from json: JSON) -> Video {
|
||||
let id = json["uuid"].stringValue
|
||||
let url = json["url"].url
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let publishedAt = dateFormatter.date(from: json["publishedAt"].stringValue)
|
||||
|
||||
return Video(
|
||||
instanceID: account.instanceID,
|
||||
app: .peerTube,
|
||||
instanceURL: account.instance.apiURL,
|
||||
id: id,
|
||||
videoID: id,
|
||||
videoURL: url,
|
||||
title: json["name"].stringValue,
|
||||
author: json["channel"].dictionaryValue["name"]?.stringValue ?? "",
|
||||
length: json["duration"].doubleValue,
|
||||
views: json["views"].intValue,
|
||||
description: json["description"].stringValue,
|
||||
channel: extractChannel(from: json["channel"]),
|
||||
thumbnails: extractThumbnails(from: json),
|
||||
live: json["isLive"].boolValue,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likes"].int,
|
||||
dislikes: json["dislikes"].int,
|
||||
streams: extractStreams(from: json)
|
||||
// related: extractRelated(from: json),
|
||||
// chapters: extractChapters(from: description),
|
||||
// captions: extractCaptions(from: json)
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannel(from json: JSON) -> Channel {
|
||||
Channel(
|
||||
app: .peerTube,
|
||||
id: json["id"].stringValue,
|
||||
name: json["name"].stringValue
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||
let details = json.dictionaryValue
|
||||
return ChannelPlaylist(
|
||||
id: details["playlistId"]?.string ?? details["mixId"]?.string ?? UUID().uuidString,
|
||||
title: details["title"]?.stringValue ?? "",
|
||||
thumbnailURL: details["playlistThumbnail"]?.url,
|
||||
channel: extractChannel(from: json),
|
||||
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [],
|
||||
videosCount: details["videoCount"]?.int
|
||||
)
|
||||
}
|
||||
|
||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
if let thumbnailPath = details["thumbnailPath"].string {
|
||||
return [Thumbnail(url: URL(string: thumbnailPath, relativeTo: account.url)!, quality: .medium)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func extractStreams(from json: JSON) -> [Stream] {
|
||||
let hls = extractHLSStreams(from: json)
|
||||
|
||||
if json["isLive"].boolValue {
|
||||
return hls
|
||||
}
|
||||
|
||||
return extractFormatStreams(from: json) +
|
||||
extractAdaptiveFormats(from: json) +
|
||||
hls
|
||||
}
|
||||
|
||||
private func extractFormatStreams(from json: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
if let fileURL = json.dictionaryValue["streamingPlaylists"]?.arrayValue.first?
|
||||
.dictionaryValue["files"]?.arrayValue.first?
|
||||
.dictionaryValue["fileUrl"]?.url
|
||||
{
|
||||
let resolution = Stream.Resolution.predefined(.hd720p30)
|
||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream))
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
private func extractAdaptiveFormats(from json: JSON) -> [Stream] {
|
||||
json.dictionaryValue["files"]?.arrayValue.compactMap { file in
|
||||
if let resolution = file.dictionaryValue["resolution"]?.dictionaryValue["label"]?.stringValue, let url = file.dictionaryValue["fileUrl"]?.url {
|
||||
return SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: url), resolution: Stream.Resolution.from(resolution: resolution), kind: .adaptive, videoFormat: "mp4")
|
||||
}
|
||||
|
||||
return nil
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private func extractHLSStreams(from content: JSON) -> [Stream] {
|
||||
if let hlsURL = content.dictionaryValue["streamingPlaylists"]?.arrayValue.first?.dictionaryValue["playlistUrl"]?.url {
|
||||
return [Stream(instance: account.instance, hlsURL: hlsURL)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||
let id = content["playlistId"].stringValue
|
||||
return Playlist(
|
||||
id: id,
|
||||
title: content["title"].stringValue,
|
||||
visibility: content["isListed"].boolValue ? .public : .private,
|
||||
editable: id.starts(with: "IV"),
|
||||
updated: content["updated"].doubleValue,
|
||||
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: authorAvatarURL,
|
||||
time: details["publishedText"]?.string ?? "",
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: details["content"]?.string ?? "",
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
channel: Channel(app: .peerTube, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { _ in
|
||||
nil
|
||||
// let baseURL = account.url
|
||||
// guard let url = URL(string: baseURL + details["url"].stringValue) else { return nil }
|
||||
//
|
||||
// return Captions(
|
||||
// label: details["label"].stringValue,
|
||||
// code: details["language_code"].stringValue,
|
||||
// url: url
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,850 +0,0 @@
|
||||
import Alamofire
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var disallowedVideoCodecs = ["av01"]
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||
static var contentItemsKeys = ["items", "content", "relatedStreams"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> PipedAPI {
|
||||
.init(account: Instance(app: .piped, apiURLString: url.absoluteString).anonymousAccount)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard account != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
configure()
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
|
||||
$0.headers["Authorization"] = self.account.token
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> ChannelPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.string
|
||||
let channel = self.extractChannel(from: content.json)
|
||||
return ChannelPage(
|
||||
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
|
||||
channel: channel,
|
||||
nextPage: nextPage,
|
||||
last: nextPage.isNil
|
||||
)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("/nextpage/channel/*")) { (content: Entity<JSON>) -> ChannelPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.string
|
||||
return ChannelPage(
|
||||
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
|
||||
channel: self.extractChannel(from: content.json),
|
||||
nextPage: nextPage,
|
||||
last: nextPage.isNil
|
||||
)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/tabs*")) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
(content.json.dictionaryValue["content"]?.arrayValue ?? []).compactMap { self.extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("c/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
||||
self.extractVideos(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.string
|
||||
return SearchPage(
|
||||
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
|
||||
nextPage: nextPage,
|
||||
last: nextPage == "null"
|
||||
)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
||||
content.json.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.compactMap { self.extractChannel(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
|
||||
guard let details = content?.json.dictionaryValue else {
|
||||
return CommentsPage(comments: [], nextPage: nil, disabled: true)
|
||||
}
|
||||
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["nextpage"]?.string
|
||||
let disabled = details["disabled"]?.bool ?? false
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.compactMap { self.extractUserPlaylist(from: $0) }
|
||||
}
|
||||
|
||||
if account.token.isNil || account.token!.isEmpty {
|
||||
updateToken()
|
||||
} else {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuthorization(_ url: URL) -> Bool {
|
||||
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
func updateToken() {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
|
||||
guard !account.anonymous,
|
||||
let username,
|
||||
let password
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
AF.request(
|
||||
login.url,
|
||||
method: .post,
|
||||
parameters: ["username": username, "password": password],
|
||||
encoding: JSONEncoding.default
|
||||
)
|
||||
.responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
let json = JSON(value)
|
||||
let token = json.dictionaryValue["token"]?.string ?? ""
|
||||
if let error = json.dictionaryValue["error"]?.string {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error
|
||||
)
|
||||
} else if !token.isEmpty {
|
||||
AccountsModel.setToken(self.account, token)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Could not update your token."
|
||||
)
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
case let .failure(error):
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource {
|
||||
let path = page.isNil ? "channel" : "nextpage/channel"
|
||||
|
||||
var channel: Siesta.Resource
|
||||
|
||||
if contentType == .videos || data.isNil {
|
||||
channel = resource(baseURL: account.url, path: "\(path)/\(id)")
|
||||
} else {
|
||||
channel = resource(baseURL: account.url, path: "channels/tabs")
|
||||
.withParam("data", data)
|
||||
}
|
||||
|
||||
if let page, !page.isEmpty {
|
||||
channel = channel.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
func channelByName(_ name: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: "c/\(name)")
|
||||
}
|
||||
|
||||
func channelByUsername(_ username: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: "user/\(username)")
|
||||
}
|
||||
|
||||
func channelVideos(_ id: String) -> Resource {
|
||||
channel(id, contentType: .videos)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: "playlists/\(id)")
|
||||
}
|
||||
|
||||
func trending(country: Country, category _: TrendingCategory? = nil) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "trending")
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource {
|
||||
let path = page.isNil ? "search" : "nextpage/search"
|
||||
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: path)
|
||||
.withParam("q", query.query)
|
||||
.withParam("filter", "all")
|
||||
|
||||
if page.isNil {
|
||||
return resource
|
||||
}
|
||||
|
||||
return resource.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "suggestions")
|
||||
.withParam("query", query.lowercased())
|
||||
}
|
||||
|
||||
func video(_ id: Video.ID) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
guard let account else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscriptions")
|
||||
}
|
||||
|
||||
func feed(_: Int?) -> Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "feed")
|
||||
.withParam("authToken", account.token)
|
||||
}
|
||||
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "user/playlists")
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
channelPlaylist(id)
|
||||
}
|
||||
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
|
||||
let body = ["videoId": videoID, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
|
||||
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["name": name]
|
||||
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
} else {
|
||||
onSuccess(nil)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
|
||||
let body = ["playlistId": playlist.id]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
|
||||
if page.isNil {
|
||||
return resource
|
||||
}
|
||||
|
||||
return resource.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(path)"
|
||||
}
|
||||
|
||||
private func extractContentItem(from content: JSON) -> ContentItem? {
|
||||
let details = content.dictionaryValue
|
||||
|
||||
let contentType: ContentItem.ContentType
|
||||
|
||||
if let url = details["url"]?.string {
|
||||
if url.contains("/playlist") {
|
||||
contentType = .playlist
|
||||
} else if url.contains("/channel") {
|
||||
contentType = .channel
|
||||
} else {
|
||||
contentType = .video
|
||||
}
|
||||
} else {
|
||||
contentType = .video
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case .video:
|
||||
if let video = extractVideo(from: content) {
|
||||
return ContentItem(video: video)
|
||||
}
|
||||
|
||||
case .playlist:
|
||||
if let playlist = extractChannelPlaylist(from: content) {
|
||||
return ContentItem(playlist: playlist)
|
||||
}
|
||||
|
||||
case .channel:
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extractContentItems(from content: JSON) -> [ContentItem] {
|
||||
content.arrayValue.compactMap { extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
private func extractChannel(from content: JSON) -> Channel? {
|
||||
let attributes = content.dictionaryValue
|
||||
guard let id = attributes["id"]?.string ??
|
||||
(attributes["url"] ?? attributes["uploaderUrl"])?.string?.components(separatedBy: "/").last
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let subscriptionsCount = attributes["subscriberCount"]?.int ?? attributes["subscribers"]?.int
|
||||
|
||||
var videos = [Video]()
|
||||
if let relatedStreams = attributes["relatedStreams"] {
|
||||
videos = extractVideos(from: relatedStreams)
|
||||
}
|
||||
|
||||
let name = attributes["name"]?.string ??
|
||||
attributes["uploaderName"]?.string ??
|
||||
attributes["uploader"]?.string ?? ""
|
||||
|
||||
let thumbnailURL = attributes["avatarUrl"]?.url ??
|
||||
attributes["uploaderAvatar"]?.url ??
|
||||
attributes["avatar"]?.url ??
|
||||
attributes["thumbnail"]?.url
|
||||
|
||||
let tabs = attributes["tabs"]?.arrayValue.compactMap { tab in
|
||||
let name = tab["name"].string
|
||||
let data = tab["data"].string
|
||||
if let name, let data, let type = Channel.ContentType(rawValue: name) {
|
||||
return Channel.Tab(contentType: type, data: data)
|
||||
}
|
||||
|
||||
return nil
|
||||
} ?? [Channel.Tab]()
|
||||
|
||||
return Channel(
|
||||
app: .piped,
|
||||
id: id,
|
||||
name: name,
|
||||
bannerURL: attributes["bannerUrl"]?.url,
|
||||
thumbnailURL: thumbnailURL,
|
||||
subscriptionsCount: subscriptionsCount,
|
||||
verified: attributes["verified"]?.bool,
|
||||
videos: videos,
|
||||
tabs: tabs
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
||||
let details = json.dictionaryValue
|
||||
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last
|
||||
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
|
||||
var videos = [Video]()
|
||||
if let relatedStreams = details["relatedStreams"] {
|
||||
videos = extractVideos(from: relatedStreams)
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id ?? UUID().uuidString,
|
||||
title: details["name"]?.string ?? "",
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json),
|
||||
videos: videos,
|
||||
videosCount: details["videos"]?.int
|
||||
)
|
||||
}
|
||||
|
||||
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
|
||||
let hostValue = hostItem.value
|
||||
else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
urlComponents.host = hostValue
|
||||
|
||||
guard let newUrl = urlComponents.url else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
completion(AVURLAsset(url: newUrl))
|
||||
}
|
||||
|
||||
// Overload used for hlsURLS
|
||||
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
nonProxiedAsset(asset: asset, completion: completion)
|
||||
}
|
||||
|
||||
private func extractVideo(from content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
|
||||
if let url = details["url"]?.string {
|
||||
guard url.contains("/watch") else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let channelId = details["uploaderUrl"]?.string?.components(separatedBy: "/").last ?? "unknown"
|
||||
|
||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
if let url = buildThumbnailURL(from: content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.string ?? details["uploader"]?.string ?? ""
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||
|
||||
let uploaded = details["uploaded"]?.double
|
||||
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
var publishedAt: Date?
|
||||
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||
|
||||
if published.isNil,
|
||||
let date = details["uploadDate"]?.string,
|
||||
let formattedDate = dateFormatter.date(from: date)
|
||||
{
|
||||
publishedAt = formattedDate
|
||||
} else {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
|
||||
}
|
||||
|
||||
let live = details["livestream"]?.bool ?? (details["duration"]?.int == -1)
|
||||
|
||||
let description = extractDescription(from: content) ?? ""
|
||||
|
||||
var chapters = extractChapters(from: content)
|
||||
if chapters.isEmpty, !description.isEmpty {
|
||||
chapters = extractChapters(from: description)
|
||||
}
|
||||
|
||||
let length = details["duration"]?.double ?? 0
|
||||
|
||||
return Video(
|
||||
instanceID: account.instanceID,
|
||||
app: .piped,
|
||||
instanceURL: account.instance.apiURL,
|
||||
videoID: extractID(from: content),
|
||||
title: details["title"]?.string ?? "",
|
||||
author: author,
|
||||
length: length,
|
||||
published: published ?? "",
|
||||
views: details["views"]?.int ?? 0,
|
||||
description: description,
|
||||
channel: Channel(app: .piped, id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
|
||||
publishedAt: publishedAt,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
related: extractRelated(from: content),
|
||||
chapters: extractChapters(from: content),
|
||||
captions: extractCaptions(from: content)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractID(from content: JSON) -> Video.ID {
|
||||
content.dictionaryValue["url"]?.string?.components(separatedBy: "?v=").last ??
|
||||
extractThumbnailURL(from: content)?.relativeString.components(separatedBy: "/")[4] ?? ""
|
||||
}
|
||||
|
||||
private func extractThumbnailURL(from content: JSON) -> URL? {
|
||||
content.dictionaryValue["thumbnail"]?.url ?? content.dictionaryValue["thumbnailUrl"]?.url
|
||||
}
|
||||
|
||||
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
||||
guard let thumbnailURL = extractThumbnailURL(from: content) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URL(
|
||||
string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractUserPlaylist(from json: JSON) -> Playlist? {
|
||||
let id = json["id"].string ?? ""
|
||||
let title = json["name"].string ?? ""
|
||||
let visibility = Playlist.Visibility.private
|
||||
|
||||
return Playlist(id: id, title: title, visibility: visibility)
|
||||
}
|
||||
|
||||
private func extractDescription(from content: JSON) -> String? {
|
||||
guard let description = content.dictionaryValue["description"]?.string else { return nil }
|
||||
|
||||
return replaceHTML(description)
|
||||
}
|
||||
|
||||
private func replaceHTML(_ string: String) -> String {
|
||||
var string = string.replacingOccurrences(
|
||||
of: "<br/>|<br />|<br>",
|
||||
with: "\n",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
let linkRegex = #"(<a\s+(?:[^>]*?\s+)?href=\"[^"]*\">[^<]*<\/a>)"#
|
||||
let hrefRegex = #"href=\"([^"]*)\">"#
|
||||
guard let hrefRegex = try? NSRegularExpression(pattern: hrefRegex) else { return string }
|
||||
string = string.replacingMatches(regex: linkRegex) { matchingGroup in
|
||||
let results = hrefRegex.matches(in: matchingGroup, range: NSRange(matchingGroup.startIndex..., in: matchingGroup))
|
||||
|
||||
if let result = results.first {
|
||||
if let swiftRange = Range(result.range(at: 1), in: matchingGroup) {
|
||||
return String(matchingGroup[swiftRange])
|
||||
}
|
||||
}
|
||||
|
||||
return matchingGroup
|
||||
}
|
||||
|
||||
string = string
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: "",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
return string
|
||||
}
|
||||
|
||||
private func extractVideos(from content: JSON) -> [Video] {
|
||||
content.arrayValue.compactMap(extractVideo(from:))
|
||||
}
|
||||
|
||||
private func extractStreams(from content: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(instance: account.instance, hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
let audioStreams = content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
|
||||
.filter { stream in
|
||||
let type = stream.dictionaryValue["audioTrackType"]?.string
|
||||
return type == nil || type == "ORIGINAL"
|
||||
}
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
} ?? []
|
||||
|
||||
guard let audioStream = audioStreams.first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
for videoStream in videoStreams {
|
||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||
let videoAsset = AVURLAsset(url: videoAssetUrl)
|
||||
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.bool ?? true
|
||||
let quality = videoStream.dictionaryValue["quality"]?.string ?? "unknown"
|
||||
let qualityComponents = quality.components(separatedBy: "p")
|
||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
||||
var requestRange: String?
|
||||
|
||||
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
|
||||
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
|
||||
{
|
||||
requestRange = "\(initStart)-\(initEnd)"
|
||||
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
|
||||
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
|
||||
{
|
||||
requestRange = "\(indexStart)-\(indexEnd)"
|
||||
} else {
|
||||
requestRange = nil
|
||||
}
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: audioAsset,
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate,
|
||||
requestRange: requestRange
|
||||
)
|
||||
)
|
||||
} else {
|
||||
streams.append(
|
||||
SingleAssetStream(
|
||||
instance: account.instance,
|
||||
avAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .stream
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["relatedStreams"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.string ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.string
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
|
||||
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
|
||||
let commentId = details["commentId"]?.string ?? UUID().uuidString
|
||||
|
||||
// Sanity checks: return nil if required data is missing
|
||||
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Comment(
|
||||
id: commentId,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
||||
time: details["commentedTime"]?.string ?? "",
|
||||
pinned: details["pinned"]?.bool ?? false,
|
||||
hearted: details["hearted"]?.bool ?? false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: commentText,
|
||||
repliesPage: details["repliesPage"]?.string,
|
||||
channel: Channel(app: .piped, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractCommentText(from string: String?) -> String {
|
||||
guard let string, !string.isEmpty else { return "" }
|
||||
|
||||
return replaceHTML(string)
|
||||
}
|
||||
|
||||
private func extractChapters(from content: JSON) -> [Chapter] {
|
||||
guard let chapters = content.dictionaryValue["chapters"]?.array else {
|
||||
return .init()
|
||||
}
|
||||
|
||||
return chapters.compactMap { chapter in
|
||||
guard let title = chapter["title"].string,
|
||||
let image = chapter["image"].url,
|
||||
let start = chapter["start"].double
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Chapter(title: title, image: image, start: start)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["subtitles"].arrayValue.compactMap { details in
|
||||
guard let url = details["url"].url,
|
||||
let code = details["code"].string,
|
||||
let label = details["name"].string,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return nil }
|
||||
|
||||
components.queryItems = components.queryItems?.map { item in
|
||||
item.name == "fmt" ? URLQueryItem(name: "fmt", value: "srt") : item
|
||||
}
|
||||
|
||||
guard let newUrl = components.url else { return nil }
|
||||
|
||||
return Captions(label: label, code: code, url: newUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private func contentItemsDictionary(from content: JSON) -> JSON {
|
||||
if let key = Self.contentItemsKeys.first(where: { content.dictionaryValue.keys.contains($0) }),
|
||||
let items = content.dictionaryValue[key]
|
||||
{
|
||||
return items
|
||||
}
|
||||
|
||||
return .null
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
|
||||
protocol VideosAPI {
|
||||
var account: Account! { get }
|
||||
var signedIn: Bool { get }
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource
|
||||
func channelByName(_ name: String) -> Resource?
|
||||
func channelByUsername(_ username: String) -> Resource?
|
||||
func channelVideos(_ id: String) -> Resource
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource
|
||||
func searchSuggestions(query: String) -> Resource
|
||||
|
||||
func video(_ id: Video.ID) -> Resource
|
||||
|
||||
func feed(_ page: Int?) -> Resource?
|
||||
var subscriptions: Resource? { get }
|
||||
var home: Resource? { get }
|
||||
var popular: Resource? { get }
|
||||
var playlists: Resource? { get }
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
|
||||
func playlist(_ id: String) -> Resource?
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
)
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(
|
||||
_ item: PlayerQueueItem,
|
||||
failureHandler: ((RequestError) -> Void)?,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||
)
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
|
||||
extension VideosAPI {
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil, page: String? = nil) -> Resource {
|
||||
channel(id, contentType: contentType, data: data, page: page)
|
||||
}
|
||||
|
||||
func loadDetails(
|
||||
_ item: PlayerQueueItem,
|
||||
failureHandler: ((RequestError) -> Void)? = nil,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }
|
||||
) {
|
||||
guard (item.video?.streams ?? []).isEmpty else {
|
||||
completionHandler(item)
|
||||
return
|
||||
}
|
||||
|
||||
if let video = item.video, video.isLocal {
|
||||
completionHandler(item)
|
||||
return
|
||||
}
|
||||
|
||||
video(item.videoID).load()
|
||||
.onSuccess { response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
VideosCacheModel.shared.storeVideo(video)
|
||||
|
||||
var newItem = item
|
||||
newItem.id = UUID()
|
||||
newItem.video = video
|
||||
|
||||
completionHandler(newItem)
|
||||
}
|
||||
.onFailure { failureHandler?($0) }
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
var urlComponents: URLComponents?
|
||||
if let frontendURLString,
|
||||
let frontendURL = URL(string: frontendURLString)
|
||||
{
|
||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
guard var urlComponents else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
switch item.contentType {
|
||||
case .video:
|
||||
urlComponents.path = "/watch"
|
||||
queryItems.append(.init(name: "v", value: item.video.videoID))
|
||||
case .channel:
|
||||
urlComponents.path = "/channel/\(item.channel.id)"
|
||||
case .playlist:
|
||||
urlComponents.path = "/playlist"
|
||||
queryItems.append(.init(name: "list", value: item.playlist.id))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if !time.isNil, time!.seconds.isFinite {
|
||||
queryItems.append(.init(name: "t", value: "\(Int(time!.seconds))s"))
|
||||
}
|
||||
|
||||
if !queryItems.isEmpty {
|
||||
urlComponents.queryItems = queryItems
|
||||
}
|
||||
|
||||
return urlComponents.url
|
||||
}
|
||||
|
||||
func extractChapters(from description: String) -> [Chapter] {
|
||||
/*
|
||||
The following chapter patterns are covered:
|
||||
|
||||
1) "start - end - title" / "start - end: Title" / "start - end title"
|
||||
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
||||
3) "index. title - start" / "index. title start"
|
||||
4) "title: (start)"
|
||||
5) "(start) title"
|
||||
|
||||
These represent:
|
||||
|
||||
- "start" and "end" are timestamps, defining the start and end of the individual chapter
|
||||
- "title" is the name of the chapter
|
||||
- "index" is the chapter's position in a list
|
||||
|
||||
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
|
||||
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
|
||||
*/
|
||||
let patterns = [
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
|
||||
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
|
||||
]
|
||||
|
||||
let extractChaptersGroup = DispatchGroup()
|
||||
var capturedChapters: [Int: [Chapter]] = [:]
|
||||
let lock = NSLock()
|
||||
|
||||
for (index, pattern) in patterns.enumerated() {
|
||||
extractChaptersGroup.enter()
|
||||
DispatchQueue.global().async {
|
||||
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return Chapter(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
|
||||
if !extractedChapters.isEmpty {
|
||||
lock.lock()
|
||||
capturedChapters[index] = extractedChapters
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
extractChaptersGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
extractChaptersGroup.wait()
|
||||
|
||||
// Now we sort the keys of the capturedChapters dictionary.
|
||||
// These keys correspond to the priority of each pattern.
|
||||
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
||||
|
||||
// Return first non-empty result in the order of patterns
|
||||
for key in sortedKeys {
|
||||
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum VideosApp: String, CaseIterable {
|
||||
enum AppType: String {
|
||||
case local
|
||||
case youTube
|
||||
case peerTube
|
||||
}
|
||||
|
||||
case local
|
||||
case invidious
|
||||
case piped
|
||||
case peerTube
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .peerTube:
|
||||
return "PeerTube"
|
||||
default:
|
||||
return rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
var appType: AppType {
|
||||
switch self {
|
||||
case .local:
|
||||
return .local
|
||||
case .invidious:
|
||||
return .youTube
|
||||
case .piped:
|
||||
return .youTube
|
||||
case .peerTube:
|
||||
return .peerTube
|
||||
}
|
||||
}
|
||||
|
||||
var supportsAccounts: Bool {
|
||||
self != .local
|
||||
}
|
||||
|
||||
var supportsPopular: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsSearchFilters: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsSearchSuggestions: Bool {
|
||||
self != .peerTube
|
||||
}
|
||||
|
||||
var supportsSubscriptions: Bool {
|
||||
supportsAccounts
|
||||
}
|
||||
|
||||
var paginatesSubscriptions: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsTrendingCategories: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
self != .local
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsUseChannelPlaylistEndpoint: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var hasFrontendURL: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsOpeningChannelsByName: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
self == .invidious || self == .piped
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
self != .local
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct BaseCacheModel {
|
||||
static var shared = Self()
|
||||
|
||||
static let jsonToDataTransformer: (JSON) -> Data = { try! $0.rawData() }
|
||||
static let jsonFromDataTransformer: (Data) -> JSON = { try! JSON(data: $0) }
|
||||
static let jsonTransformer = Transformer(toData: jsonToDataTransformer, fromData: jsonFromDataTransformer)
|
||||
|
||||
static let imageCache = URLCache(memoryCapacity: 512 * 1000 * 1000, diskCapacity: 10 * 1000 * 1000 * 1000)
|
||||
|
||||
var models: [CacheModel] {
|
||||
[
|
||||
FeedCacheModel.shared,
|
||||
VideosCacheModel.shared,
|
||||
ChannelsCacheModel.shared,
|
||||
PlaylistsCacheModel.shared,
|
||||
ChannelPlaylistsCacheModel.shared,
|
||||
SubscribedChannelsModel.shared
|
||||
]
|
||||
}
|
||||
|
||||
func clear() {
|
||||
models.forEach { $0.clear() }
|
||||
|
||||
Self.imageCache.removeAllCachedResponses()
|
||||
}
|
||||
|
||||
var totalSize: Int {
|
||||
models.compactMap { $0.storage?.totalDiskStorageSize }.reduce(0, +) + Self.imageCache.currentDiskUsage
|
||||
}
|
||||
|
||||
var totalSizeFormatted: String {
|
||||
byteCountFormatter.string(fromByteCount: Int64(totalSize))
|
||||
}
|
||||
|
||||
private var byteCountFormatter: ByteCountFormatter { .init() }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct BookmarksCacheModel {
|
||||
static var shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache")
|
||||
|
||||
static let bookmarksGroup = "group.stream.yattee.app.bookmarks"
|
||||
let defaults = UserDefaults(suiteName: Self.bookmarksGroup)
|
||||
|
||||
func clear() {
|
||||
guard let defaults else { return }
|
||||
defaults.dictionaryRepresentation().keys.forEach(defaults.removeObject(forKey:))
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
protocol CacheModel {
|
||||
var storage: Storage<String, JSON>? { get }
|
||||
|
||||
func clear()
|
||||
}
|
||||
|
||||
extension CacheModel {
|
||||
func clear() {
|
||||
try? storage?.removeAll()
|
||||
}
|
||||
|
||||
func getFormattedDate(_ date: Date?) -> String {
|
||||
guard let date else { return "unknown" }
|
||||
|
||||
let isSameDay = Calendar(identifier: .iso8601).isDate(date, inSameDayAs: Date())
|
||||
let formatter = isSameDay ? dateFormatterForTimeOnly : dateFormatter
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
var dateFormatterForTimeOnly: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
var iso8601DateFormatter: ISO8601DateFormatter { .init() }
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelPlaylistsCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.channel-playlists")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channel-playlists")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
var storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storePlaylist(playlist: ChannelPlaylist) {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("STORE \(playlist.cacheKey) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let playlistObject: JSON = ["playlist": playlist.json.object]
|
||||
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(playlist.cacheKey))
|
||||
try? storage?.setObject(playlistObject, forKey: playlist.cacheKey)
|
||||
}
|
||||
|
||||
func retrievePlaylist(_ playlist: ChannelPlaylist) -> ChannelPlaylist? {
|
||||
logger.info("RETRIEVE \(playlist.cacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: playlist.cacheKey).dictionaryValue["playlist"] {
|
||||
return ChannelPlaylist.from(json)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPlaylistsTime(_ id: ChannelPlaylist.ID) -> Date? {
|
||||
if let json = try? storage?.object(forKey: playlistTimeCacheKey(id)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFormattedPlaylistTime(_ id: ChannelPlaylist.ID) -> String {
|
||||
getFormattedDate(getPlaylistsTime(id))
|
||||
}
|
||||
|
||||
private func playlistTimeCacheKey(_ cacheKey: ChannelPlaylist.ID) -> String {
|
||||
"\(cacheKey)-time"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelsCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.channels")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channels")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func store(_ channel: Channel) {
|
||||
guard channel.hasExtendedDetails else {
|
||||
logger.debug("not caching \(channel.cacheKey)")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("caching \(channel.cacheKey)")
|
||||
try? storage?.setObject(channel.json, forKey: channel.cacheKey)
|
||||
}
|
||||
|
||||
func storeIfMissing(_ channel: Channel) {
|
||||
guard let storage, !storage.objectExists(forKey: channel.cacheKey) else {
|
||||
return
|
||||
}
|
||||
|
||||
store(channel)
|
||||
}
|
||||
|
||||
func retrieve(_ cacheKey: String) -> ChannelPage? {
|
||||
logger.debug("retrieving cache for \(cacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: cacheKey) {
|
||||
return ChannelPage(channel: Channel.from(json))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import Cache
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct FeedCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.feed")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "feed")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storeFeed(account: Account, videos: [Video]) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching feed \(account.feedCacheKey) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map(\.json.object)]
|
||||
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
|
||||
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveFeed(account: Account) -> [Video] {
|
||||
logger.debug("retrieving cache for \(account.feedCacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: account.feedCacheKey),
|
||||
let videos = json.dictionaryValue["videos"]
|
||||
{
|
||||
return videos.arrayValue.map { Video.from($0) }
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func getFeedTime(account: Account) -> Date? {
|
||||
if let json = try? storage?.object(forKey: feedTimeCacheKey(account.feedCacheKey)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var cacheLimit: Int {
|
||||
let setting = Int(Defaults[.feedCacheSize]) ?? 0
|
||||
if setting > 0 {
|
||||
return setting
|
||||
}
|
||||
|
||||
return 50
|
||||
}
|
||||
|
||||
private func feedTimeCacheKey(_ feedCacheKey: String) -> String {
|
||||
"\(feedCacheKey)-feedTime"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct PlaylistsCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
static let limit = 30
|
||||
let logger = Logger(label: "stream.yattee.cache.playlists")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "playlists")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storePlaylist(account: Account, playlists: [Playlist]) {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching \(playlistCacheKey(account)) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let playlistsObject: JSON = ["playlists": playlists.map(\.json.object)]
|
||||
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account))
|
||||
try? storage?.setObject(playlistsObject, forKey: playlistCacheKey(account))
|
||||
}
|
||||
|
||||
func retrievePlaylists(account: Account) -> [Playlist] {
|
||||
logger.debug("retrieving cache for \(playlistCacheKey(account))")
|
||||
|
||||
if let json = try? storage?.object(forKey: playlistCacheKey(account)),
|
||||
let playlists = json.dictionaryValue["playlists"]
|
||||
{
|
||||
return playlists.arrayValue.map { Playlist.from($0) }
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func getPlaylistsTime(account: Account) -> Date? {
|
||||
if let json = try? storage?.object(forKey: playlistTimeCacheKey(account)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFormattedPlaylistTime(account: Account) -> String {
|
||||
getFormattedDate(getPlaylistsTime(account: account))
|
||||
}
|
||||
|
||||
private func playlistCacheKey(_ account: Account) -> String {
|
||||
"playlists-\(account.id)"
|
||||
}
|
||||
|
||||
private func playlistTimeCacheKey(_ account: Account) -> String {
|
||||
"\(playlistCacheKey(account))-time"
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
static var shared = SubscribedChannelsModel()
|
||||
let logger = Logger(label: "stream.yattee.cache.channels")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channels")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: SubscribedChannelsModel.diskConfig,
|
||||
memoryConfig: SubscribedChannelsModel.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var channels = [Channel]()
|
||||
@Published var error: RequestError?
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
var unwatchedFeedCount: UnwatchedFeedCountModel { .shared }
|
||||
|
||||
var resource: Resource? {
|
||||
accounts.api.subscriptions
|
||||
}
|
||||
|
||||
var all: [Channel] {
|
||||
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
var allByUnwatchedCount: [Channel] {
|
||||
if let account = accounts.current {
|
||||
return all.sorted { c1, c2 in
|
||||
let c1HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c1.id] ?? -1) > 0
|
||||
let c2HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c2.id] ?? -1) > 0
|
||||
let nameIncreasing = c1.name.lowercased() < c2.name.lowercased()
|
||||
|
||||
return c1HasUnwatched ? (c2HasUnwatched ? nameIncreasing : true) : (c2HasUnwatched ? false : nameIncreasing)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.subscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.unsubscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func isSubscribing(_ channelID: String) -> Bool {
|
||||
channels.contains { $0.id == channelID }
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard accounts.app.supportsSubscriptions, !isLoading, accounts.signedIn, let account = accounts.current else {
|
||||
channels = []
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let request = force ? self.resource?.load() : self.resource?.loadIfNeeded()
|
||||
guard request != nil else { return }
|
||||
|
||||
self.loadCachedChannels(account)
|
||||
|
||||
self.isLoading = true
|
||||
|
||||
request?
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
}
|
||||
.onSuccess { resource in
|
||||
self.error = nil
|
||||
if let channels: [Channel] = resource.typedContent() {
|
||||
self.channels = channels
|
||||
self.storeChannels(account: account, channels: channels)
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
.onFailure { self.error = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
func loadCachedChannels(_ account: Account) {
|
||||
let cache = getChannels(account: account)
|
||||
if !cache.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.channels = cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func storeChannels(account: Account, channels: [Channel]) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let date = self.iso8601DateFormatter.string(from: Date())
|
||||
self.logger.info("caching channels \(self.channelsDateCacheKey(account)) -- \(date)")
|
||||
|
||||
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
||||
|
||||
let dateObject: JSON = ["date": date]
|
||||
let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)]
|
||||
|
||||
try? self.storage?.setObject(dateObject, forKey: self.channelsDateCacheKey(account))
|
||||
try? self.storage?.setObject(channelsObject, forKey: self.channelsCacheKey(account))
|
||||
}
|
||||
}
|
||||
|
||||
func getChannels(account: Account) -> [Channel] {
|
||||
logger.info("getting channels \(channelsDateCacheKey(account))")
|
||||
|
||||
if let json = try? storage?.object(forKey: channelsCacheKey(account)),
|
||||
let channels = json.dictionaryValue["channels"]
|
||||
{
|
||||
return channels.arrayValue.compactMap { json in
|
||||
let channel = Channel.from(json)
|
||||
if !channel.hasExtendedDetails,
|
||||
let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey)
|
||||
{
|
||||
return cache.channel
|
||||
}
|
||||
|
||||
return channel
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private func scheduleLoad(onSuccess: @escaping () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.load(force: true, onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
private func channelsCacheKey(_ account: Account) -> String {
|
||||
"channels-\(account.id)"
|
||||
}
|
||||
|
||||
private func channelsDateCacheKey(_ account: Account) -> String {
|
||||
"channels-\(account.id)-date"
|
||||
}
|
||||
|
||||
func getChannelsTime(account: Account) -> Date? {
|
||||
if let json = try? storage?.object(forKey: channelsDateCacheKey(account)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var channelsTime: Date? {
|
||||
if let account = accounts.current {
|
||||
return getChannelsTime(account: account)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var formattedCacheTime: String {
|
||||
getFormattedDate(channelsTime)
|
||||
}
|
||||
|
||||
func onAccountChange() {
|
||||
channels = []
|
||||
load(force: true)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct VideosCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.videos")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "videos")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storeVideo(_ video: Video) {
|
||||
logger.info("caching \(video.cacheKey)")
|
||||
try? storage?.setObject(video.json, forKey: video.cacheKey)
|
||||
|
||||
ChannelsCacheModel.shared.storeIfMissing(video.channel)
|
||||
}
|
||||
|
||||
func retrieveVideo(_ cacheKey: String) -> Video? {
|
||||
logger.debug("retrieving cache for \(cacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: cacheKey) {
|
||||
return Video.from(json)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Captions: Hashable, Identifiable {
|
||||
var id = UUID().uuidString
|
||||
let label: String
|
||||
let code: String
|
||||
let url: URL
|
||||
|
||||
var description: String {
|
||||
"\(label) (\(code))"
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct Channel: Identifiable, Hashable {
|
||||
enum ContentType: String, Identifiable, CaseIterable {
|
||||
case videos
|
||||
case playlists
|
||||
case livestreams
|
||||
case shorts
|
||||
case channels
|
||||
case releases
|
||||
case podcasts
|
||||
|
||||
static func from(_ name: String) -> Self? {
|
||||
let rawValueMatch = allCases.first { $0.rawValue == name }
|
||||
guard rawValueMatch.isNil else { return rawValueMatch! }
|
||||
|
||||
if name == "streams" { return .livestreams }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .livestreams:
|
||||
return "Live Streams".localized()
|
||||
default:
|
||||
return rawValue.capitalized.localized()
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .videos:
|
||||
return "video"
|
||||
case .playlists:
|
||||
return "list.and.film"
|
||||
case .livestreams:
|
||||
return "dot.radiowaves.left.and.right"
|
||||
case .shorts:
|
||||
return "1.square"
|
||||
case .channels:
|
||||
return "person.3"
|
||||
case .releases:
|
||||
return "square.stack"
|
||||
case .podcasts:
|
||||
return "radio"
|
||||
}
|
||||
}
|
||||
|
||||
var alwaysAvailable: Bool {
|
||||
self == .videos || self == .playlists
|
||||
}
|
||||
}
|
||||
|
||||
struct Tab: Identifiable, Hashable {
|
||||
var contentType: ContentType
|
||||
var data: String
|
||||
|
||||
var id: String {
|
||||
contentType.id
|
||||
}
|
||||
}
|
||||
|
||||
var app: VideosApp
|
||||
var instanceID: Instance.ID?
|
||||
var instanceURL: URL?
|
||||
|
||||
var id: String
|
||||
var name: String
|
||||
var bannerURL: URL?
|
||||
var thumbnailURL: URL?
|
||||
var description = ""
|
||||
|
||||
var subscriptionsCount: Int?
|
||||
var subscriptionsText: String?
|
||||
|
||||
var totalViews: Int?
|
||||
// swiftlint:disable discouraged_optional_boolean
|
||||
var verified: Bool?
|
||||
// swiftlint:enable discouraged_optional_boolean
|
||||
|
||||
var videos = [Video]()
|
||||
var tabs = [Tab]()
|
||||
|
||||
var detailsLoaded: Bool {
|
||||
!subscriptionsString.isNil
|
||||
}
|
||||
|
||||
var subscriptionsString: String? {
|
||||
if let subscriptionsCount, subscriptionsCount > 0 {
|
||||
return subscriptionsCount.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
return subscriptionsText
|
||||
}
|
||||
|
||||
var totalViewsString: String? {
|
||||
guard let totalViews, totalViews > 0 else { return nil }
|
||||
|
||||
return totalViews.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
var contentItem: ContentItem {
|
||||
ContentItem(channel: self)
|
||||
}
|
||||
|
||||
func hasData(for contentType: ContentType) -> Bool {
|
||||
tabs.contains { $0.contentType == contentType }
|
||||
}
|
||||
|
||||
var cacheKey: String {
|
||||
switch app {
|
||||
case .local:
|
||||
return id
|
||||
case .invidious:
|
||||
return "youtube-\(id)"
|
||||
case .piped:
|
||||
return "youtube-\(id)"
|
||||
case .peerTube:
|
||||
return "peertube-\(instanceURL?.absoluteString ?? "unknown-instance")-\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
var hasExtendedDetails: Bool {
|
||||
thumbnailURL != nil
|
||||
}
|
||||
|
||||
var thumbnailURLOrCached: URL? {
|
||||
thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.channel?.thumbnailURL
|
||||
}
|
||||
|
||||
var json: JSON {
|
||||
[
|
||||
"app": app.rawValue,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"bannerURL": bannerURL?.absoluteString as Any,
|
||||
"thumbnailURL": thumbnailURL?.absoluteString as Any,
|
||||
"description": description,
|
||||
"subscriptionsCount": subscriptionsCount as Any,
|
||||
"subscriptionsText": subscriptionsText as Any,
|
||||
"totalViews": totalViews as Any,
|
||||
"verified": verified as Any,
|
||||
"videos": videos.map(\.json.object)
|
||||
]
|
||||
}
|
||||
|
||||
static func from(_ json: JSON) -> Self {
|
||||
.init(
|
||||
app: VideosApp(rawValue: json["app"].stringValue) ?? .local,
|
||||
id: json["id"].stringValue,
|
||||
name: json["name"].stringValue,
|
||||
bannerURL: json["bannerURL"].url,
|
||||
thumbnailURL: json["thumbnailURL"].url,
|
||||
description: json["description"].stringValue,
|
||||
subscriptionsCount: json["subscriptionsCount"].int,
|
||||
subscriptionsText: json["subscriptionsText"].string,
|
||||
totalViews: json["totalViews"].int,
|
||||
videos: json["videos"].arrayValue.map { Video.from($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct ChannelPage {
|
||||
var results = [ContentItem]()
|
||||
var channel: Channel?
|
||||
var nextPage: String?
|
||||
var last = false
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelPlaylist: Identifiable {
|
||||
var id: String
|
||||
var title: String
|
||||
var thumbnailURL: URL?
|
||||
var channel: Channel?
|
||||
var videos = [Video]()
|
||||
var videosCount: Int?
|
||||
|
||||
var cacheKey: String {
|
||||
"channelplaylists-\(id)"
|
||||
}
|
||||
|
||||
var json: JSON {
|
||||
[
|
||||
"id": id,
|
||||
"title": title,
|
||||
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
|
||||
"channel": channel?.json.object ?? "",
|
||||
"videos": videos.map(\.json.object),
|
||||
"videosCount": String(videosCount ?? 0)
|
||||
]
|
||||
}
|
||||
|
||||
static func from(_ json: JSON) -> Self {
|
||||
Self(
|
||||
id: json["id"].stringValue,
|
||||
title: json["title"].stringValue,
|
||||
thumbnailURL: json["thumbnailURL"].url,
|
||||
channel: Channel.from(json["channel"]),
|
||||
videos: json["videos"].arrayValue.map { Video.from($0) },
|
||||
videosCount: json["videosCount"].int
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Chapter: Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var title: String
|
||||
var image: URL?
|
||||
var start: Double
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
struct Comment: Identifiable, Equatable {
|
||||
let id: String
|
||||
let author: String
|
||||
let authorAvatarURL: String
|
||||
let time: String
|
||||
let pinned: Bool
|
||||
let hearted: Bool
|
||||
var likeCount: Int
|
||||
let text: String
|
||||
let repliesPage: String?
|
||||
let channel: Channel
|
||||
|
||||
var hasReplies: Bool {
|
||||
!(repliesPage?.isEmpty ?? true)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class CommentsModel: ObservableObject {
|
||||
static let shared = CommentsModel()
|
||||
|
||||
@Published var all = [Comment]()
|
||||
|
||||
@Published var nextPage: String?
|
||||
@Published var firstPage = true
|
||||
|
||||
@Published var loaded = false
|
||||
@Published var disabled = false
|
||||
|
||||
@Published var replies = [Comment]()
|
||||
@Published var repliesPageID: String?
|
||||
@Published var repliesLoaded = false
|
||||
|
||||
var player = PlayerModel.shared
|
||||
var accounts = AccountsModel.shared
|
||||
|
||||
var instance: Instance? {
|
||||
accounts.current?.instance
|
||||
}
|
||||
|
||||
var nextPageAvailable: Bool {
|
||||
!(nextPage?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func loadIfNeeded() {
|
||||
guard !loaded else { return }
|
||||
load()
|
||||
}
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard let video = player.currentVideo else { return }
|
||||
guard firstPage || nextPageAvailable else { return }
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
guard let self else { return }
|
||||
if let commentsPage: CommentsPage = response.typedContent() {
|
||||
self.all += commentsPage.comments
|
||||
self.nextPage = commentsPage.nextPage
|
||||
self.disabled = commentsPage.disabled
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] _ in
|
||||
self?.disabled = true
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPageIfNeeded(current comment: Comment) {
|
||||
let thresholdIndex = all.index(all.endIndex, offsetBy: -5)
|
||||
if all.firstIndex(where: { $0 == comment }) == thresholdIndex {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard nextPageAvailable else { return }
|
||||
load(page: nextPage)
|
||||
}
|
||||
|
||||
func loadReplies(page: String) {
|
||||
guard !player.currentVideo.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
if page == repliesPageID {
|
||||
return
|
||||
}
|
||||
|
||||
replies = []
|
||||
repliesPageID = page
|
||||
repliesLoaded = false
|
||||
|
||||
accounts.api.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.replies = page.comments
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] _ in
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
all = []
|
||||
disabled = false
|
||||
firstPage = true
|
||||
nextPage = nil
|
||||
loaded = false
|
||||
replies = []
|
||||
repliesLoaded = false
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct CommentsPage {
|
||||
var comments = [Comment]()
|
||||
var nextPage: String?
|
||||
var disabled = false
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct ContentItem: Identifiable {
|
||||
enum ContentType: String {
|
||||
case video, playlist, channel, placeholder
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .channel:
|
||||
return 1
|
||||
case .playlist:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
static var placeholders: [Self] {
|
||||
(0 ..< 9).map { i in .init(id: String(i)) }
|
||||
}
|
||||
|
||||
var video: Video!
|
||||
var playlist: ChannelPlaylist!
|
||||
var channel: Channel!
|
||||
|
||||
var id: String = UUID().uuidString
|
||||
|
||||
static func array(of videos: [Video]) -> [Self] {
|
||||
videos.map { Self(video: $0) }
|
||||
}
|
||||
|
||||
static func array(of playlists: [ChannelPlaylist]) -> [Self] {
|
||||
playlists.map { Self(playlist: $0) }
|
||||
}
|
||||
|
||||
static func array(of channels: [Channel]) -> [Self] {
|
||||
channels.map { Self(channel: $0) }
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.contentType < rhs.contentType
|
||||
}
|
||||
|
||||
var contentType: ContentType {
|
||||
video.isNil ? (channel.isNil ? (playlist.isNil ? .placeholder : .playlist) : .channel) : .video
|
||||
}
|
||||
|
||||
var cacheKey: String {
|
||||
switch contentType {
|
||||
case .video:
|
||||
return video.cacheKey
|
||||
case .playlist:
|
||||
return playlist.cacheKey
|
||||
case .channel:
|
||||
return channel.cacheKey
|
||||
case .placeholder:
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// swiftlint:disable switch_case_on_newline
|
||||
import Defaults
|
||||
|
||||
enum Country: String, CaseIterable, Identifiable, Hashable, Defaults.Serializable {
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
case dz = "DZ"
|
||||
case ar = "AR"
|
||||
case au = "AU"
|
||||
case at = "AT"
|
||||
case az = "AZ"
|
||||
case bh = "BH"
|
||||
case bd = "BD"
|
||||
case by = "BY"
|
||||
case be = "BE"
|
||||
case bo = "BO"
|
||||
case ba = "BA"
|
||||
case br = "BR"
|
||||
case bg = "BG"
|
||||
case ca = "CA"
|
||||
case cl = "CL"
|
||||
case co = "CO"
|
||||
case cr = "CR"
|
||||
case hr = "HR"
|
||||
case cy = "CY"
|
||||
case cz = "CZ"
|
||||
case dk = "DK"
|
||||
case `do` = "DO"
|
||||
case ec = "EC"
|
||||
case eg = "EG"
|
||||
case sv = "SV"
|
||||
case ee = "EE"
|
||||
case fi = "FI"
|
||||
case fr = "FR"
|
||||
case ge = "GE"
|
||||
case de = "DE"
|
||||
case gh = "GH"
|
||||
case gr = "GR"
|
||||
case gt = "GT"
|
||||
case hn = "HN"
|
||||
case hk = "HK"
|
||||
case hu = "HU"
|
||||
case `is` = "IS"
|
||||
case `in` = "IN"
|
||||
case id = "ID"
|
||||
case iq = "IQ"
|
||||
case ie = "IE"
|
||||
case il = "IL"
|
||||
case it = "IT"
|
||||
case jm = "JM"
|
||||
case jp = "JP"
|
||||
case jo = "JO"
|
||||
case kz = "KZ"
|
||||
case ke = "KE"
|
||||
case kr = "KR"
|
||||
case kw = "KW"
|
||||
case lv = "LV"
|
||||
case lb = "LB"
|
||||
case ly = "LY"
|
||||
case li = "LI"
|
||||
case lt = "LT"
|
||||
case lu = "LU"
|
||||
case mk = "MK"
|
||||
case my = "MY"
|
||||
case mt = "MT"
|
||||
case mx = "MX"
|
||||
case me = "ME"
|
||||
case ma = "MA"
|
||||
case np = "NP"
|
||||
case nl = "NL"
|
||||
case nz = "NZ"
|
||||
case ni = "NI"
|
||||
case ng = "NG"
|
||||
case no = "NO"
|
||||
case om = "OM"
|
||||
case pk = "PK"
|
||||
case pa = "PA"
|
||||
case pg = "PG"
|
||||
case py = "PY"
|
||||
case pe = "PE"
|
||||
case ph = "PH"
|
||||
case pl = "PL"
|
||||
case pt = "PT"
|
||||
case pr = "PR"
|
||||
case qa = "QA"
|
||||
case ro = "RO"
|
||||
case ru = "RU"
|
||||
case sa = "SA"
|
||||
case sn = "SN"
|
||||
case rs = "RS"
|
||||
case sg = "SG"
|
||||
case sk = "SK"
|
||||
case si = "SI"
|
||||
case za = "ZA"
|
||||
case es = "ES"
|
||||
case lk = "LK"
|
||||
case se = "SE"
|
||||
case ch = "CH"
|
||||
case tw = "TW"
|
||||
case tz = "TZ"
|
||||
case th = "TH"
|
||||
case tn = "TN"
|
||||
case tr = "TR"
|
||||
case ug = "UG"
|
||||
case ua = "UA"
|
||||
case ae = "AE"
|
||||
case gb = "GB"
|
||||
case us = "US"
|
||||
case uy = "UY"
|
||||
case ve = "VE"
|
||||
case vn = "VN"
|
||||
case vi = "VI"
|
||||
case ye = "YE"
|
||||
case zw = "ZW"
|
||||
}
|
||||
|
||||
extension Country {
|
||||
var name: String {
|
||||
switch self {
|
||||
case .dz: return "Algeria"
|
||||
case .ar: return "Argentina"
|
||||
case .au: return "Australia"
|
||||
case .at: return "Austria"
|
||||
case .az: return "Azerbaijan"
|
||||
case .bh: return "Bahrain"
|
||||
case .bd: return "Bangladesh"
|
||||
case .by: return "Belarus"
|
||||
case .be: return "Belgium"
|
||||
case .bo: return "Bolivia (Plurinational State of)"
|
||||
case .ba: return "Bosnia and Herzegovina"
|
||||
case .br: return "Brazil"
|
||||
case .bg: return "Bulgaria"
|
||||
case .ca: return "Canada"
|
||||
case .cl: return "Chile"
|
||||
case .co: return "Colombia"
|
||||
case .cr: return "Costa Rica"
|
||||
case .hr: return "Croatia"
|
||||
case .cy: return "Cyprus"
|
||||
case .cz: return "Czechia"
|
||||
case .dk: return "Denmark"
|
||||
case .do: return "Dominican Republic"
|
||||
case .ec: return "Ecuador"
|
||||
case .eg: return "Egypt"
|
||||
case .sv: return "El Salvador"
|
||||
case .ee: return "Estonia"
|
||||
case .fi: return "Finland"
|
||||
case .fr: return "France"
|
||||
case .ge: return "Georgia"
|
||||
case .de: return "Germany"
|
||||
case .gh: return "Ghana"
|
||||
case .gr: return "Greece"
|
||||
case .gt: return "Guatemala"
|
||||
case .hn: return "Honduras"
|
||||
case .hk: return "Hong Kong"
|
||||
case .hu: return "Hungary"
|
||||
case .is: return "Iceland"
|
||||
case .in: return "India"
|
||||
case .id: return "Indonesia"
|
||||
case .iq: return "Iraq"
|
||||
case .ie: return "Ireland"
|
||||
case .il: return "Israel"
|
||||
case .it: return "Italy"
|
||||
case .jm: return "Jamaica"
|
||||
case .jp: return "Japan"
|
||||
case .jo: return "Jordan"
|
||||
case .kz: return "Kazakhstan"
|
||||
case .ke: return "Kenya"
|
||||
case .kr: return "Korea (Republic of)"
|
||||
case .kw: return "Kuwait"
|
||||
case .lv: return "Latvia"
|
||||
case .lb: return "Lebanon"
|
||||
case .ly: return "Libya"
|
||||
case .li: return "Liechtenstein"
|
||||
case .lt: return "Lithuania"
|
||||
case .lu: return "Luxembourg"
|
||||
case .mk: return "Macedonia (the former Yugoslav Republic of)"
|
||||
case .my: return "Malaysia"
|
||||
case .mt: return "Malta"
|
||||
case .mx: return "Mexico"
|
||||
case .me: return "Montenegro"
|
||||
case .ma: return "Morocco"
|
||||
case .np: return "Nepal"
|
||||
case .nl: return "Netherlands"
|
||||
case .nz: return "New Zealand"
|
||||
case .ni: return "Nicaragua"
|
||||
case .ng: return "Nigeria"
|
||||
case .no: return "Norway"
|
||||
case .om: return "Oman"
|
||||
case .pk: return "Pakistan"
|
||||
case .pa: return "Panama"
|
||||
case .pg: return "Papua New Guinea"
|
||||
case .py: return "Paraguay"
|
||||
case .pe: return "Peru"
|
||||
case .ph: return "Philippines"
|
||||
case .pl: return "Poland"
|
||||
case .pt: return "Portugal"
|
||||
case .pr: return "Puerto Rico"
|
||||
case .qa: return "Qatar"
|
||||
case .ro: return "Romania"
|
||||
case .ru: return "Russian Federation"
|
||||
case .sa: return "Saudi Arabia"
|
||||
case .sn: return "Senegal"
|
||||
case .rs: return "Serbia"
|
||||
case .sg: return "Singapore"
|
||||
case .sk: return "Slovakia"
|
||||
case .si: return "Slovenia"
|
||||
case .za: return "South Africa"
|
||||
case .es: return "Spain"
|
||||
case .lk: return "Sri Lanka"
|
||||
case .se: return "Sweden"
|
||||
case .ch: return "Switzerland"
|
||||
case .tw: return "Taiwan"
|
||||
case .tz: return "Tanzania, United Republic of"
|
||||
case .th: return "Thailand"
|
||||
case .tn: return "Tunisia"
|
||||
case .tr: return "Turkey"
|
||||
case .ug: return "Uganda"
|
||||
case .ua: return "Ukraine"
|
||||
case .ae: return "United Arab Emirates"
|
||||
case .gb: return "United Kingdom of Great Britain and Northern Ireland"
|
||||
case .us: return "United States of America"
|
||||
case .uy: return "Uruguay"
|
||||
case .ve: return "Venezuela (Bolivarian Republic of)"
|
||||
case .vn: return "Viet Nam"
|
||||
case .vi: return "Virgin Islands (U.S.)"
|
||||
case .ye: return "Yemen"
|
||||
case .zw: return "Zimbabwe"
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable switch_case_on_newline
|
||||
|
||||
var flag: String {
|
||||
let unicodeScalars = rawValue
|
||||
.unicodeScalars
|
||||
.map { $0.value + 0x1F1E6 - 65 }
|
||||
.compactMap(UnicodeScalar.init)
|
||||
var result = ""
|
||||
result.unicodeScalars.append(contentsOf: unicodeScalars)
|
||||
return result
|
||||
}
|
||||
|
||||
static func search(_ query: String) -> [Country] {
|
||||
if let country = searchByCode(query) {
|
||||
return [country]
|
||||
}
|
||||
|
||||
let countries = filteredCountries { stringFolding($0) == stringFolding(query) }
|
||||
|
||||
return countries.isEmpty ? searchByPartialName(query) : countries
|
||||
}
|
||||
|
||||
static func searchByCode(_ code: String) -> Country? {
|
||||
Country(rawValue: code.uppercased())
|
||||
}
|
||||
|
||||
static func searchByPartialName(_ name: String) -> [Country] {
|
||||
guard !name.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return filteredCountries { stringFolding($0).contains(stringFolding(name)) }
|
||||
}
|
||||
|
||||
private static func stringFolding(_ string: String) -> String {
|
||||
string.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
}
|
||||
|
||||
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
|
||||
Country.allCases
|
||||
.map(\.name)
|
||||
.filter(predicate)
|
||||
.compactMap { string in Country.allCases.first { $0.name == string } }
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class DocumentsModel: ObservableObject {
|
||||
static var shared = DocumentsModel()
|
||||
|
||||
@Published private(set) var refreshID = UUID()
|
||||
|
||||
typealias AreInIncreasingOrder = (URL, URL) -> Bool
|
||||
|
||||
private var fileManager: FileManager {
|
||||
.default
|
||||
}
|
||||
|
||||
var sortPredicates: [AreInIncreasingOrder] {
|
||||
[
|
||||
{ self.isDirectory($0) && !self.isDirectory($1) },
|
||||
{ $0.lastPathComponent.caseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }
|
||||
]
|
||||
}
|
||||
|
||||
func sortedDirectoryContents(_ directoryURL: URL) -> [URL] {
|
||||
directoryContents(directoryURL).sorted { lhs, rhs in
|
||||
for predicate in sortPredicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func directoryContents(_ directoryURL: URL) -> [URL] {
|
||||
contents(of: directoryURL)
|
||||
}
|
||||
|
||||
var documentsDirectory: URL? {
|
||||
if let url = try? fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
|
||||
return standardizedURL(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recentDocuments(_ limit: Int = 10) -> [URL] {
|
||||
guard let documentsDirectory else { return [] }
|
||||
|
||||
return Array(
|
||||
contents(of: documentsDirectory)
|
||||
.filter { !isDirectory($0) }
|
||||
.sorted {
|
||||
((try? $0.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date()) >
|
||||
((try? $1.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date())
|
||||
}
|
||||
.prefix(limit)
|
||||
)
|
||||
}
|
||||
|
||||
func isDocument(_ video: Video) -> Bool {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return false }
|
||||
return isDocument(url)
|
||||
}
|
||||
|
||||
func isDocument(_ url: URL) -> Bool {
|
||||
guard let url = standardizedURL(url), let documentsDirectory else { return false }
|
||||
return url.absoluteString.starts(with: documentsDirectory.absoluteString)
|
||||
}
|
||||
|
||||
func isDirectory(_ url: URL) -> Bool {
|
||||
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
}
|
||||
|
||||
var creationDateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
formatter.setLocalizedDateFormatFromTemplate("YYMMddHHmm")
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
func creationDate(_ video: Video) -> Date? {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
|
||||
return creationDate(url)
|
||||
}
|
||||
|
||||
func creationDate(_ url: URL) -> Date? {
|
||||
try? url.resourceValues(forKeys: [.creationDateKey]).creationDate
|
||||
}
|
||||
|
||||
func formattedCreationDate(_ video: Video) -> String? {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
|
||||
return formattedCreationDate(url)
|
||||
}
|
||||
|
||||
func formattedCreationDate(_ url: URL) -> String? {
|
||||
if let date = try? url.resourceValues(forKeys: [.creationDateKey]).creationDate {
|
||||
return creationDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var sizeFormatter: ByteCountFormatter {
|
||||
let formatter = ByteCountFormatter()
|
||||
|
||||
formatter.allowedUnits = .useAll
|
||||
formatter.countStyle = .file
|
||||
formatter.includesUnit = true
|
||||
formatter.isAdaptive = true
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
func size(_ video: Video) -> Int? {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
|
||||
return size(url)
|
||||
}
|
||||
|
||||
func size(_ url: URL) -> Int? {
|
||||
try? url.resourceValues(forKeys: [.fileAllocatedSizeKey]).fileAllocatedSize
|
||||
}
|
||||
|
||||
func formattedSize(_ video: Video) -> String? {
|
||||
guard let size = size(video) else { return nil }
|
||||
return sizeFormatter.string(fromByteCount: Int64(size))
|
||||
}
|
||||
|
||||
func formattedSize(_ url: URL) -> String? {
|
||||
guard let size = size(url) else { return nil }
|
||||
return sizeFormatter.string(fromByteCount: Int64(size))
|
||||
}
|
||||
|
||||
func removeDocument(_ url: URL) throws {
|
||||
guard isDocument(url) else { return }
|
||||
try fileManager.removeItem(at: url)
|
||||
URLBookmarkModel.shared.removeBookmark(url)
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func contents(of directory: URL) -> [URL] {
|
||||
(try? fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.creationDateKey, .fileAllocatedSizeKey, .isDirectoryKey],
|
||||
options: [.includesDirectoriesPostOrder, .skipsHiddenFiles]
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
func displayLabelForDocument(_ file: URL) -> String {
|
||||
let components = file.absoluteString.components(separatedBy: "/Documents/")
|
||||
if components.count == 2 {
|
||||
let component = components[1]
|
||||
return component.isEmpty ? "Documents" : component.removingPercentEncoding ?? component
|
||||
}
|
||||
return "Document"
|
||||
}
|
||||
|
||||
func standardizedURL(_ url: URL) -> URL? {
|
||||
let standardizedURL = NSString(string: url.absoluteString).standardizingPath
|
||||
return URL(string: standardizedURL)
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
refreshID = UUID()
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
enum Section: Codable, Equatable, Defaults.Serializable {
|
||||
case history
|
||||
case subscriptions
|
||||
case popular
|
||||
case trending(String, String?)
|
||||
case channel(String, String, String)
|
||||
case playlist(String, String)
|
||||
case channelPlaylist(String, String, String)
|
||||
case searchQuery(String, String, String, String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .history:
|
||||
return "History"
|
||||
case .subscriptions:
|
||||
return "Subscriptions"
|
||||
case .popular:
|
||||
return "Popular"
|
||||
case let .trending(country, category):
|
||||
let trendingCountry = Country(rawValue: country)!
|
||||
let trendingCategory = category.isNil ? nil : TrendingCategory(rawValue: category!)
|
||||
return "\(trendingCountry.flag) \(trendingCountry.id) \(trendingCategory?.name ?? "Trending")"
|
||||
case let .channel(_, _, name):
|
||||
return name
|
||||
case let .channelPlaylist(_, _, name):
|
||||
return name
|
||||
case let .searchQuery(text, date, duration, order):
|
||||
var label = "Search: \"\(text)\""
|
||||
if !date.isEmpty, let date = SearchQuery.Date(rawValue: date), date != .any {
|
||||
label += " from \(date == .today ? date.name : " this \(date.name)")"
|
||||
}
|
||||
if !order.isEmpty, let order = SearchQuery.SortOrder(rawValue: order), order != .relevance {
|
||||
label += " by \(order.name)"
|
||||
}
|
||||
if !duration.isEmpty {
|
||||
label += " (\(duration))"
|
||||
}
|
||||
|
||||
return label
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.section == rhs.section
|
||||
}
|
||||
|
||||
var id = UUID().uuidString
|
||||
var section: Section
|
||||
|
||||
var widgetSettingsKey: String {
|
||||
"favorites-\(id)"
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = Self()
|
||||
|
||||
@Default(.showFavoritesInHome) var showFavoritesInHome
|
||||
@Default(.favorites) var all
|
||||
@Default(.widgetsSettings) var widgetsSettings
|
||||
|
||||
var isEnabled: Bool {
|
||||
showFavoritesInHome
|
||||
}
|
||||
|
||||
func contains(_ item: FavoriteItem) -> Bool {
|
||||
all.contains { $0 == item }
|
||||
}
|
||||
|
||||
func toggle(_ item: FavoriteItem) {
|
||||
if contains(item) {
|
||||
remove(item)
|
||||
} else {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
if contains(item) { return }
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
func remove(_ item: FavoriteItem) {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
all.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func canMoveUp(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index > all.startIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canMoveDown(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index < all.endIndex - 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func moveUp(_ item: FavoriteItem) {
|
||||
guard canMoveUp(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func moveDown(_ item: FavoriteItem) {
|
||||
guard canMoveDown(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from + 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func addableItems() -> [FavoriteItem] {
|
||||
let allItems = [
|
||||
FavoriteItem(section: .subscriptions),
|
||||
FavoriteItem(section: .popular),
|
||||
FavoriteItem(section: .history)
|
||||
]
|
||||
|
||||
return allItems.filter { item in !all.contains { $0.section == item.section } }
|
||||
}
|
||||
|
||||
func listingStyle(_ item: FavoriteItem) -> WidgetListingStyle {
|
||||
widgetSettings(item).listingStyle
|
||||
}
|
||||
|
||||
func limit(_ item: FavoriteItem) -> Int {
|
||||
min(WidgetSettings.maxLimit(listingStyle(item)), widgetSettings(item).limit)
|
||||
}
|
||||
|
||||
func setListingStyle(_ style: WidgetListingStyle, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
settings.listingStyle = style
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
let settings = WidgetSettings(id: item.widgetSettingsKey, listingStyle: style)
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func setLimit(_ limit: Int, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
var settings = WidgetSettings(id: item.widgetSettingsKey, limit: limit)
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
||||
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
||||
}
|
||||
|
||||
func updateWidgetSettings(_ settings: WidgetSettings) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == settings.id }) {
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import Cache
|
||||
import CoreData
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class FeedModel: ObservableObject, CacheModel {
|
||||
static let shared = FeedModel()
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var videos = [Video]()
|
||||
@Published private var page = 1
|
||||
@Published var watchedUUID = UUID()
|
||||
|
||||
private var feedCount = UnwatchedFeedCountModel.shared
|
||||
private var cacheModel = FeedCacheModel.shared
|
||||
private var accounts = AccountsModel.shared
|
||||
|
||||
var storage: Storage<String, JSON>?
|
||||
|
||||
@Published var error: RequestError?
|
||||
|
||||
private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
|
||||
|
||||
var feed: Resource? {
|
||||
accounts.api.feed(page)
|
||||
}
|
||||
|
||||
func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if force || self.videos.isEmpty {
|
||||
self.loadCachedFeed()
|
||||
}
|
||||
|
||||
if self.accounts.app == .invidious {
|
||||
// Invidious for some reason won't refresh feed until homepage is loaded
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let home = self.accounts.api.home else { return }
|
||||
self.request(home, force: force)?
|
||||
.onCompletion { _ in
|
||||
self.loadFeed(force: force, onCompletion: onCompletion)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.loadFeed(force: force, onCompletion: onCompletion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFeed(force: Bool = false, paginating: Bool = false, onCompletion: @escaping () -> Void = {}) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
!self.isLoading,
|
||||
let account = self.accounts.current
|
||||
else {
|
||||
self?.isLoading = false
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if paginating {
|
||||
self.page += 1
|
||||
} else {
|
||||
self.page = 1
|
||||
}
|
||||
|
||||
let feedBeforeLoad = self.feed
|
||||
var request: Request?
|
||||
if let feedBeforeLoad {
|
||||
request = self.request(feedBeforeLoad, force: force)
|
||||
}
|
||||
if request != nil {
|
||||
self.isLoading = true
|
||||
}
|
||||
|
||||
request?
|
||||
.onCompletion { _ in
|
||||
self.isLoading = false
|
||||
onCompletion()
|
||||
}
|
||||
.onSuccess { response in
|
||||
self.error = nil
|
||||
if let videos: [Video] = response.typedContent() {
|
||||
if paginating {
|
||||
self.videos.append(contentsOf: videos)
|
||||
} else {
|
||||
self.videos = videos
|
||||
self.cacheModel.storeFeed(account: account, videos: self.videos)
|
||||
self.calculateUnwatchedFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { self.error = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
videos.removeAll()
|
||||
page = 1
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard accounts.app.paginatesSubscriptions, !isLoading else { return }
|
||||
|
||||
loadFeed(force: true, paginating: true)
|
||||
}
|
||||
|
||||
func onAccountChange() {
|
||||
reset()
|
||||
error = nil
|
||||
loadResources(force: true)
|
||||
calculateUnwatchedFeed()
|
||||
}
|
||||
|
||||
func calculateUnwatchedFeed() {
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
let feed = cacheModel.retrieveFeed(account: account)
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter(\.finished)
|
||||
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
|
||||
let unwatchedCount = max(0, feed.count - watched.count)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if unwatchedCount != self.feedCount.unwatched[account] {
|
||||
self.feedCount.unwatched[account] = unwatchedCount
|
||||
}
|
||||
|
||||
let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count)
|
||||
self.feedCount.unwatchedByChannel[account] = byChannel
|
||||
self.watchedUUID = UUID()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markAllFeedAsWatched() {
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos, watched: true, watchedAt: Date(timeIntervalSince1970: 0))
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
var canMarkAllFeedAsWatched: Bool {
|
||||
guard let account = accounts.current, accounts.signedIn else { return false }
|
||||
return (feedCount.unwatched[account] ?? 0) > 0
|
||||
}
|
||||
|
||||
func canMarkChannelAsWatched(_ channelID: Channel.ID) -> Bool {
|
||||
guard let account = accounts.current, accounts.signedIn else { return false }
|
||||
|
||||
return feedCount.unwatchedByChannel[account]?.keys.contains(channelID) ?? false
|
||||
}
|
||||
|
||||
func markChannelAsWatched(_ channelID: Channel.ID) {
|
||||
guard accounts.signedIn else { return }
|
||||
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: true)
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
func markChannelAsUnwatched(_ channelID: Channel.ID) {
|
||||
guard accounts.signedIn else { return }
|
||||
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: false)
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
func markAllFeedAsUnwatched() {
|
||||
guard accounts.current != nil else { return }
|
||||
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos, watched: false)
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
func markVideos(_ videos: [Video], watched: Bool, watchedAt: Date? = nil) {
|
||||
guard accounts.signedIn, let account = accounts.current else { return }
|
||||
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if watched {
|
||||
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, watchedAt: watchedAt, context: self.backgroundContext) }
|
||||
} else {
|
||||
let watches = self.watchFetchRequestResult(videos, context: self.backgroundContext)
|
||||
watches.forEach { self.backgroundContext.delete($0) }
|
||||
}
|
||||
|
||||
try? self.backgroundContext.save()
|
||||
|
||||
self.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func playUnwatchedFeed() {
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
let videos = cacheModel.retrieveFeed(account: account)
|
||||
guard !videos.isEmpty else { return }
|
||||
|
||||
let watches = watchFetchRequestResult(videos, context: backgroundContext)
|
||||
let watchesIDs = watches.map(\.videoID)
|
||||
let unwatched = videos.filter { video in
|
||||
if Defaults[.hideShorts], video.short {
|
||||
return false
|
||||
}
|
||||
|
||||
if !watchesIDs.contains(video.videoID) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let watch = watches.first(where: { $0.videoID == video.videoID }),
|
||||
watch.finished
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
guard !unwatched.isEmpty else { return }
|
||||
PlayerModel.shared.play(unwatched)
|
||||
}
|
||||
|
||||
var canPlayUnwatchedFeed: Bool {
|
||||
guard let account = accounts.current, accounts.signedIn else { return false }
|
||||
return (feedCount.unwatched[account] ?? 0) > 0
|
||||
}
|
||||
|
||||
var watchedId: String {
|
||||
watchedUUID.uuidString
|
||||
}
|
||||
|
||||
var feedTime: Date? {
|
||||
if let account = accounts.current {
|
||||
return cacheModel.getFeedTime(account: account)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var formattedFeedTime: String {
|
||||
getFormattedDate(feedTime)
|
||||
}
|
||||
|
||||
private func loadCachedFeed(_ onCompletion: @escaping () -> Void = {}) {
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
let cache = cacheModel.retrieveFeed(account: account)
|
||||
if !cache.isEmpty {
|
||||
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
|
||||
self?.videos = cache
|
||||
onCompletion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func request(_ resource: Resource, force: Bool = false) -> Request? {
|
||||
if force {
|
||||
return resource.load()
|
||||
}
|
||||
|
||||
return resource.loadIfNeeded()
|
||||
}
|
||||
|
||||
private func watchFetchRequestResult(_ videos: [Video], context: NSManagedObjectContext) -> [Watch] {
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID IN %@", videos.map(\.videoID) as [String])
|
||||
return (try? context.fetch(watchFetchRequest)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
extension PlayerModel {
|
||||
func historyVideo(_ id: String) -> Video? {
|
||||
historyVideos.first { $0.videoID == id }
|
||||
}
|
||||
|
||||
func loadHistoryVideoDetails(_ watch: Watch, onCompletion: @escaping () -> Void = {}) {
|
||||
guard historyVideo(watch.videoID).isNil else {
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if !Video.VideoID.isValid(watch.videoID), let url = URL(string: watch.videoID) {
|
||||
historyVideos.append(.local(url))
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if let video = VideosCacheModel.shared.retrieveVideo(watch.video.cacheKey) {
|
||||
historyVideos.append(video)
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
guard let api = playerAPI(watch.video) else { return }
|
||||
|
||||
api.video(watch.videoID)
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
if let video: Video = response.typedContent() {
|
||||
VideosCacheModel.shared.storeVideo(video)
|
||||
self.historyVideos.append(video)
|
||||
onCompletion()
|
||||
}
|
||||
}
|
||||
.onCompletion { _ in
|
||||
self.logger.info("LOADED history details: \(watch.videoID)")
|
||||
}
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
|
||||
guard let currentVideo, saveHistory, isPlaying else { return }
|
||||
|
||||
let id = currentVideo.videoID
|
||||
let time = time ?? backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
if seconds < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
|
||||
|
||||
let results = try? backgroundContext.fetch(watchFetchRequest)
|
||||
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self, finished || time != nil || self.backend.isPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
let watch: Watch!
|
||||
|
||||
let duration = self.activeBackend == .mpv ? self.playerTime.duration.seconds : self.avPlayerBackend.playerItemDuration?.seconds ?? 0
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
watch = Watch(context: self.backgroundContext)
|
||||
watch.videoID = id
|
||||
watch.appName = currentVideo.app.rawValue
|
||||
watch.instanceURL = currentVideo.instanceURL
|
||||
} else {
|
||||
watch = results?.first
|
||||
}
|
||||
|
||||
if duration.isFinite, duration > 0 {
|
||||
watch.videoDuration = duration
|
||||
}
|
||||
|
||||
if watch.finished {
|
||||
if !finished, self.resetWatchedStatusOnPlaying, seconds.isFinite, seconds > 0 {
|
||||
watch.stoppedAt = seconds
|
||||
}
|
||||
} else if seconds.isFinite, seconds > 0 {
|
||||
watch.stoppedAt = seconds
|
||||
}
|
||||
|
||||
watch.watchedAt = Date()
|
||||
|
||||
try? self.backgroundContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
func removeHistory() {
|
||||
removeAllWatches()
|
||||
BookmarksCacheModel.shared.clear()
|
||||
}
|
||||
|
||||
func removeWatch(_ watch: Watch) {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.delete(watch)
|
||||
|
||||
try? self.context.save()
|
||||
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllWatches() {
|
||||
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
|
||||
|
||||
do {
|
||||
try context.executeAndMergeChanges(deleteRequest)
|
||||
try context.save()
|
||||
} catch let error as NSError {
|
||||
logger.info(.init(stringLiteral: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
||||
"mpvCachePauseInital": Defaults[.mpvCachePauseInital],
|
||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||
"mpvHWdec": Defaults[.mpvHWdec],
|
||||
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
||||
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
|
||||
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
||||
"showCacheStatus": Defaults[.showCacheStatus],
|
||||
"feedCacheSize": Defaults[.feedCacheSize]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showHome": Defaults[.showHome],
|
||||
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
|
||||
"showQueueInHome": Defaults[.showQueueInHome],
|
||||
"showFavoritesInHome": Defaults[.showFavoritesInHome],
|
||||
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
||||
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
||||
"startupSection": Defaults[.startupSection].rawValue,
|
||||
"showSearchSuggestions": Defaults[.showSearchSuggestions],
|
||||
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
|
||||
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
||||
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
||||
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
|
||||
"expandChannelDescription": Defaults[.expandChannelDescription],
|
||||
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
|
||||
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
|
||||
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
|
||||
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
|
||||
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
|
||||
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
|
||||
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
|
||||
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
|
||||
"channelOnThumbnail": Defaults[.channelOnThumbnail],
|
||||
"timeOnThumbnail": Defaults[.timeOnThumbnail],
|
||||
"roundedThumbnails": Defaults[.roundedThumbnails],
|
||||
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if os(iOS)
|
||||
export["showDocuments"].bool = Defaults[.showDocuments]
|
||||
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
|
||||
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
|
||||
"fullscreenPlayerGestureEnabled": Defaults[.fullscreenPlayerGestureEnabled],
|
||||
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
|
||||
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
|
||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
||||
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
|
||||
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
||||
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
||||
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
||||
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
|
||||
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
|
||||
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
|
||||
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
|
||||
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
|
||||
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
|
||||
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
|
||||
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
|
||||
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
|
||||
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
|
||||
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
|
||||
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
|
||||
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
|
||||
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
|
||||
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
|
||||
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
|
||||
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
|
||||
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
|
||||
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
|
||||
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
|
||||
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
|
||||
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"saveRecents": Defaults[.saveRecents],
|
||||
"saveHistory": Defaults[.saveHistory],
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount],
|
||||
"showWatchingProgress": Defaults[.showWatchingProgress],
|
||||
"saveLastPlayed": Defaults[.saveLastPlayed],
|
||||
|
||||
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
|
||||
"watchedThreshold": Defaults[.watchedThreshold],
|
||||
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
|
||||
|
||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
|
||||
var includePublicInstances = true
|
||||
var includeInstances = true
|
||||
var includeAccounts = true
|
||||
var includeAccountsUnencryptedPasswords = false
|
||||
|
||||
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
|
||||
self.includePublicInstances = includePublicInstances
|
||||
self.includeInstances = includeInstances
|
||||
self.includeAccounts = includeAccounts
|
||||
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
|
||||
}
|
||||
|
||||
override var globalJSON: JSON {
|
||||
var json = JSON()
|
||||
|
||||
if includePublicInstances {
|
||||
json["instancesManifest"].string = Defaults[.instancesManifest]
|
||||
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
|
||||
}
|
||||
|
||||
if includeInstances {
|
||||
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
|
||||
}
|
||||
|
||||
if includeAccounts {
|
||||
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
|
||||
var account = account
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
account.username = username ?? ""
|
||||
if includeAccountsUnencryptedPasswords {
|
||||
account.password = password ?? ""
|
||||
}
|
||||
|
||||
return accountJSON(account).dictionaryObject
|
||||
}
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
private func instanceJSON(_ instance: Instance) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = InstancesBridge().serialize(instance)
|
||||
return json
|
||||
}
|
||||
|
||||
private func accountJSON(_ account: Account) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = AccountsBridge().serialize(account)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"lastAccountID": Defaults[.lastAccountID] ?? "",
|
||||
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
|
||||
|
||||
"playerRate": Defaults[.playerRate],
|
||||
|
||||
"trendingCategory": Defaults[.trendingCategory].rawValue,
|
||||
"trendingCountry": Defaults[.trendingCountry].rawValue,
|
||||
|
||||
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
|
||||
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
|
||||
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
|
||||
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
|
||||
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
|
||||
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
|
||||
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
|
||||
|
||||
"hideShorts": Defaults[.hideShorts],
|
||||
"hideWatched": Defaults[.hideWatched]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
|
||||
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
|
||||
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
|
||||
"exitFullscreenOnEOF": Defaults[.exitFullscreenOnEOF],
|
||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
||||
"showChapters": Defaults[.showChapters],
|
||||
"showChapterThumbnails": Defaults[.showChapterThumbnails],
|
||||
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
|
||||
"expandChapters": Defaults[.expandChapters],
|
||||
"showRelated": Defaults[.showRelated],
|
||||
"showInspector": Defaults[.showInspector].rawValue,
|
||||
"playerSidebar": Defaults[.playerSidebar].rawValue,
|
||||
"showKeywords": Defaults[.showKeywords],
|
||||
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
|
||||
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
|
||||
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
|
||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP],
|
||||
"captionsAutoShow": Defaults[.captionsAutoShow],
|
||||
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
|
||||
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
|
||||
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
|
||||
"captionsFontColor": Defaults[.captionsFontColor]
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if !os(macOS)
|
||||
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
|
||||
#endif
|
||||
|
||||
export["showComments"].bool = Defaults[.showComments]
|
||||
|
||||
#if !os(tvOS)
|
||||
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
|
||||
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
||||
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user