diff --git a/.github/workflows/beta_media_release.yml b/.github/workflows/beta_media_release.yml new file mode 100644 index 000000000..103b5e219 --- /dev/null +++ b/.github/workflows/beta_media_release.yml @@ -0,0 +1,153 @@ +name: Beta Media Release builds + +on: + push: + branches: ["dev-media"] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + changelog: + name: Beta Media Release Changelog + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create or update ref + id: create-or-update-ref + uses: ovsds/create-or-update-ref-action@v1 + with: + ref: tags/beta-media + sha: ${{ github.sha }} + + - name: Delete beta-media tag + run: git tag -d beta-media + continue-on-error: true + + - name: changelog + id: changelog + run: | + git tag -l + npx changelogithub --output CHANGELOG.md + + - name: Upload assets to beta-media release + uses: softprops/action-gh-release@v2 + with: + body_path: CHANGELOG.md + files: CHANGELOG.md + prerelease: true + tag_name: beta-media + + - name: Upload assets to github artifact + uses: actions/upload-artifact@v4 + with: + name: beta-media changelog + path: ${{ github.workspace }}/CHANGELOG.md + compression-level: 0 + if-no-files-found: error + + release: + needs: + - changelog + strategy: + matrix: + include: + - target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch + hash: "md5" + - target: "linux-!(arm*)-musl*" #musl-not-arm + hash: "md5-linux-musl" + - target: "linux-arm*-musl*" #musl-arm + hash: "md5-linux-musl-arm" + - target: "windows-arm64" #win-arm64 + hash: "md5-windows-arm64" + - target: "windows7-*" #win7 + hash: "md5-windows7" + - target: "android-*" #android + hash: "md5-android" + - target: "freebsd-*" #freebsd + hash: "md5-freebsd" + + name: Beta Media Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + + - name: Setup web + run: | + frontendRepo="${FRONTEND_REPO:-OpenListTeam/OpenList-Frontend}" + release_json=$(curl -fsSL --max-time 10 \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/$frontendRepo/releases/tags/beta-media") + tar_url=$(echo "$release_json" | jq -r '.assets[].browser_download_url' | grep "openlist-frontend-dist" | grep -v "lite" | grep "\.tar\.gz$") + echo "Downloading frontend from: $tar_url" + curl -fsSL "$tar_url" -o dist.tar.gz + rm -rf public/dist && mkdir -p public/dist + tar -zxvf dist.tar.gz -C public/dist + rm -rf dist.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FRONTEND_REPO: ${{ vars.FRONTEND_REPO }} + + - name: Build + uses: OpenListTeam/cgo-actions@v1.2.2 + with: + targets: ${{ matrix.target }} + musl-target-format: $os-$musl-$arch + github-token: ${{ secrets.GITHUB_TOKEN }} + out-dir: build + output: openlist-$target$ext + musl-base-url: "https://github.com/OpenListTeam/musl-compilers/releases/latest/download/" + x-flags: | + github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at + github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors + github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit + github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag + github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling + + - name: Compress + run: | + bash build.sh zip ${{ matrix.hash }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload assets to beta-media release + uses: softprops/action-gh-release@v2 + with: + files: build/compress/* + prerelease: true + tag_name: beta-media + + - name: Clean illegal characters from matrix.target + id: clean_target_name + run: | + ILLEGAL_CHARS_REGEX='[":<>|*?\\/\r\n]' + CLEANED_TARGET=$(echo "${{ matrix.target }}" | sed -E "s/$ILLEGAL_CHARS_REGEX//g") + echo "Original target: ${{ matrix.target }}" + echo "Cleaned target: $CLEANED_TARGET" + echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV + + - name: Upload assets to github artifact + uses: actions/upload-artifact@v4 + with: + name: beta-media builds for ${{ env.cleaned_target }} + path: ${{ github.workspace }}/build/compress/* + compression-level: 0 + if-no-files-found: error diff --git a/go.mod b/go.mod index c36ac1ca0..63a198d31 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( github.com/pquerna/otp v1.5.0 github.com/quic-go/quic-go v0.54.1 github.com/rclone/rclone v1.70.3 + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/shirou/gopsutil/v4 v4.25.5 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.14.0 @@ -89,8 +90,8 @@ require ( ) require ( - cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect diff --git a/go.sum b/go.sum index b9a4570bd..f09c0aecb 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,10 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.53.0 h1:MZQCQQaRwOrAcuKjiHWHrgKykt4fZyuwF2dtiG3fGW8= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= -cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= @@ -35,9 +17,8 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZY github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Da3zKi7/saferith v0.33.0-fixed h1:fnIWTk7EP9mZAICf7aQjeoAwpfrlCrkOvqmi6CbWdTk= github.com/Da3zKi7/saferith v0.33.0-fixed/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= github.com/KarpelesLab/reflink v1.0.2 h1:hQ1aM3TmjU2kTNUx5p/HaobDoADYk+a6AuEinG4Cv88= @@ -205,7 +186,6 @@ github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCN github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -226,12 +206,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e h1:GLC8iDDcbt1H8+RkNao2nRGjyNTIo81e1rAJT9/uWYA= github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e/go.mod h1:ln9Whp+wVY/FTbn2SK0ag+SKD2fC0yQCF/Lqowc1LmU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= @@ -278,8 +254,6 @@ github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7 github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fclairamb/ftpserverlib v0.26.1-0.20250709223522-4a925d79caf6 h1:q1b+gv6AG2TDPN+f0QAkbRrAvJ3ZosnwRLTKNxSXlaA= @@ -309,8 +283,6 @@ github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -348,32 +320,14 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -381,22 +335,15 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= -github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -419,8 +366,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= @@ -432,7 +377,6 @@ github.com/henrybear327/Proton-API-Bridge v1.0.0 h1:gjKAaWfKu++77WsZTHg6FUyPC5W0 github.com/henrybear327/Proton-API-Bridge v1.0.0/go.mod h1:gunH16hf6U74W2b9CGDaWRadiLICsoJ6KRkSt53zLts= github.com/henrybear327/go-proton-api v1.0.0 h1:zYi/IbjLwFAW7ltCeqXneUGJey0TN//Xo851a/BgLXw= github.com/henrybear327/go-proton-api v1.0.0/go.mod h1:w63MZuzufKcIZ93pwRgiOtxMXYafI8H74D77AxytOBc= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ipfs/boxo v0.12.0 h1:AXHg/1ONZdRQHQLgG5JHsSC3XoE4DjCAMgK+asZvUcQ= @@ -478,8 +422,6 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg= github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c= github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg= @@ -498,11 +440,8 @@ github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQ github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -621,7 +560,6 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= @@ -641,10 +579,10 @@ github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Ny github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= @@ -729,30 +667,24 @@ github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3 h1:PSRw github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3/go.mod h1:CKriYB8bkNgSbYUQF1khSpejKb5IsV6cR7MdaAR7Fc0= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= -go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= @@ -760,9 +692,6 @@ golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= @@ -771,63 +700,23 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -839,48 +728,22 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -900,8 +763,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -914,13 +775,9 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -932,111 +789,42 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190829051458-42f498d34c4d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.236.0 h1:CAiEiDVtO4D/Qja2IA9VzlFrgPnK3XVMmRoJZlSWbc0= -google.golang.org/api v0.236.0/go.mod h1:X1WF9CU2oTc+Jml1tiIxGmWFK/UZezdqEu09gcxZAj4= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= +google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/ldap.v3 v3.1.0 h1:DIDWEjI7vQWREh0S8X5/NFPCZ3MCVd55LmXKPW4XLGE= @@ -1061,17 +849,9 @@ gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0= lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= resty.dev/v3 v3.0.0-beta.2 h1:xu4mGAdbCLuc3kbk7eddWfWm4JfhwDtdapwss5nCjnQ= resty.dev/v3 v3.0.0-beta.2/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 7bff851de..1c440ab48 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -242,6 +242,13 @@ func InitialSettings() []model.SettingItem { {Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, {Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, {Key: conf.StreamMaxServerUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, + + // media settings + {Key: conf.MediaTMDBKey, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaDiscogsToken, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaStoreThumbnail, Value: "false", Type: conf.TypeBool, Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaThumbnailMode, Value: "base64", Type: conf.TypeSelect, Options: "base64,local", Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaThumbnailPath, Value: "/.thumbnail", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE}, } additionalSettingItems := tool.Tools.Items() // 固定顺序 diff --git a/internal/conf/const.go b/internal/conf/const.go index b99d8849c..d1be011fe 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -161,6 +161,13 @@ const ( StreamMaxClientUploadSpeed = "max_client_upload_speed" StreamMaxServerDownloadSpeed = "max_server_download_speed" StreamMaxServerUploadSpeed = "max_server_upload_speed" + + // media + MediaTMDBKey = "media_tmdb_key" + MediaDiscogsToken = "media_discogs_token" + MediaThumbnailMode = "media_thumbnail_mode" + MediaThumbnailPath = "media_thumbnail_path" + MediaStoreThumbnail = "media_store_thumbnail" ) const ( diff --git a/internal/db/db.go b/internal/db/db.go index 96529c15d..900f816d7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -12,7 +12,7 @@ var db *gorm.DB func Init(d *gorm.DB) { db = d - err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB)) + err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB), new(model.MediaItem), new(model.MediaConfig)) if err != nil { log.Fatalf("failed migrate database: %s", err.Error()) } diff --git a/internal/db/media.go b/internal/db/media.go new file mode 100644 index 000000000..d1ab15cc5 --- /dev/null +++ b/internal/db/media.go @@ -0,0 +1,281 @@ +package db + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/model" + "gorm.io/gorm" +) + +// ==================== MediaConfig ==================== + +// GetMediaConfig 获取指定类型的媒体库配置,不存在则返回默认值 +func GetMediaConfig(mediaType model.MediaType) (*model.MediaConfig, error) { + var cfg model.MediaConfig + result := db.Where("media_type = ?", mediaType).First(&cfg) + if result.Error == gorm.ErrRecordNotFound { + // 返回默认配置 + return &model.MediaConfig{ + MediaType: mediaType, + Enabled: false, + ScanPath: "/", + PathMerge: false, + }, nil + } + return &cfg, result.Error +} + +// GetAllMediaConfigs 获取所有媒体库配置 +func GetAllMediaConfigs() ([]model.MediaConfig, error) { + var cfgs []model.MediaConfig + err := db.Find(&cfgs).Error + return cfgs, err +} + +// SaveMediaConfig 保存媒体库配置(upsert) +func SaveMediaConfig(cfg *model.MediaConfig) error { + var existing model.MediaConfig + result := db.Where("media_type = ?", cfg.MediaType).First(&existing) + if result.Error == gorm.ErrRecordNotFound { + return db.Create(cfg).Error + } + cfg.ID = existing.ID + return db.Save(cfg).Error +} + +// ==================== MediaItem ==================== + +// MediaItemQuery 媒体条目查询参数 +type MediaItemQuery struct { + MediaType model.MediaType + FolderPath string + Hidden *bool + Keyword string + OrderBy string // "name", "date", "size" + OrderDir string // "asc", "desc" + Page int + PageSize int +} + +// ListMediaItems 分页查询媒体条目 +func ListMediaItems(q MediaItemQuery) ([]model.MediaItem, int64, error) { + tx := db.Model(&model.MediaItem{}) + if q.MediaType != "" { + tx = tx.Where("media_type = ?", q.MediaType) + } + if q.FolderPath != "" { + tx = tx.Where("folder_path = ?", q.FolderPath) + } + if q.Hidden != nil { + tx = tx.Where("hidden = ?", *q.Hidden) + } + if q.Keyword != "" { + like := "%" + q.Keyword + "%" + tx = tx.Where("file_name LIKE ? OR scraped_name LIKE ?", like, like) + } + + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 排序 + orderCol := "created_at" + switch q.OrderBy { + case "name": + orderCol = "COALESCE(NULLIF(scraped_name,''), file_name)" + case "date": + orderCol = "release_date" + case "size": + orderCol = "file_size" + } + dir := "asc" + if q.OrderDir == "desc" { + dir = "desc" + } + tx = tx.Order(orderCol + " " + dir) + + // 分页 + if q.PageSize <= 0 { + q.PageSize = 20 + } + if q.Page <= 0 { + q.Page = 1 + } + tx = tx.Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize) + + var items []model.MediaItem + err := tx.Find(&items).Error + return items, total, err +} + +// GetMediaItemByID 按ID获取媒体条目 +func GetMediaItemByID(id uint) (*model.MediaItem, error) { + var item model.MediaItem + err := db.First(&item, id).Error + return &item, err +} + +// GetMediaItemByPath 按文件路径获取媒体条目 +func GetMediaItemByPath(filePath string) (*model.MediaItem, error) { + var item model.MediaItem + result := db.Where("file_path = ?", filePath).First(&item) + return &item, result.Error +} + +// CreateOrUpdateMediaItem 创建或更新媒体条目(按 file_path 唯一) +// 更新时保留已有的刮削数据,避免重新扫描时把已刮削的字段清空 +func CreateOrUpdateMediaItem(item *model.MediaItem) error { + var existing model.MediaItem + result := db.Where("file_path = ?", item.FilePath).First(&existing) + if result.Error == gorm.ErrRecordNotFound { + return db.Create(item).Error + } + if result.Error != nil { + return result.Error + } + item.ID = existing.ID + item.CreatedAt = existing.CreatedAt + // 如果已有刮削数据,保留刮削字段,防止重新扫描时覆盖刮削结果 + if existing.ScrapedAt != nil { + item.ScrapedAt = existing.ScrapedAt + item.ScrapedName = existing.ScrapedName + item.Cover = existing.Cover + item.AlbumName = existing.AlbumName + item.AlbumArtist = existing.AlbumArtist + item.TrackNumber = existing.TrackNumber + item.Duration = existing.Duration + item.Genre = existing.Genre + item.ReleaseDate = existing.ReleaseDate + item.Rating = existing.Rating + item.Plot = existing.Plot + item.Authors = existing.Authors + item.Description = existing.Description + item.Publisher = existing.Publisher + item.ISBN = existing.ISBN + item.ExternalID = existing.ExternalID + } + return db.Save(item).Error +} + +// UpdateMediaItem 更新媒体条目(仅更新可编辑字段) +func UpdateMediaItem(item *model.MediaItem) error { + return db.Save(item).Error +} + +// DeleteMediaItem 硬删除媒体条目(真正从数据库删除) +func DeleteMediaItem(id uint) error { + return db.Unscoped().Delete(&model.MediaItem{}, id).Error +} + +// ClearMediaItems 硬删除指定类型的所有媒体条目(真正从数据库删除) +func ClearMediaItems(mediaType model.MediaType) error { + return db.Unscoped().Where("media_type = ?", mediaType).Delete(&model.MediaItem{}).Error +} + +// ListAlbums 列出所有专辑(音乐专用) +func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) { + type albumRow struct { + AlbumName string + AlbumArtist string + Cover string + ReleaseDate string + TrackCount int + } + + // 构建基础查询 + baseQuery := db.Model(&model.MediaItem{}). + Where("media_type = ?", model.MediaTypeMusic) + if q.Hidden != nil { + baseQuery = baseQuery.Where("hidden = ?", *q.Hidden) + } + if q.Keyword != "" { + like := "%" + q.Keyword + "%" + baseQuery = baseQuery.Where("album_name LIKE ? OR album_artist LIKE ?", like, like) + } + + // 统计分组数(用子查询) + var total int64 + if err := db.Table("(?) as sub", baseQuery. + Select("album_name, album_artist"). + Group("album_name, album_artist")). + Count(&total).Error; err != nil { + return nil, 0, err + } + + if q.PageSize <= 0 { + q.PageSize = 20 + } + if q.Page <= 0 { + q.Page = 1 + } + + tx := baseQuery. + Select("album_name, album_artist, MAX(cover) as cover, MAX(release_date) as release_date, COUNT(*) as track_count"). + Group("album_name, album_artist"). + Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize) + + var rows []albumRow + if err := tx.Scan(&rows).Error; err != nil { + return nil, 0, err + } + + albums := make([]AlbumInfo, len(rows)) + for i, r := range rows { + albums[i] = AlbumInfo{ + AlbumName: r.AlbumName, + AlbumArtist: r.AlbumArtist, + Cover: r.Cover, + ReleaseDate: r.ReleaseDate, + TrackCount: r.TrackCount, + } + } + return albums, total, nil +} + +// AlbumInfo 专辑信息 +type AlbumInfo struct { + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + Cover string `json:"cover"` + ReleaseDate string `json:"release_date"` + TrackCount int `json:"track_count"` +} + +// GetAlbumTracks 获取专辑曲目列表 +func GetAlbumTracks(albumName, albumArtist string) ([]model.MediaItem, error) { + var items []model.MediaItem + tx := db.Where("media_type = ?", model.MediaTypeMusic) + if albumName != "" { + tx = tx.Where("album_name = ?", albumName) + } else { + // album_name 为空时,查询该艺术家下所有无专辑名的曲目 + tx = tx.Where("(album_name = '' OR album_name IS NULL)") + } + if albumArtist != "" { + tx = tx.Where("album_artist = ?", albumArtist) + } + err := tx.Order("track_number asc").Find(&items).Error + return items, err +} + +// ListFolderPaths 列出指定媒体类型下的所有文件夹路径(目录浏览模式) +func ListFolderPaths(mediaType model.MediaType) ([]string, error) { + var paths []string + err := db.Model(&model.MediaItem{}). + Where("media_type = ?", mediaType). + Distinct("folder_path"). + Pluck("folder_path", &paths).Error + return paths, err +} + +// GetUnscrappedItems 获取未刮削或刮削不完整的媒体条目 +// 只要 scraped_at 为空,或 cover/scraped_name/description 任一为空,就需要重新刮削 +func GetUnscrappedItems(mediaType model.MediaType, limit int) ([]model.MediaItem, error) { + var items []model.MediaItem + err := db.Where( + "media_type = ? AND (scraped_at IS NULL OR cover = '' OR cover IS NULL OR scraped_name = '' OR scraped_name IS NULL OR description = '' OR description IS NULL)", + mediaType, + ). + Limit(limit). + Find(&items).Error + return items, err +} diff --git a/internal/media/id3.go b/internal/media/id3.go new file mode 100644 index 000000000..2a4490c75 --- /dev/null +++ b/internal/media/id3.go @@ -0,0 +1,500 @@ +package media + +import ( + "encoding/binary" + "io" + "strconv" + "strings" +) + +// MusicTag 音频文件标签信息 +type MusicTag struct { + Title string // TIT2 + Artist string // TPE1 + Album string // TALB + AlbumArtist string // TPE2 + TrackNumber int // TRCK + Year string // TYER / TDRC + Genre string // TCON + CoverData []byte // 封面图片原始字节(APIC / PICTURE) + CoverMIME string // 封面图片 MIME 类型(如 image/jpeg) +} + +// ParseID3v2 从 io.Reader 中解析 ID3v2 标签(只读取文件头部,不需要 Seek) +// 支持 ID3v2.3 和 ID3v2.4 +func ParseID3v2(r io.Reader) (*MusicTag, error) { + // 读取 ID3v2 头部(10 字节) + header := make([]byte, 10) + if _, err := io.ReadFull(r, header); err != nil { + return nil, err + } + + // 检查魔数 + if string(header[0:3]) != "ID3" { + return nil, nil // 不是 ID3v2 文件,不报错 + } + + version := header[3] // 主版本号:3 = ID3v2.3, 4 = ID3v2.4 + if version < 3 || version > 4 { + return nil, nil // 只支持 v2.3 和 v2.4 + } + + // 解析标签总大小(syncsafe integer,4 字节,每字节最高位为 0) + tagSize := syncsafeToInt(header[6:10]) + if tagSize <= 0 || tagSize > 10*1024*1024 { // 最大 10MB + return nil, nil + } + + // 读取所有帧数据 + data := make([]byte, tagSize) + if _, err := io.ReadFull(r, data); err != nil { + return nil, nil // 读取失败时静默跳过 + } + + tag := &MusicTag{} + pos := 0 + + // 跳过扩展头部(如果有) + flags := header[5] + if flags&0x40 != 0 { // 有扩展头部 + if pos+4 > len(data) { + return tag, nil + } + extSize := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += extSize + } + + // 解析帧 + for pos+10 <= len(data) { + frameID := string(data[pos : pos+4]) + if frameID == "\x00\x00\x00\x00" { + break // 填充区域,结束 + } + + frameSize := int(binary.BigEndian.Uint32(data[pos+4 : pos+8])) + pos += 10 // 跳过帧头(4+4+2) + + if frameSize <= 0 || pos+frameSize > len(data) { + break + } + + frameData := data[pos : pos+frameSize] + pos += frameSize + + // 解析文本帧(T 开头的帧) + if len(frameID) == 4 && frameID[0] == 'T' && len(frameData) > 0 { + text := decodeID3Text(frameData) + switch frameID { + case "TIT2": + tag.Title = text + case "TPE1": + tag.Artist = text + case "TALB": + tag.Album = text + case "TPE2": + tag.AlbumArtist = text + case "TRCK": + // 格式可能是 "1" 或 "1/12" + parts := strings.SplitN(text, "/", 2) + if n, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + tag.TrackNumber = n + } + case "TYER", "TDRC": + if tag.Year == "" { + tag.Year = text + } + case "TCON": + tag.Genre = parseID3Genre(text) + } + } + + // 解析 APIC 帧(内嵌封面图片),只取第一张 + if frameID == "APIC" && tag.CoverData == nil && len(frameData) > 1 { + tag.CoverData, tag.CoverMIME = parseAPICFrame(frameData) + } + } + + return tag, nil +} + +// parseAPICFrame 解析 ID3v2 APIC 帧,返回图片字节和 MIME 类型 +// APIC 格式:编码字节(1) + MIME字符串(null结尾) + 图片类型(1) + 描述字符串(null结尾) + 图片数据 +func parseAPICFrame(data []byte) ([]byte, string) { + if len(data) < 4 { + return nil, "" + } + // encoding := data[0] // 文本编码(只影响描述字段,MIME 始终是 ASCII) + pos := 1 + + // 读取 MIME 类型(null 结尾的 ASCII 字符串) + nullIdx := -1 + for i := pos; i < len(data); i++ { + if data[i] == 0 { + nullIdx = i + break + } + } + if nullIdx < 0 { + return nil, "" + } + mimeType := string(data[pos:nullIdx]) + pos = nullIdx + 1 + + if pos >= len(data) { + return nil, "" + } + // 图片类型(1字节,3 = Cover (front),但我们取任意第一张) + pos++ // 跳过图片类型字节 + + // 跳过描述字符串(null 结尾,编码由第一字节决定) + encoding := data[0] + if encoding == 0x01 || encoding == 0x02 { + // UTF-16:找 \x00\x00 结尾 + for pos+1 < len(data) { + if data[pos] == 0 && data[pos+1] == 0 { + pos += 2 + break + } + pos += 2 + } + } else { + // ISO-8859-1 / UTF-8:找单个 \x00 结尾 + for pos < len(data) { + if data[pos] == 0 { + pos++ + break + } + pos++ + } + } + + if pos >= len(data) { + return nil, "" + } + + // 标准化 MIME 类型 + if mimeType == "" || mimeType == "image/" { + mimeType = "image/jpeg" // 默认 + } + + imgData := make([]byte, len(data)-pos) + copy(imgData, data[pos:]) + return imgData, mimeType +} + +// syncsafeToInt 将 syncsafe integer 转换为普通整数 +func syncsafeToInt(b []byte) int { + result := 0 + for _, v := range b { + result = (result << 7) | int(v&0x7F) + } + return result +} + +// decodeID3Text 解码 ID3 文本帧(第一字节是编码标识) +func decodeID3Text(data []byte) string { + if len(data) == 0 { + return "" + } + encoding := data[0] + content := data[1:] + + // 去掉末尾的 null 字节 + switch encoding { + case 0x00: // ISO-8859-1 + // 去掉末尾 null + content = trimNull(content, false) + // 尝试 UTF-8 解码,如果失败则按 Latin-1 处理 + return latin1ToUTF8(content) + case 0x01: // UTF-16 with BOM + content = trimNull(content, true) + return utf16ToUTF8(content) + case 0x02: // UTF-16 BE without BOM + content = trimNull(content, true) + return utf16BEToUTF8(content) + case 0x03: // UTF-8 + content = trimNull(content, false) + return string(content) + default: + return string(content) + } +} + +// trimNull 去掉末尾的 null 字节 +func trimNull(b []byte, wide bool) []byte { + if wide { + // UTF-16: 去掉末尾的 \x00\x00 + for len(b) >= 2 && b[len(b)-2] == 0 && b[len(b)-1] == 0 { + b = b[:len(b)-2] + } + } else { + for len(b) > 0 && b[len(b)-1] == 0 { + b = b[:len(b)-1] + } + } + return b +} + +// latin1ToUTF8 将 Latin-1 编码转换为 UTF-8 +func latin1ToUTF8(b []byte) string { + runes := make([]rune, len(b)) + for i, v := range b { + runes[i] = rune(v) + } + return string(runes) +} + +// utf16ToUTF8 将 UTF-16(带 BOM)转换为 UTF-8 +func utf16ToUTF8(b []byte) string { + if len(b) < 2 { + return "" + } + var bigEndian bool + if b[0] == 0xFF && b[1] == 0xFE { + bigEndian = false + b = b[2:] + } else if b[0] == 0xFE && b[1] == 0xFF { + bigEndian = true + b = b[2:] + } + return decodeUTF16(b, bigEndian) +} + +// utf16BEToUTF8 将 UTF-16 BE(无 BOM)转换为 UTF-8 +func utf16BEToUTF8(b []byte) string { + return decodeUTF16(b, true) +} + +// decodeUTF16 解码 UTF-16 字节序列 +func decodeUTF16(b []byte, bigEndian bool) string { + if len(b)%2 != 0 { + b = b[:len(b)-1] + } + runes := make([]rune, 0, len(b)/2) + for i := 0; i+1 < len(b); i += 2 { + var code uint16 + if bigEndian { + code = uint16(b[i])<<8 | uint16(b[i+1]) + } else { + code = uint16(b[i+1])<<8 | uint16(b[i]) + } + runes = append(runes, rune(code)) + } + return string(runes) +} + +// parseID3Genre 解析 ID3 流派字段(可能是 "(17)" 格式的数字引用) +func parseID3Genre(s string) string { + s = strings.TrimSpace(s) + if len(s) > 2 && s[0] == '(' && s[len(s)-1] == ')' { + // 数字引用格式,直接返回原始字符串 + return s + } + return s +} + +// ─── FLAC Vorbis Comment 解析 ──────────────────────────────────────────────── + +// ParseFLACVorbisComment 从 io.Reader 中解析 FLAC 文件的 Vorbis Comment 元数据 +// FLAC 格式:4字节魔数 "fLaC" + 若干 METADATA_BLOCK +// METADATA_BLOCK:1字节(最高位=是否最后块, 低7位=块类型) + 3字节长度 + 数据 +// 块类型 4 = VORBIS_COMMENT +func ParseFLACVorbisComment(r io.Reader) (*MusicTag, error) { + // 读取魔数(4字节) + magic := make([]byte, 4) + if _, err := io.ReadFull(r, magic); err != nil { + return nil, err + } + if string(magic) != "fLaC" { + return nil, nil // 不是 FLAC 文件 + } + + // 遍历 METADATA_BLOCK,找到 VORBIS_COMMENT(类型4)和 PICTURE(类型6) + var tag *MusicTag + for { + // 读取块头(4字节:1字节标志+类型 + 3字节长度) + blockHeader := make([]byte, 4) + if _, err := io.ReadFull(r, blockHeader); err != nil { + return nil, nil // 读取失败,静默跳过 + } + + isLast := blockHeader[0]&0x80 != 0 + blockType := blockHeader[0] & 0x7F + blockLen := int(blockHeader[1])<<16 | int(blockHeader[2])<<8 | int(blockHeader[3]) + + if blockLen < 0 || blockLen > 16*1024*1024 { // 最大 16MB + return nil, nil + } + + if blockType == 4 { + // VORBIS_COMMENT 块 + data := make([]byte, blockLen) + if _, err := io.ReadFull(r, data); err != nil { + return nil, nil + } + tag = parseVorbisCommentData(data) + if isLast { + return tag, nil + } + // 继续读取后续块,寻找 PICTURE 块(类型6) + continue + } + + if blockType == 6 { + // PICTURE 块(FLAC 内嵌封面) + data := make([]byte, blockLen) + if _, err := io.ReadFull(r, data); err == nil && tag != nil && tag.CoverData == nil { + tag.CoverData, tag.CoverMIME = parseFLACPictureBlock(data) + } else if err != nil { + // 读取失败,跳过 + _ = err + } + if isLast { + return tag, nil + } + continue + } + + // 跳过此块 + if _, err := io.CopyN(io.Discard, r, int64(blockLen)); err != nil { + return nil, nil + } + + if isLast { + break + } + } + + return tag, nil +} + +// parseFLACPictureBlock 解析 FLAC PICTURE 元数据块,返回图片字节和 MIME 类型 +// 格式(大端序): +// 4字节 picture_type +// 4字节 mime_length + mime_string +// 4字节 description_length + description_string +// 4字节 width, 4字节 height, 4字节 color_depth, 4字节 color_count +// 4字节 data_length + data +func parseFLACPictureBlock(data []byte) ([]byte, string) { + if len(data) < 8 { + return nil, "" + } + pos := 4 // 跳过 picture_type + + // 读取 MIME 类型 + if pos+4 > len(data) { + return nil, "" + } + mimeLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += 4 + if pos+mimeLen > len(data) { + return nil, "" + } + mimeType := string(data[pos : pos+mimeLen]) + pos += mimeLen + + // 跳过描述字符串 + if pos+4 > len(data) { + return nil, "" + } + descLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += 4 + descLen + + // 跳过 width(4) + height(4) + color_depth(4) + color_count(4) + pos += 16 + + // 读取图片数据 + if pos+4 > len(data) { + return nil, "" + } + dataLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += 4 + if pos+dataLen > len(data) { + return nil, "" + } + + if mimeType == "" { + mimeType = "image/jpeg" + } + + imgData := make([]byte, dataLen) + copy(imgData, data[pos:pos+dataLen]) + return imgData, mimeType +} + +// parseVorbisCommentData 解析 Vorbis Comment 数据块 +// 格式(小端序): +// 4字节 vendor_length + vendor_string +// 4字节 comment_count +// 每条注释:4字节长度 + UTF-8字符串(格式 "KEY=VALUE") +func parseVorbisCommentData(data []byte) *MusicTag { + tag := &MusicTag{} + pos := 0 + + // 跳过 vendor string + if pos+4 > len(data) { + return tag + } + vendorLen := int(binary.LittleEndian.Uint32(data[pos : pos+4])) + pos += 4 + vendorLen + + // 读取注释数量 + if pos+4 > len(data) { + return tag + } + commentCount := int(binary.LittleEndian.Uint32(data[pos : pos+4])) + pos += 4 + + for i := 0; i < commentCount; i++ { + if pos+4 > len(data) { + break + } + commentLen := int(binary.LittleEndian.Uint32(data[pos : pos+4])) + pos += 4 + + if commentLen <= 0 || pos+commentLen > len(data) { + break + } + + comment := string(data[pos : pos+commentLen]) + pos += commentLen + + // 解析 "KEY=VALUE" 格式(大小写不敏感) + eqIdx := strings.IndexByte(comment, '=') + if eqIdx < 0 { + continue + } + key := strings.ToUpper(comment[:eqIdx]) + value := strings.TrimSpace(comment[eqIdx+1:]) + if value == "" { + continue + } + + switch key { + case "TITLE": + tag.Title = value + case "ARTIST": + if tag.Artist == "" { + tag.Artist = value + } + case "ALBUM": + tag.Album = value + case "ALBUMARTIST", "ALBUM ARTIST": + tag.AlbumArtist = value + case "TRACKNUMBER", "TRACK": + // 格式可能是 "1" 或 "1/12" + parts := strings.SplitN(value, "/", 2) + if n, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + tag.TrackNumber = n + } + case "DATE", "YEAR": + if tag.Year == "" && len(value) >= 4 { + tag.Year = value[:4] + } + case "GENRE": + tag.Genre = value + } + } + + return tag +} diff --git a/internal/media/scanner.go b/internal/media/scanner.go new file mode 100644 index 000000000..90ba6c0fa --- /dev/null +++ b/internal/media/scanner.go @@ -0,0 +1,353 @@ +package media + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "mime" + "net/http" + stdpath "path" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/OpenListTeam/OpenList/v4/internal/db" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/http_range" +) + +// 支持的文件扩展名 +var ( + videoExts = map[string]bool{ + ".mp4": true, ".mkv": true, ".avi": true, ".mov": true, + ".wmv": true, ".flv": true, ".webm": true, ".m4v": true, + ".ts": true, ".rmvb": true, ".rm": true, ".3gp": true, + } + musicExts = map[string]bool{ + ".mp3": true, ".flac": true, ".aac": true, ".ogg": true, + ".wav": true, ".wma": true, ".m4a": true, ".ape": true, + ".opus": true, ".aiff": true, + } + imageExts = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, + ".webp": true, ".bmp": true, ".tiff": true, ".svg": true, + ".heic": true, ".avif": true, + } + bookExts = map[string]bool{ + ".epub": true, ".pdf": true, ".mobi": true, ".azw3": true, + ".txt": true, ".djvu": true, ".cbz": true, ".cbr": true, + } +) + +// ScanProgress 扫描进度(全局,按媒体类型维护) +type ScanProgress struct { + mu sync.RWMutex + Running bool + Total int + Done int + Message string + Error string +} + +var progressMap = map[model.MediaType]*ScanProgress{ + model.MediaTypeVideo: {}, + model.MediaTypeMusic: {}, + model.MediaTypeImage: {}, + model.MediaTypeBook: {}, +} + +// GetProgress 获取扫描进度 +func GetProgress(mediaType model.MediaType) model.MediaScanProgress { + p, ok := progressMap[mediaType] + if !ok { + return model.MediaScanProgress{MediaType: mediaType} + } + p.mu.RLock() + defer p.mu.RUnlock() + return model.MediaScanProgress{ + MediaType: mediaType, + Running: p.Running, + Total: p.Total, + Done: p.Done, + Message: p.Message, + Error: p.Error, + } +} + +// ScanMedia 扫描媒体文件(异步) +func ScanMedia(cfg *model.MediaConfig) { + p, ok := progressMap[cfg.MediaType] + if !ok { + return + } + p.mu.Lock() + if p.Running { + p.mu.Unlock() + return + } + p.Running = true + p.Total = 0 + p.Done = 0 + p.Error = "" + p.Message = "正在扫描..." + p.mu.Unlock() + + go func() { + defer func() { + p.mu.Lock() + p.Running = false + p.mu.Unlock() + }() + + if err := doScan(cfg, p); err != nil { + p.mu.Lock() + p.Error = err.Error() + p.Message = "扫描失败" + p.mu.Unlock() + log.Errorf("media scan error [%s]: %v", cfg.MediaType, err) + } else { + p.mu.Lock() + p.Message = "扫描完成" + p.mu.Unlock() + // 更新最后扫描时间 + now := time.Now() + cfg.LastScanAt = &now + _ = db.SaveMediaConfig(cfg) + } + }() +} + +func doScan(cfg *model.MediaConfig, p *ScanProgress) error { + scanRoot := cfg.ScanPath + if scanRoot == "" { + scanRoot = "/" + } + + // 收集所有待处理路径(VFS 虚拟路径) + var targets []string + + ctx := context.Background() + + if cfg.PathMerge { + // 路径合并模式:每个子文件夹作为一个条目 + entries, err := fs.List(ctx, scanRoot, &fs.ListArgs{NoLog: true}) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() { + targets = append(targets, stdpath.Join(scanRoot, e.GetName())) + } + } + } else { + // 普通模式:递归扫描所有匹配文件 + if err := walkVFS(ctx, scanRoot, cfg.MediaType, &targets); err != nil { + return err + } + } + + p.mu.Lock() + p.Total = len(targets) + p.mu.Unlock() + + for _, target := range targets { + item, err := buildMediaItemFromVFS(ctx, target, cfg) + if err != nil { + log.Warnf("build media item error [%s]: %v", target, err) + continue + } + + // 书籍类型:扫描阶段只记录基本信息,不读取文件内容,不刮削 + // 封面提取和豆瓣刮削在用户手动触发刮削时进行 + + if err := db.CreateOrUpdateMediaItem(item); err != nil { + log.Warnf("save media item error [%s]: %v", target, err) + } + p.mu.Lock() + p.Done++ + p.Message = "已扫描: " + stdpath.Base(target) + p.mu.Unlock() + } + return nil +} + +// FetchFileReader 通过 VFS 路径获取文件内容流(用于刮削器读取文件内容) +// 优先使用 RangeReader 直接读取(本地存储无需 HTTP),失败时回退到 HTTP URL +// 返回 nil 表示无法获取(不影响主流程) +func FetchFileReader(ctx context.Context, vfsPath string) io.ReadCloser { + link, _, err := fs.Link(ctx, vfsPath, model.LinkArgs{}) + if err != nil || link == nil { + return nil + } + // 优先使用 RangeReader(本地存储直接读取,无需 HTTP 请求) + if link.RangeReader != nil { + rc, err := link.RangeReader.RangeRead(ctx, http_range.Range{Start: 0, Length: -1}) + if err == nil && rc != nil { + return rc + } + } + // 回退:通过 HTTP URL 读取(远程存储) + if link.URL == "" { + return nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) + if err != nil { + return nil + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + _ = resp.Body.Close() + return nil + } + return resp.Body +} + +// walkVFS 递归遍历 VFS 路径,收集匹配的媒体文件路径 +func walkVFS(ctx context.Context, dirPath string, mediaType model.MediaType, targets *[]string) error { + entries, err := fs.List(ctx, dirPath, &fs.ListArgs{NoLog: true}) + if err != nil { + log.Warnf("media scan: list vfs path [%s] error: %v", dirPath, err) + return nil // 跳过无权限目录,不中断整体扫描 + } + for _, e := range entries { + childPath := stdpath.Join(dirPath, e.GetName()) + if e.IsDir() { + if err := walkVFS(ctx, childPath, mediaType, targets); err != nil { + return err + } + } else if isMediaFile(e.GetName(), mediaType) { + *targets = append(*targets, childPath) + } + } + return nil +} + +// buildMediaItemFromVFS 根据 VFS 路径构建 MediaItem +func buildMediaItemFromVFS(ctx context.Context, vfsPath string, cfg *model.MediaConfig) (*model.MediaItem, error) { + obj, err := fs.Get(ctx, vfsPath, &fs.GetArgs{NoLog: true}) + if err != nil { + return nil, err + } + + name := obj.GetName() + folderPath := stdpath.Dir(vfsPath) + + item := &model.MediaItem{ + MediaType: cfg.MediaType, + FilePath: vfsPath, + FileName: name, + FolderPath: folderPath, + IsFolder: obj.IsDir(), + } + + if !obj.IsDir() { + item.FileSize = obj.GetSize() + ext := strings.ToLower(stdpath.Ext(name)) + item.MimeType = mime.TypeByExtension(ext) + } + + // 路径合并模式:使用文件夹名作为名称 + if cfg.PathMerge && obj.IsDir() { + item.ScrapedName = name + } else { + // 去掉扩展名作为默认名称 + ext := stdpath.Ext(name) + item.ScrapedName = strings.TrimSuffix(name, ext) + } + + // 音乐文件:尝试读取标签(MP3 读 ID3v2,FLAC 读 Vorbis Comment),填充专辑/艺术家/曲目等元数据 + if cfg.MediaType == model.MediaTypeMusic && !obj.IsDir() { + ext := strings.ToLower(stdpath.Ext(name)) + readCtx, readCancel := context.WithTimeout(ctx, 15*time.Second) + if reader := FetchFileReader(readCtx, vfsPath); reader != nil { + var tag *MusicTag + switch ext { + case ".flac": + tag, _ = ParseFLACVorbisComment(reader) + default: + // MP3 及其他格式尝试 ID3v2 + tag, _ = ParseID3v2(reader) + } + if tag != nil { + if tag.Title != "" { + item.ScrapedName = tag.Title + } + item.AlbumName = tag.Album + item.AlbumArtist = tag.AlbumArtist + if item.AlbumArtist == "" { + item.AlbumArtist = tag.Artist + } + // 将艺术家写入 Authors 字段(JSON 数组格式) + if tag.Artist != "" { + if authorsJSON, err := json.Marshal([]string{tag.Artist}); err == nil { + item.Authors = string(authorsJSON) + } + } + item.TrackNumber = tag.TrackNumber + if tag.Year != "" && len(tag.Year) >= 4 { + item.ReleaseDate = tag.Year[:4] + "-01-01" + } + if tag.Genre != "" { + item.Genre = tag.Genre + } + // 提取内嵌封面图片,转为 data URI 存入 Cover(仅当 Cover 为空时) + if item.Cover == "" && len(tag.CoverData) > 0 { + mime := tag.CoverMIME + if mime == "" { + mime = "image/jpeg" + } + item.Cover = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(tag.CoverData) + } + } + _ = reader.Close() + } + readCancel() + } + + return item, nil +} + +// isMediaFile 判断文件名是否为指定媒体类型(按扩展名判断) +func isMediaFile(name string, mediaType model.MediaType) bool { + ext := strings.ToLower(stdpath.Ext(name)) + switch mediaType { + case model.MediaTypeVideo: + return videoExts[ext] + case model.MediaTypeMusic: + return musicExts[ext] + case model.MediaTypeImage: + return imageExts[ext] + case model.MediaTypeBook: + return bookExts[ext] + } + return false +} + +// GetSupportedExts 获取指定媒体类型支持的扩展名列表 +func GetSupportedExts(mediaType model.MediaType) []string { + var extMap map[string]bool + switch mediaType { + case model.MediaTypeVideo: + extMap = videoExts + case model.MediaTypeMusic: + extMap = musicExts + case model.MediaTypeImage: + extMap = imageExts + case model.MediaTypeBook: + extMap = bookExts + default: + return nil + } + exts := make([]string, 0, len(extMap)) + for ext := range extMap { + exts = append(exts, ext) + } + return exts +} \ No newline at end of file diff --git a/internal/media/scraper/book_local.go b/internal/media/scraper/book_local.go new file mode 100644 index 000000000..576c7180c --- /dev/null +++ b/internal/media/scraper/book_local.go @@ -0,0 +1,402 @@ +package scraper + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/xml" + "image" + "image/jpeg" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// BookLocalScraper 书籍本地刮削器 +// 尝试从书籍文件中提取封面图片并以 base64 存入 Cover 字段,或保存到本地文件。 +// - EPUB:解压 zip,提取 OPF 声明的封面或 cover.jpg/cover.png +// - PDF:尝试提取内嵌 JPEG 图片流作为封面 +// - 其他/失败:降级为文件路径 +type BookLocalScraper struct { + // ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件) + ThumbnailMode string + // ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail" + ThumbnailPath string +} + +// NewBookLocalScraper 创建书籍本地刮削器 +func NewBookLocalScraper() *BookLocalScraper { + return &BookLocalScraper{ + ThumbnailMode: "base64", + ThumbnailPath: "/.thumbnail", + } +} + +// NewBookLocalScraperWithConfig 创建带完整配置的书籍本地刮削器 +func NewBookLocalScraperWithConfig(thumbnailMode, thumbnailPath string) *BookLocalScraper { + if thumbnailMode == "" { + thumbnailMode = "base64" + } + if thumbnailPath == "" { + thumbnailPath = "/.thumbnail" + } + return &BookLocalScraper{ + ThumbnailMode: thumbnailMode, + ThumbnailPath: thumbnailPath, + } +} + +// ScrapeBookLocal 从书籍文件流中提取封面图片,填充基本信息。 +// reader 为书籍文件内容流,不能为 nil。 +// 返回提取到的封面 base64 字符串(data URI),提取失败返回空字符串。 +// 注意:本函数不会将文件路径作为 cover 降级,cover 为空表示提取失败。 +func (s *BookLocalScraper) ScrapeBookLocal(item *model.MediaItem, reader io.Reader) error { + ext := strings.ToLower(getFileExt(item.FileName)) + + // 根据文件类型补充 MimeType + if item.MimeType == "" { + item.MimeType = mimeTypeByBookExt(ext) + } + // 去掉扩展名作为展示名 + if item.ScrapedName == "" { + item.ScrapedName = trimExt(item.FileName) + } + + // 从文件流提取封面,提取失败则 cover 保持为空 + if reader != nil { + data, err := io.ReadAll(reader) + if err == nil && len(data) > 0 { + var coverData []byte + switch ext { + case ".epub": + coverData = extractEpubCoverData(data) + case ".pdf": + coverData = extractPDFCoverData(data) + } + if len(coverData) > 0 { + cover := s.storeCover(item.FilePath, coverData) + if cover != "" { + item.Cover = cover + } + } + } + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} + +// ExtractLocalCover 从书籍文件流中提取封面并根据配置存储(供外部调用)。 +// 提取失败返回空字符串,不降级为文件路径。 +func (s *BookLocalScraper) ExtractLocalCover(fileName string, filePath string, reader io.Reader) string { + if reader == nil { + return "" + } + ext := strings.ToLower(getFileExt(fileName)) + data, err := io.ReadAll(reader) + if err != nil || len(data) == 0 { + return "" + } + var coverData []byte + switch ext { + case ".epub": + coverData = extractEpubCoverData(data) + case ".pdf": + coverData = extractPDFCoverData(data) + } + if len(coverData) == 0 { + return "" + } + return s.storeCover(filePath, coverData) +} + +// storeCover 根据 ThumbnailMode 存储封面数据,返回 Cover 字段值 +func (s *BookLocalScraper) storeCover(filePath string, coverData []byte) string { + if s.ThumbnailMode == "local" { + return s.saveCoverLocal(filePath, coverData) + } + // 默认 base64 模式 + return imageDataToBase64Thumb(coverData) +} + +// saveCoverLocal 将封面图片保存到本地文件系统,返回可访问的路径 +func (s *BookLocalScraper) saveCoverLocal(filePath string, coverData []byte) string { + // 生成缩略图数据 + img, _, err := image.Decode(bytes.NewReader(coverData)) + if err != nil { + // 解码失败时直接保存原始数据 + return s.writeLocalFile(filePath, coverData) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return s.writeLocalFile(filePath, buf.Bytes()) +} + +// writeLocalFile 将数据写入本地文件,返回内部访问路径 +func (s *BookLocalScraper) writeLocalFile(filePath string, data []byte) string { + safeFileName := strings.ReplaceAll(strings.TrimPrefix(filePath, "/"), "/", "_") + ".jpg" + localDir := s.ThumbnailPath + if !filepath.IsAbs(localDir) { + if wd, err := os.Getwd(); err == nil { + localDir = filepath.Join(wd, localDir) + } + } + if err := os.MkdirAll(localDir, 0755); err != nil { + return "" + } + localFilePath := filepath.Join(localDir, safeFileName) + if err := os.WriteFile(localFilePath, data, 0644); err != nil { + return "" + } + thumbVFSPath := strings.TrimSuffix(s.ThumbnailPath, "/") + "/" + safeFileName + return buildInternalDownloadPath(thumbVFSPath) +} + +// ── EPUB 封面提取 ───────────────────────────────────────────────────────────── + +// epubContainer 解析 META-INF/container.xml +type epubContainer struct { + Rootfiles []struct { + FullPath string `xml:"full-path,attr"` + } `xml:"rootfiles>rootfile"` +} + +// epubOPF 解析 OPF 文件,找封面 item +type epubOPF struct { + Metadata struct { + Metas []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + } `xml:"meta"` + } `xml:"metadata"` + Manifest struct { + Items []struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` + MediaType string `xml:"media-type,attr"` + } `xml:"item"` + } `xml:"manifest"` +} + +// extractEpubCoverData 从 EPUB(zip)数据中提取封面图片原始字节 +func extractEpubCoverData(data []byte) []byte { + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil + } + + // 建立文件名→ZipFile 映射(忽略大小写) + fileMap := make(map[string]*zip.File, len(r.File)) + for _, f := range r.File { + fileMap[strings.ToLower(f.Name)] = f + } + + // 1. 尝试读取 META-INF/container.xml 找 OPF 路径 + opfPath := "" + if cf, ok := fileMap["meta-inf/container.xml"]; ok { + if rc, err := cf.Open(); err == nil { + var container epubContainer + if xml.NewDecoder(rc).Decode(&container) == nil && len(container.Rootfiles) > 0 { + opfPath = container.Rootfiles[0].FullPath + } + rc.Close() + } + } + + // OPF 所在目录(用于拼接相对路径) + opfDir := "" + if idx := strings.LastIndex(opfPath, "/"); idx >= 0 { + opfDir = opfPath[:idx+1] + } + + // 2. 从 OPF 中找封面 item ID + coverHref := "" + if opfPath != "" { + if of, ok := fileMap[strings.ToLower(opfPath)]; ok { + if rc, err := of.Open(); err == nil { + var opf epubOPF + if xml.NewDecoder(rc).Decode(&opf) == nil { + // 先找 指向的 item id + coverItemID := "" + for _, m := range opf.Metadata.Metas { + if strings.EqualFold(m.Name, "cover") && m.Content != "" { + coverItemID = m.Content + break + } + } + // 在 manifest 中找对应 item + for _, item := range opf.Manifest.Items { + if coverItemID != "" && item.ID == coverItemID { + coverHref = opfDir + item.Href + break + } + // 如果没有指定 cover id,找第一个图片类型的 item + if coverItemID == "" && strings.Contains(strings.ToLower(item.MediaType), "image") { + // 优先选择 id 或 href 中包含 "cover" 的 + if strings.Contains(strings.ToLower(item.ID), "cover") || + strings.Contains(strings.ToLower(item.Href), "cover") { + coverHref = opfDir + item.Href + break + } + } + } + // 如果还没找到,取第一个图片 + if coverHref == "" && coverItemID == "" { + for _, item := range opf.Manifest.Items { + if strings.Contains(strings.ToLower(item.MediaType), "image") { + coverHref = opfDir + item.Href + break + } + } + } + } + rc.Close() + } + } + } + + // 3. 常见封面文件名候选(按优先级) + candidates := []string{} + if coverHref != "" { + candidates = append(candidates, strings.ToLower(coverHref)) + } + candidates = append(candidates, + "cover.jpg", "cover.jpeg", "cover.png", + "images/cover.jpg", "images/cover.jpeg", "images/cover.png", + "oebps/cover.jpg", "oebps/images/cover.jpg", + "oebps/cover.jpeg", "oebps/images/cover.jpeg", + ) + + for _, name := range candidates { + if f, ok := fileMap[name]; ok { + if rc, err := f.Open(); err == nil { + imgData, readErr := io.ReadAll(rc) + rc.Close() + if readErr == nil && len(imgData) > 0 { + return imgData + } + } + } + } + return nil +} + +// extractEpubCoverBase64 从 EPUB(zip)数据中提取封面图片,返回 data URI base64(向后兼容) +func extractEpubCoverBase64(data []byte) string { + coverData := extractEpubCoverData(data) + if len(coverData) == 0 { + return "" + } + return imageDataToBase64Thumb(coverData) +} + +// ── PDF 封面提取 ────────────────────────────────────────────────────────────── + +// extractPDFCoverData 从 PDF 数据中提取第一个内嵌 JPEG 图片流,返回原始字节 +// PDF 内嵌图片通常以 JPEG 流存储(SOI marker: 0xFF 0xD8),直接扫描字节流提取 +func extractPDFCoverData(data []byte) []byte { + // 扫描 JPEG SOI (0xFF 0xD8 0xFF) 标记 + for i := 0; i < len(data)-2; i++ { + if data[i] == 0xFF && data[i+1] == 0xD8 && data[i+2] == 0xFF { + // 找对应的 EOI (0xFF 0xD9) + for j := i + 2; j < len(data)-1; j++ { + if data[j] == 0xFF && data[j+1] == 0xD9 { + jpegData := data[i : j+2] + // 验证是否为有效图片(至少 1KB,避免误匹配小缩略图) + if len(jpegData) > 1024 { + return jpegData + } + break + } + } + } + } + return nil +} + +// extractPDFCoverBase64 从 PDF 数据中提取第一个内嵌 JPEG 图片流,返回 data URI base64(向后兼容) +func extractPDFCoverBase64(data []byte) string { + coverData := extractPDFCoverData(data) + if len(coverData) == 0 { + return "" + } + return imageDataToBase64Thumb(coverData) +} + +// ── 公共辅助 ────────────────────────────────────────────────────────────────── + +// imageDataToBase64Thumb 将图片字节解码并缩放为 300px 宽缩略图,返回 data URI base64 +func imageDataToBase64Thumb(data []byte) string { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + // 解码失败时直接 base64 原始数据(可能是 PNG 等) + mimeType := detectImageMime(data) + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// detectImageMime 根据文件头检测图片 MIME 类型 +func detectImageMime(data []byte) string { + if len(data) < 4 { + return "image/jpeg" + } + switch { + case data[0] == 0xFF && data[1] == 0xD8: + return "image/jpeg" + case data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47: + return "image/png" + case data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46: + return "image/gif" + case data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46: + return "image/webp" + default: + return "image/jpeg" + } +} + +// getFileExt 获取文件扩展名(含点号,小写) +func getFileExt(fileName string) string { + if idx := strings.LastIndex(fileName, "."); idx >= 0 { + return fileName[idx:] + } + return "" +} + +// mimeTypeByBookExt 根据书籍文件扩展名返回 MIME 类型 +func mimeTypeByBookExt(ext string) string { + switch ext { + case ".pdf": + return "application/pdf" + case ".epub": + return "application/epub+zip" + case ".mobi": + return "application/x-mobipocket-ebook" + case ".azw3": + return "application/vnd.amazon.ebook" + case ".txt": + return "text/plain" + case ".djvu": + return "image/vnd.djvu" + case ".cbz": + return "application/vnd.comicbook+zip" + case ".cbr": + return "application/vnd.comicbook-rar" + default: + return "application/octet-stream" + } +} diff --git a/internal/media/scraper/discogs.go b/internal/media/scraper/discogs.go new file mode 100644 index 000000000..4b086624e --- /dev/null +++ b/internal/media/scraper/discogs.go @@ -0,0 +1,291 @@ +package scraper + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +const discogsBaseURL = "https://api.discogs.com" + +// DiscogsScraper Discogs音乐刮削器 +type DiscogsScraper struct { + Token string + client *http.Client +} + +// NewDiscogsScraper 创建Discogs刮削器 +func NewDiscogsScraper(token string) *DiscogsScraper { + return &DiscogsScraper{ + Token: token, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +type discogsSearchResult struct { + Results []struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Year string `json:"year"` + Thumb string `json:"thumb"` + CoverImage string `json:"cover_image"` + Genre []string `json:"genre"` + Style []string `json:"style"` + ResourceURL string `json:"resource_url"` + } `json:"results"` +} + +type discogsReleaseDetail struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + CoverImage string `json:"cover_image"` + Notes string `json:"notes"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Genres []string `json:"genres"` + Styles []string `json:"styles"` + Tracklist []struct { + Position string `json:"position"` + Title string `json:"title"` + Duration string `json:"duration"` + } `json:"tracklist"` + Community struct { + Rating struct { + Average float32 `json:"average"` + } `json:"rating"` + } `json:"community"` +} + +// parseMusicFileName 从音乐文件名中提取搜索关键词列表 +// 规则:去掉扩展名后,按" - "或"-"分割,返回各部分作为候选搜索词 +// 例如: +// +// "周杰伦 - 七里香.mp3" -> ["周杰伦 - 七里香", "周杰伦", "七里香"] +// "Coldplay-Yellow.flac" -> ["Coldplay-Yellow", "Coldplay", "Yellow"] +// "七里香.mp3" -> ["七里香"] +func parseMusicFileName(fileName string) []string { + // 去掉扩展名 + if idx := strings.LastIndex(fileName, "."); idx > 0 { + ext := strings.ToLower(fileName[idx:]) + if len(ext) <= 5 { + fileName = fileName[:idx] + } + } + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return nil + } + + var queries []string + // 完整文件名作为第一候选 + queries = append(queries, fileName) + + // 按" - "(带空格)分割 + parts := strings.SplitN(fileName, " - ", 2) + if len(parts) == 2 { + before := strings.TrimSpace(parts[0]) + after := strings.TrimSpace(parts[1]) + if before != "" { + queries = append(queries, before) + } + if after != "" { + queries = append(queries, after) + } + } else { + // 按"-"(不带空格)分割 + parts = strings.SplitN(fileName, "-", 2) + if len(parts) == 2 { + before := strings.TrimSpace(parts[0]) + after := strings.TrimSpace(parts[1]) + if before != "" { + queries = append(queries, before) + } + if after != "" { + queries = append(queries, after) + } + } + } + + return queries +} + +// doDiscogsSearch 执行单次Discogs搜索 +func (s *DiscogsScraper) doDiscogsSearch(query string) (*discogsSearchResult, error) { + searchURL := fmt.Sprintf("%s/database/search?q=%s&type=release&token=%s", + discogsBaseURL, url.QueryEscape(query), s.Token) + + req, _ := http.NewRequest("GET", searchURL, nil) + req.Header.Set("User-Agent", "OpenList/4.0 +https://github.com/OpenListTeam/OpenList") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("Discogs搜索请求失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var result discogsSearchResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("Discogs搜索结果解析失败: %w", err) + } + return &result, nil +} + +// buildMusicQuery 根据 MediaItem 已有字段拼接 Discogs 搜索词 +// 优先级:专辑名 > ScrapedName > 文件名解析 +// 若有歌手或年份信息,则附加到搜索词中以提高精准度 +func buildMusicQuery(base string, item *model.MediaItem) string { + q := base + // 附加歌手 + if item.AlbumArtist != "" { + q = item.AlbumArtist + " " + q + } + // 附加年份(取 ReleaseDate 前4位) + if len(item.ReleaseDate) >= 4 { + q = q + " " + item.ReleaseDate[:4] + } + return strings.TrimSpace(q) +} + +// ScrapeMusic 刮削音乐/专辑信息 +func (s *DiscogsScraper) ScrapeMusic(item *model.MediaItem) error { + if s.Token == "" { + return fmt.Errorf("Discogs Token 未配置") + } + + // 构建候选搜索词列表 + var queries []string + if item.AlbumName != "" { + queries = append(queries, buildMusicQuery(item.AlbumName, item)) + // 同时保留不带附加信息的纯专辑名作为降级候选 + if item.AlbumArtist != "" || len(item.ReleaseDate) >= 4 { + queries = append(queries, item.AlbumName) + } + } else if item.ScrapedName != "" { + queries = append(queries, buildMusicQuery(item.ScrapedName, item)) + if item.AlbumArtist != "" || len(item.ReleaseDate) >= 4 { + queries = append(queries, item.ScrapedName) + } + } else { + // 从文件名解析,按"-"分割分别搜索 + baseQueries := parseMusicFileName(item.FileName) + for _, bq := range baseQueries { + enhanced := buildMusicQuery(bq, item) + if enhanced != bq { + // 先放带附加信息的精准词,再放原始词作为降级 + queries = append(queries, enhanced) + } + queries = append(queries, bq) + } + } + + if len(queries) == 0 { + return fmt.Errorf("无法从文件名中提取有效搜索词") + } + + // 依次尝试各候选词,找到结果即停止(模糊匹配降级) + var searchResult *discogsSearchResult + var lastQuery string + for _, q := range queries { + result, err := s.doDiscogsSearch(q) + if err != nil { + return err + } + lastQuery = q + if len(result.Results) > 0 { + searchResult = result + break + } + } + + if searchResult == nil || len(searchResult.Results) == 0 { + return fmt.Errorf("Discogs未找到匹配结果: %s", lastQuery) + } + + first := searchResult.Results[0] + + // 获取详情 + detailURL := fmt.Sprintf("%s/releases/%d?token=%s", discogsBaseURL, first.ID, s.Token) + detailReq, _ := http.NewRequest("GET", detailURL, nil) + detailReq.Header.Set("User-Agent", "OpenList/4.0 +https://github.com/OpenListTeam/OpenList") + + detailResp, err := s.client.Do(detailReq) + if err != nil { + return fmt.Errorf("Discogs详情请求失败: %w", err) + } + defer detailResp.Body.Close() + + detailBody, _ := io.ReadAll(detailResp.Body) + var detail discogsReleaseDetail + if err := json.Unmarshal(detailBody, &detail); err != nil { + return fmt.Errorf("Discogs详情解析失败: %w", err) + } + + // 填充字段:已有值的字段不覆盖,仅补充空缺 + // Discogs 的 title 就是专辑名(不是 "Artist - Album" 格式) + if detail.Title != "" { + if item.AlbumName == "" { + item.AlbumName = detail.Title + } + if item.ScrapedName == "" { + item.ScrapedName = detail.Title + } + } + + if item.ReleaseDate == "" { + if detail.Year > 0 { + item.ReleaseDate = fmt.Sprintf("%d-01-01", detail.Year) + } else if first.Year != "" { + item.ReleaseDate = first.Year + "-01-01" + } + } + + item.Rating = detail.Community.Rating.Average + item.Plot = detail.Notes + item.ExternalID = fmt.Sprintf("discogs:%d", detail.ID) + + // 封面(存储 URL,前端直接展示) + if item.Cover == "" { + if detail.CoverImage != "" { + item.Cover = detail.CoverImage + } else if first.CoverImage != "" { + item.Cover = first.CoverImage + } else if first.Thumb != "" { + item.Cover = first.Thumb + } + } + + // 类型 + if item.Genre == "" { + genres := append(detail.Genres, detail.Styles...) + item.Genre = strings.Join(genres, ",") + } + + // 艺术家:仅在 Authors 和 AlbumArtist 均为空时才填充(ID3已有值则保留) + artists := make([]string, 0, len(detail.Artists)) + for _, a := range detail.Artists { + artists = append(artists, a.Name) + } + if len(artists) > 0 { + if item.Authors == "" { + authorsJSON, _ := json.Marshal(artists) + item.Authors = string(authorsJSON) + } + if item.AlbumArtist == "" { + item.AlbumArtist = artists[0] + } + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} diff --git a/internal/media/scraper/douban.go b/internal/media/scraper/douban.go new file mode 100644 index 000000000..7705f4e8d --- /dev/null +++ b/internal/media/scraper/douban.go @@ -0,0 +1,472 @@ +package scraper + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/jpeg" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "golang.org/x/net/html" +) + +const ( + doubanSearchURL = "https://www.douban.com/search" + doubanBookCat = "1001" + doubanBase = "https://book.douban.com/" +) + +var ( + doubanBookURLPattern = regexp.MustCompile(`.*/subject/(\d+)/?`) + doubanDatePattern = regexp.MustCompile(`(\d{4})-(\d+)`) + doubanDefaultHeaders = map[string]string{ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": doubanBase, + } +) + +// DoubanScraper 豆瓣书籍刮削器(移植自 NewDouban.py) +type DoubanScraper struct { + client *http.Client + // ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件) + ThumbnailMode string + // ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail" + ThumbnailPath string +} + +// NewDoubanScraper 创建豆瓣刮削器 +func NewDoubanScraper() *DoubanScraper { + return &DoubanScraper{ + client: &http.Client{Timeout: 20 * time.Second}, + ThumbnailMode: "base64", + ThumbnailPath: "/.thumbnail", + } +} + +// NewDoubanScraperWithConfig 创建带完整配置的豆瓣刮削器 +func NewDoubanScraperWithConfig(thumbnailMode, thumbnailPath string) *DoubanScraper { + if thumbnailMode == "" { + thumbnailMode = "base64" + } + if thumbnailPath == "" { + thumbnailPath = "/.thumbnail" + } + return &DoubanScraper{ + client: &http.Client{Timeout: 20 * time.Second}, + ThumbnailMode: thumbnailMode, + ThumbnailPath: thumbnailPath, + } +} + +// ScrapeBook 刮削书籍信息 +func (s *DoubanScraper) ScrapeBook(item *model.MediaItem) error { + query := item.ScrapedName + if query == "" { + query = strings.TrimSuffix(item.FileName, strings.ToLower(item.FileName[strings.LastIndex(item.FileName, "."):])) + } + + // 搜索书籍URL列表 + bookURLs, err := s.searchBookURLs(query) + if err != nil { + return fmt.Errorf("豆瓣搜索失败: %w", err) + } + if len(bookURLs) == 0 { + return fmt.Errorf("豆瓣未找到匹配书籍: %s", query) + } + + // 取第一个结果 + bookDetail, err := s.loadBookDetail(bookURLs[0]) + if err != nil { + return fmt.Errorf("豆瓣获取书籍详情失败: %w", err) + } + + // 填充字段 + if bookDetail.Title != "" { + item.ScrapedName = bookDetail.Title + } + item.Plot = bookDetail.Description + // 仅在豆瓣返回了有效封面图片URL时才覆盖本地封面 + // 下载封面图片并根据 ThumbnailMode 存储,避免豆瓣防盗链导致前端无法显示 + if bookDetail.Cover != "" { + if cover := s.downloadAndStoreCover(item.FilePath, bookDetail.Cover); cover != "" { + item.Cover = cover + } else { + // 下载失败时保留原 URL(降级) + item.Cover = bookDetail.Cover + } + } + item.Rating = bookDetail.Rating + item.ReleaseDate = bookDetail.PublishedDate + item.Publisher = bookDetail.Publisher + item.ISBN = bookDetail.ISBN + item.ExternalID = "douban:" + bookDetail.ID + + if len(bookDetail.Authors) > 0 { + authorsJSON, _ := json.Marshal(bookDetail.Authors) + item.Authors = string(authorsJSON) + } + if len(bookDetail.Tags) > 0 { + item.Genre = strings.Join(bookDetail.Tags, ",") + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} + +type doubanBookDetail struct { + ID string + Title string + Authors []string + Publisher string + PublishedDate string + Cover string + Rating float32 + Description string + Tags []string + ISBN string +} + +// searchBookURLs 搜索豆瓣书籍URL列表 +func (s *DoubanScraper) searchBookURLs(query string) ([]string, error) { + searchURL := fmt.Sprintf("%s?cat=%s&q=%s", doubanSearchURL, doubanBookCat, url.QueryEscape(query)) + body, err := s.doGet(searchURL) + if err != nil { + return nil, err + } + + doc, err := html.Parse(strings.NewReader(string(body))) + if err != nil { + return nil, err + } + + var bookURLs []string + var traverse func(*html.Node) + traverse = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "a" { + for _, attr := range n.Attr { + if attr.Key == "class" && attr.Val == "nbg" { + for _, a := range n.Attr { + if a.Key == "href" { + parsed := s.calcURL(a.Val) + if parsed != "" && len(bookURLs) < 5 { + bookURLs = append(bookURLs, parsed) + } + } + } + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + traverse(doc) + return bookURLs, nil +} + +// calcURL 解析豆瓣搜索结果中的真实URL +func (s *DoubanScraper) calcURL(href string) string { + parsed, err := url.Parse(href) + if err != nil { + return "" + } + query := parsed.Query() + rawURL := query.Get("url") + if rawURL == "" { + return "" + } + decoded, err := url.QueryUnescape(rawURL) + if err != nil { + return "" + } + if doubanBookURLPattern.MatchString(decoded) { + return decoded + } + return "" +} + +// loadBookDetail 加载书籍详情页 +func (s *DoubanScraper) loadBookDetail(bookURL string) (*doubanBookDetail, error) { + body, err := s.doGet(bookURL) + if err != nil { + return nil, err + } + return s.parseBookHTML(bookURL, string(body)) +} + +// parseBookHTML 解析书籍HTML(移植自 DoubanBookHtmlParser.parse_book) +func (s *DoubanScraper) parseBookHTML(bookURL, content string) (*doubanBookDetail, error) { + doc, err := html.Parse(strings.NewReader(content)) + if err != nil { + return nil, err + } + + detail := &doubanBookDetail{} + + // 提取豆瓣ID + if m := doubanBookURLPattern.FindStringSubmatch(bookURL); len(m) > 1 { + detail.ID = m[1] + } + + var traverse func(*html.Node) + traverse = func(n *html.Node) { + if n.Type == html.ElementNode { + switch n.Data { + case "span": + // 标题 + if getAttr(n, "property") == "v:itemreviewed" { + detail.Title = getTextContent(n) + } + // 元数据字段 + if getAttr(n, "class") == "pl" { + text := getTextContent(n) + tail := getNextText(n) + switch { + case strings.HasPrefix(text, "作者") || strings.HasPrefix(text, "译者"): + // 作者通过链接提取 + collectAuthors(n, &detail.Authors) + case strings.HasPrefix(text, "出版社"): + detail.Publisher = strings.TrimSpace(tail) + case strings.HasPrefix(text, "出版年"): + detail.PublishedDate = parseDoubanDate(strings.TrimSpace(tail)) + case strings.HasPrefix(text, "ISBN"): + detail.ISBN = strings.TrimSpace(tail) + case strings.HasPrefix(text, "副标题"): + if detail.Title != "" { + detail.Title += ":" + strings.TrimSpace(tail) + } + } + } + // 评分 + if getAttr(n, "property") == "v:average" { + ratingStr := getTextContent(n) + var rating float32 + fmt.Sscanf(ratingStr, "%f", &rating) + detail.Rating = rating / 2 + } + case "img": + // 封面图片: + if getAttr(n, "class") == "nbg" { + src := getAttr(n, "src") + if src != "" && !strings.HasSuffix(src, "update_image") { + detail.Cover = src + } + } + // 备用:#mainpic 下的 img + if getAttr(n, "id") == "mainpic" || (n.Parent != nil && getAttr(n.Parent, "id") == "mainpic") { + src := getAttr(n, "src") + if src != "" && detail.Cover == "" { + detail.Cover = src + } + } + case "a": + // 标签(原封面逻辑移除,改为从img获取) + cls := getAttr(n, "class") + if strings.Contains(cls, "tag") { + tag := getTextContent(n) + if tag != "" { + detail.Tags = append(detail.Tags, tag) + } + } + case "div": + // 简介 + if getAttr(n, "id") == "link-report" { + detail.Description = extractIntroText(n) + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + traverse(doc) + return detail, nil +} + +// HTML辅助函数 + +func getAttr(n *html.Node, key string) string { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func getTextContent(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.TextNode { + sb.WriteString(node.Data) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(sb.String()) +} + +func getNextText(n *html.Node) string { + if n.NextSibling != nil { + if n.NextSibling.Type == html.TextNode { + return strings.TrimSpace(n.NextSibling.Data) + } + return getTextContent(n.NextSibling) + } + return "" +} + +func collectAuthors(n *html.Node, authors *[]string) { + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "a" { + href := getAttr(node, "href") + if strings.Contains(href, "/author") || strings.Contains(href, "/search") { + name := getTextContent(node) + if name != "" { + *authors = append(*authors, name) + } + } + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + // 从父节点开始找 + if n.Parent != nil { + walk(n.Parent) + } +} + +func extractIntroText(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "div" { + if getAttr(node, "class") == "intro" { + sb.WriteString(getTextContent(node)) + return + } + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(sb.String()) +} + +func parseDoubanDate(dateStr string) string { + if dateStr == "" { + return "" + } + if m := doubanDatePattern.FindStringSubmatch(dateStr); len(m) > 2 { + return fmt.Sprintf("%s-%s-01", m[1], m[2]) + } + return dateStr +} + +// downloadAndStoreCover 下载封面图片并根据 ThumbnailMode 存储 +// 失败时返回空字符串 +func (s *DoubanScraper) downloadAndStoreCover(filePath, imgURL string) string { + data, mimeType := s.downloadImage(imgURL) + if len(data) == 0 { + return "" + } + if s.ThumbnailMode == "local" { + // 本地存储模式 + localScraper := NewBookLocalScraperWithConfig(s.ThumbnailMode, s.ThumbnailPath) + return localScraper.saveCoverLocal(filePath, data) + } + // Base64 模式(默认) + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// downloadCoverAsBase64 下载封面图片并转为 data URI Base64 字符串(向后兼容) +// 失败时返回空字符串 +func (s *DoubanScraper) downloadCoverAsBase64(imgURL string) string { + data, mimeType := s.downloadImage(imgURL) + if len(data) == 0 { + return "" + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// downloadImage 下载图片,返回原始字节和 MIME 类型 +func (s *DoubanScraper) downloadImage(imgURL string) ([]byte, string) { + req, err := http.NewRequest("GET", imgURL, nil) + if err != nil { + return nil, "" + } + for k, v := range doubanDefaultHeaders { + req.Header.Set(k, v) + } + resp, err := s.client.Do(req) + if err != nil { + return nil, "" + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, "" + } + data, err := io.ReadAll(resp.Body) + if err != nil || len(data) == 0 { + return nil, "" + } + mimeType := resp.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "image/jpeg" + } + return data, mimeType +} + +// doGet 发送GET请求 +func (s *DoubanScraper) doGet(reqURL string) ([]byte, error) { + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + for k, v := range doubanDefaultHeaders { + req.Header.Set(k, v) + } + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, reqURL) + } + return io.ReadAll(resp.Body) +} diff --git a/internal/media/scraper/image.go b/internal/media/scraper/image.go new file mode 100644 index 000000000..57bfe3e14 --- /dev/null +++ b/internal/media/scraper/image.go @@ -0,0 +1,259 @@ +package scraper + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/jpeg" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/rwcarlsen/goexif/exif" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// ImageScraper 图片刮削器 +// 从图片 EXIF 中提取元数据,并生成缩略图写入 Cover 字段。 +type ImageScraper struct { + // StoreThumbnail 为 true 时生成缩略图写入 Cover; + // 为 false 时直接用文件路径作为 Cover,节省数据库空间。 + StoreThumbnail bool + // ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件) + ThumbnailMode string + // ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail" + ThumbnailPath string +} + +// NewImageScraper 创建图片刮削器 +// storeThumbnail 为 true 时生成缩略图写入 Cover。 +func NewImageScraper(storeThumbnail bool) *ImageScraper { + return &ImageScraper{ + StoreThumbnail: storeThumbnail, + ThumbnailMode: "base64", + ThumbnailPath: "/.thumbnail", + } +} + +// NewImageScraperWithConfig 创建带完整配置的图片刮削器 +func NewImageScraperWithConfig(storeThumbnail bool, thumbnailMode, thumbnailPath string) *ImageScraper { + if thumbnailMode == "" { + thumbnailMode = "base64" + } + if thumbnailPath == "" { + thumbnailPath = "/.thumbnail" + } + return &ImageScraper{ + StoreThumbnail: storeThumbnail, + ThumbnailMode: thumbnailMode, + ThumbnailPath: thumbnailPath, + } +} + +// ScrapeImage 刮削图片信息 +// reader 为图片文件内容流,必须传入有效流才能生成缩略图。 +// 当 reader 不为 nil 时: +// - 从 EXIF 读取拍摄时间、GPS 地点、相机型号+参数、评分 +// - 生成 300px 宽缩略图,根据 ThumbnailMode 存储为 Base64 或本地文件 +// +// 注意:绝不将文件路径作为 cover,reader 为 nil 或缩略图生成失败时 cover 保持为空。 +func (s *ImageScraper) ScrapeImage(item *model.MediaItem, reader io.Reader) error { + // 去掉扩展名作为展示名(如果尚未设置) + if item.ScrapedName == "" { + item.ScrapedName = trimExt(item.FileName) + } + + if reader == nil { + // 无文件流时无法生成缩略图,cover 保持为空,等待下次重试 + now := time.Now() + item.ScrapedAt = &now + return nil + } + + // 将流读入内存(EXIF 解析和图像解码都需要完整数据) + data, err := io.ReadAll(reader) + if err != nil || len(data) == 0 { + // 读取失败时 cover 保持为空 + now := time.Now() + item.ScrapedAt = &now + return nil + } + + // ── 1. 解析 EXIF ────────────────────────────────────────────── + s.parseEXIF(item, bytes.NewReader(data)) + + // ── 2. 生成缩略图并存储 ────────────────────────────────────── + if s.ThumbnailMode == "local" { + // 本地存储模式:将缩略图保存到指定目录,Cover 存储本地路径 + if localPath := s.saveThumbnailLocal(item.FilePath, data); localPath != "" { + item.Cover = localPath + } + // 本地存储失败时 cover 保持为空,不降级为文件路径 + } else { + // Base64 模式(默认):生成缩略图 Base64 写入 Cover + if thumb := s.generateThumbnail(data); thumb != "" { + item.Cover = thumb + } + // 缩略图生成失败时 cover 保持为空,不降级为文件路径 + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} + +// saveThumbnailLocal 将缩略图保存到本地文件系统,返回可访问的路径 +// 文件保存在 ThumbnailPath 目录下,文件名为原文件路径的 hash + .jpg +func (s *ImageScraper) saveThumbnailLocal(filePath string, data []byte) string { + // 生成缩略图数据 + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "" + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 75}); err != nil { + return "" + } + + // 构建本地存储路径:ThumbnailPath/原文件路径.jpg + // 将文件路径中的 / 替换为 _ 避免目录嵌套问题 + safeFileName := strings.ReplaceAll(strings.TrimPrefix(filePath, "/"), "/", "_") + ".jpg" + localDir := s.ThumbnailPath + if !filepath.IsAbs(localDir) { + // 相对路径转为绝对路径(相对于工作目录) + if wd, err := os.Getwd(); err == nil { + localDir = filepath.Join(wd, localDir) + } + } + + // 确保目录存在 + if err := os.MkdirAll(localDir, 0755); err != nil { + return "" + } + + localFilePath := filepath.Join(localDir, safeFileName) + if err := os.WriteFile(localFilePath, buf.Bytes(), 0644); err != nil { + return "" + } + + // 返回内部访问路径(通过 /d/ 前缀访问) + // 缩略图路径格式:ThumbnailPath/safeFileName + thumbVFSPath := strings.TrimSuffix(s.ThumbnailPath, "/") + "/" + safeFileName + return buildInternalDownloadPath(thumbVFSPath) +} + +// parseEXIF 从图片数据中解析 EXIF 元数据并写入 item +func (s *ImageScraper) parseEXIF(item *model.MediaItem, r io.Reader) { + x, err := exif.Decode(r) + if err != nil { + return + } + + // ── 拍摄时间 → ReleaseDate ──────────────────────────────────── + if item.ReleaseDate == "" { + if t, err := x.DateTime(); err == nil { + item.ReleaseDate = t.Format("2006-01-02") + } + } + + // ── GPS 地点 → Authors(借用作者字段存储地点信息)──────────── + if item.Authors == "" { + if lat, lon, err := x.LatLong(); err == nil { + item.Authors = fmt.Sprintf(`["GPS: %.6f, %.6f"]`, lat, lon) + } + } + + // ── 相机型号 + 参数 → Genre ─────────────────────────────────── + if item.Genre == "" { + var parts []string + if make, err := x.Get(exif.Make); err == nil { + if v, err := make.StringVal(); err == nil && v != "" { + parts = append(parts, strings.TrimSpace(v)) + } + } + if model_, err := x.Get(exif.Model); err == nil { + if v, err := model_.StringVal(); err == nil && v != "" { + parts = append(parts, strings.TrimSpace(v)) + } + } + if fnum, err := x.Get(exif.FNumber); err == nil { + if num, den, err := fnum.Rat2(0); err == nil && den != 0 { + parts = append(parts, fmt.Sprintf("f/%.1f", float64(num)/float64(den))) + } + } + if exp, err := x.Get(exif.ExposureTime); err == nil { + if num, den, err := exp.Rat2(0); err == nil && den != 0 { + if num == 1 { + parts = append(parts, fmt.Sprintf("1/%ds", den)) + } else { + parts = append(parts, fmt.Sprintf("%d/%ds", num, den)) + } + } + } + if iso, err := x.Get(exif.ISOSpeedRatings); err == nil { + if v, err := iso.Int(0); err == nil { + parts = append(parts, fmt.Sprintf("ISO%d", v)) + } + } + if focal, err := x.Get(exif.FocalLength); err == nil { + if num, den, err := focal.Rat2(0); err == nil && den != 0 { + parts = append(parts, fmt.Sprintf("%.0fmm", float64(num)/float64(den))) + } + } + if len(parts) > 0 { + item.Genre = strings.Join(parts, ",") + } + } + + // ── EXIF Rating(0-5 星)→ Rating(0-10 分)────────────────── + // Windows 资源管理器、Lightroom、Darktable 等软件会写入此标签 + if item.Rating == 0 { + if ratingTag, err := x.Get(exif.FieldName("Rating")); err == nil { + if v, err := ratingTag.Int(0); err == nil && v > 0 { + // 1-5 星映射到 2-10 分 + item.Rating = float32(v) * 2 + } + } + } +} + +// generateThumbnail 将图片数据缩放为 300px 宽缩略图,返回 data URI Base64 字符串 +func (s *ImageScraper) generateThumbnail(data []byte) string { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "" + } + + // 缩放到宽度 300px,高度等比 + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 75}); err != nil { + return "" + } + + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// buildInternalDownloadPath 将 VFS 文件路径转为内部代理下载路径 +// 格式:/d/path/to/file(前端通过此路径访问文件内容) +func buildInternalDownloadPath(filePath string) string { + if !strings.HasPrefix(filePath, "/") { + filePath = "/" + filePath + } + return "/d" + filePath +} + +// trimExt 去掉文件名的扩展名 +func trimExt(fileName string) string { + if idx := strings.LastIndex(fileName, "."); idx > 0 { + return fileName[:idx] + } + return fileName +} diff --git a/internal/media/scraper/tmdb.go b/internal/media/scraper/tmdb.go new file mode 100644 index 000000000..ab54493fd --- /dev/null +++ b/internal/media/scraper/tmdb.go @@ -0,0 +1,296 @@ +package scraper + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// 年份正则(匹配 1900-2099) +var yearRegexp = regexp.MustCompile(`\b((?:19|20)\d{2})\b`) + +// chineseRegexp 匹配包含中文字符的片段 +var chineseRegexp = regexp.MustCompile(`[\p{Han}]`) + +// parsedVideoTitle 解析后的视频标题信息 +type parsedVideoTitle struct { + EnglishTitle string // 英文标题(第一个中文片段之前、年份之前的部分) + ChineseTitle string // 中文标题(第一个含中文的片段) + Year string // 年份 +} + +// parseVideoFileName 从视频文件名中提取标题和年份 +// 规则:按"."分割,第一个中文之前的是英文,第一个中文是中文标题,后面都是参数,中文标题之前是年份 +// 例如: +// +// "Inception.2010.盗梦空间.双语字幕.HR-HDTV.AC3.1024X576.X264-" -> {English:"Inception", Chinese:"盗梦空间", Year:"2010"} +// "Iron.Man.3.2013.钢铁侠3.国英音轨.双语字幕.HR-HDTV.AC3.1024X576.x264-" -> {English:"Iron Man 3", Chinese:"钢铁侠3", Year:"2013"} +// "The.Dark.Knight.2008.1080p.BluRay" -> {English:"The Dark Knight", Chinese:"", Year:"2008"} +func parseVideoFileName(fileName string) parsedVideoTitle { + // 去掉扩展名(.mkv .mp4 .avi 等,扩展名长度 <= 5) + if idx := strings.LastIndex(fileName, "."); idx > 0 { + ext := strings.ToLower(fileName[idx:]) + if len(ext) <= 5 { + fileName = fileName[:idx] + } + } + + // 按"."分割各字段 + parts := strings.Split(fileName, ".") + + var result parsedVideoTitle + var englishParts []string + foundChinese := false + foundYear := false + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // 已找到中文标题后,后面都是参数,跳过 + if foundChinese { + continue + } + + // 检测是否含中文 + if chineseRegexp.MatchString(p) { + // 第一个中文片段即为中文标题 + result.ChineseTitle = p + foundChinese = true + continue + } + + // 检测是否为年份(1900-2099) + if yearRegexp.MatchString(p) && !foundYear { + result.Year = yearRegexp.FindString(p) + foundYear = true + // 年份本身不加入英文标题 + continue + } + + // 年份之前的非中文片段加入英文标题 + if !foundYear { + englishParts = append(englishParts, p) + } + // 年份之后、中文之前的片段(如果有)忽略,通常是噪音 + } + + result.EnglishTitle = strings.Join(englishParts, " ") + return result +} + +const tmdbBaseURL = "https://api.themoviedb.org/3" +const tmdbImageBase = "https://image.tmdb.org/t/p/w500" + +// TMDBScraper TMDB视频刮削器 +type TMDBScraper struct { + APIKey string + client *http.Client +} + +// NewTMDBScraper 创建TMDB刮削器 +func NewTMDBScraper(apiKey string) *TMDBScraper { + return &TMDBScraper{ + APIKey: apiKey, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +// tmdbSearchResult TMDB搜索结果 +type tmdbSearchResult struct { + Results []struct { + ID int `json:"id"` + Title string `json:"title"` + Name string `json:"name"` // 电视剧用name + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + ReleaseDate string `json:"release_date"` + FirstAirDate string `json:"first_air_date"` // 电视剧 + VoteAverage float32 `json:"vote_average"` + MediaType string `json:"media_type"` + GenreIDs []int `json:"genre_ids"` + } `json:"results"` +} + +// tmdbMovieDetail TMDB电影详情 +type tmdbMovieDetail struct { + ID int `json:"id"` + Title string `json:"title"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + ReleaseDate string `json:"release_date"` + VoteAverage float32 `json:"vote_average"` + Genres []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"genres"` + Credits struct { + Cast []struct { + Name string `json:"name"` + } `json:"cast"` + } `json:"credits"` +} + +// doTMDBSearch 执行一次TMDB搜索请求 +func (s *TMDBScraper) doTMDBSearch(query, year string) (*tmdbSearchResult, error) { + searchURL := fmt.Sprintf("%s/search/multi?api_key=%s&query=%s&language=zh-CN&search_type=ngram", + tmdbBaseURL, s.APIKey, url.QueryEscape(query)) + if year != "" { + searchURL += "&year=" + year + } + + resp, err := s.client.Get(searchURL) + if err != nil { + return nil, fmt.Errorf("TMDB搜索请求失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var result tmdbSearchResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("TMDB搜索结果解析失败: %w", err) + } + return &result, nil +} + +// searchWithFallback 带降级重试的TMDB搜索 +// 策略: +// 1. 有中文标题时,先用中文标题 + 年份搜索,再用中文标题不带年份搜索 +// 2. 有英文标题时,用英文标题 + 年份搜索,再用英文标题不带年份搜索 +// 3. 全部搜索失败才返回错误 +func (s *TMDBScraper) searchWithFallback(parsed parsedVideoTitle) (*tmdbSearchResult, error) { + type searchAttempt struct { + query string + year string + } + + var attempts []searchAttempt + + // 中文标题优先 + if parsed.ChineseTitle != "" { + if parsed.Year != "" { + attempts = append(attempts, searchAttempt{parsed.ChineseTitle, parsed.Year}) + } + attempts = append(attempts, searchAttempt{parsed.ChineseTitle, ""}) + } + + // 英文标题兜底 + if parsed.EnglishTitle != "" { + if parsed.Year != "" { + attempts = append(attempts, searchAttempt{parsed.EnglishTitle, parsed.Year}) + } + attempts = append(attempts, searchAttempt{parsed.EnglishTitle, ""}) + } + + if len(attempts) == 0 { + return nil, fmt.Errorf("无法从文件名中提取有效标题") + } + + for _, attempt := range attempts { + result, err := s.doTMDBSearch(attempt.query, attempt.year) + if err != nil { + return nil, err + } + if len(result.Results) > 0 { + return result, nil + } + } + + // 构造友好的错误信息 + titleInfo := parsed.ChineseTitle + if titleInfo == "" { + titleInfo = parsed.EnglishTitle + } + return nil, fmt.Errorf("TMDB未找到匹配结果: %s", titleInfo) +} + +// ScrapeVideo 刮削视频信息 +func (s *TMDBScraper) ScrapeVideo(item *model.MediaItem) error { + if s.APIKey == "" { + return fmt.Errorf("TMDB API Key 未配置") + } + + // 始终从文件名中解析出标题和年份(ScrapedName 是刮削结果字段,不作为搜索输入) + parsed := parseVideoFileName(item.FileName) + + // 搜索策略:中文标题优先,英文标题兜底,都搜不到才失败 + searchResult, err := s.searchWithFallback(parsed) + if err != nil { + return err + } + + // 取第一个结果 + first := searchResult.Results[0] + mediaType := first.MediaType + if mediaType == "" { + mediaType = "movie" + } + + // 获取详情 + detailURL := fmt.Sprintf("%s/%s/%d?api_key=%s&language=zh-CN&append_to_response=credits", + tmdbBaseURL, mediaType, first.ID, s.APIKey) + + detailResp, err := s.client.Get(detailURL) + if err != nil { + return fmt.Errorf("TMDB详情请求失败: %w", err) + } + defer detailResp.Body.Close() + + detailBody, _ := io.ReadAll(detailResp.Body) + var detail tmdbMovieDetail + if err := json.Unmarshal(detailBody, &detail); err != nil { + return fmt.Errorf("TMDB详情解析失败: %w", err) + } + + // 填充字段 + title := detail.Title + if title == "" { + title = first.Name + } + item.ScrapedName = title + item.Plot = detail.Overview + item.Rating = detail.VoteAverage + item.ExternalID = fmt.Sprintf("tmdb:%d", detail.ID) + item.VideoType = mediaType + + releaseDate := detail.ReleaseDate + if releaseDate == "" { + releaseDate = first.FirstAirDate + } + item.ReleaseDate = releaseDate + + if detail.PosterPath != "" { + item.Cover = tmdbImageBase + detail.PosterPath + } + + // 类型 + genres := make([]string, 0, len(detail.Genres)) + for _, g := range detail.Genres { + genres = append(genres, g.Name) + } + item.Genre = strings.Join(genres, ",") + + // 演员(取前10个) + actors := make([]string, 0) + for i, cast := range detail.Credits.Cast { + if i >= 10 { + break + } + actors = append(actors, cast.Name) + } + authorsJSON, _ := json.Marshal(actors) + item.Authors = string(authorsJSON) + + now := time.Now() + item.ScrapedAt = &now + return nil +} \ No newline at end of file diff --git a/internal/model/media.go b/internal/model/media.go new file mode 100644 index 000000000..563c944b4 --- /dev/null +++ b/internal/model/media.go @@ -0,0 +1,95 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// MediaType 媒体类型,使用字符串便于后期扩展 +type MediaType string + +const ( + MediaTypeVideo MediaType = "video" + MediaTypeMusic MediaType = "music" + MediaTypeImage MediaType = "image" + MediaTypeBook MediaType = "book" +) + +// MediaItem 媒体条目(统一表,通过 media_type 区分类型,便于后期扩展新类型) +type MediaItem struct { + gorm.Model + // 覆盖 gorm.Model 的 ID 字段,使 JSON 序列化为小写 "id",与前端保持一致 + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + // 基础信息 + MediaType MediaType `gorm:"index;not null" json:"media_type"` + FilePath string `gorm:"uniqueIndex;not null" json:"file_path"` // 原始文件路径(唯一) + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Hidden bool `gorm:"default:false" json:"hidden"` + + // 刮削/编辑信息 + ScrapedName string `json:"scraped_name"` + Description string `gorm:"type:text" json:"description"` + Cover string `json:"cover"` // 封面URL或本地路径 + ReleaseDate string `json:"release_date"` // 发布时间,格式 YYYY-MM-DD + Rating float32 `json:"rating"` // 评分 0-10 + Genre string `json:"genre"` // 类型,逗号分隔,如 "动作,科幻" + Authors string `gorm:"type:text" json:"authors"` // 作者/演员,JSON数组字符串 + Plot string `gorm:"type:text" json:"plot"` // 剧情/内容介绍 + Reviews string `gorm:"type:text" json:"reviews"` // 用户评价,JSON数组字符串 + + // 外部ID(用于刮削关联) + ExternalID string `json:"external_id"` // TMDB ID / Discogs ID / 豆瓣ID + + // 音乐专属字段 + AlbumName string `json:"album_name"` // 所属专辑名 + AlbumArtist string `json:"album_artist"` // 专辑艺术家 + TrackNumber int `json:"track_number"` // 曲目编号 + Duration int `json:"duration"` // 时长(秒) + Lyrics string `gorm:"type:text" json:"lyrics"` // LRC格式歌词 + + // 视频专属字段 + VideoType string `json:"video_type"` // "movie" 或 "tv" + Season int `json:"season"` // 季(电视剧) + Episode int `json:"episode"` // 集(电视剧) + + // 书籍专属字段 + Publisher string `json:"publisher"` // 出版社 + ISBN string `json:"isbn"` // ISBN + + // 目录合并模式 + IsFolder bool `gorm:"default:false" json:"is_folder"` // 是否为文件夹模式条目 + FolderPath string `json:"folder_path"` // 所属文件夹路径 + + ScrapedAt *time.Time `json:"scraped_at"` +} + +// MediaConfig 媒体库配置(每种类型一条记录) +type MediaConfig struct { + gorm.Model + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + MediaType MediaType `gorm:"uniqueIndex;not null" json:"media_type"` + Enabled bool `gorm:"default:false" json:"enabled"` + ScanPath string `gorm:"default:/" json:"scan_path"` + PathMerge bool `gorm:"default:false" json:"path_merge"` // 路径合并模式 + LastScanAt *time.Time `json:"last_scan_at"` + LastScrapeAt *time.Time `json:"last_scrape_at"` +} + +// MediaScanProgress 扫描进度(内存中维护,不持久化) +type MediaScanProgress struct { + MediaType MediaType `json:"media_type"` + Running bool `json:"running"` + Total int `json:"total"` + Done int `json:"done"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} diff --git a/internal/model/setting.go b/internal/model/setting.go index 1e0fda0bb..b967e8e3e 100644 --- a/internal/model/setting.go +++ b/internal/model/setting.go @@ -13,6 +13,7 @@ const ( S3 FTP TRAFFIC + MEDIA ) const ( diff --git a/public/dist/README.md b/public/dist/README.md deleted file mode 100644 index d8709fb57..000000000 --- a/public/dist/README.md +++ /dev/null @@ -1 +0,0 @@ -## Put dist of frontend here. \ No newline at end of file diff --git a/server/handles/media.go b/server/handles/media.go new file mode 100644 index 000000000..8e7bc200a --- /dev/null +++ b/server/handles/media.go @@ -0,0 +1,444 @@ +package handles + +import ( + "context" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/db" + "github.com/OpenListTeam/OpenList/v4/internal/media" + "github.com/OpenListTeam/OpenList/v4/internal/media/scraper" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +// ==================== 配置管理 ==================== + +// ListMediaConfigs 获取所有媒体库配置 +func ListMediaConfigs(c *gin.Context) { + // 确保四种类型都有配置返回 + types := []model.MediaType{ + model.MediaTypeVideo, + model.MediaTypeMusic, + model.MediaTypeImage, + model.MediaTypeBook, + } + cfgs := make([]*model.MediaConfig, 0, len(types)) + for _, t := range types { + cfg, err := db.GetMediaConfig(t) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + cfgs = append(cfgs, cfg) + } + common.SuccessResp(c, cfgs) +} + +// SaveMediaConfigReq 保存配置请求 +type SaveMediaConfigReq struct { + MediaType model.MediaType `json:"media_type" binding:"required"` + Enabled bool `json:"enabled"` + ScanPath string `json:"scan_path"` + PathMerge bool `json:"path_merge"` +} + +// SaveMediaConfig 保存媒体库配置 +func SaveMediaConfig(c *gin.Context) { + var req SaveMediaConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + cfg := &model.MediaConfig{ + MediaType: req.MediaType, + Enabled: req.Enabled, + ScanPath: req.ScanPath, + PathMerge: req.PathMerge, + } + if cfg.ScanPath == "" { + cfg.ScanPath = "/" + } + if err := db.SaveMediaConfig(cfg); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ==================== 媒体条目管理(后台) ==================== + +// ListMediaItemsAdmin 后台分页查询媒体条目 +func ListMediaItemsAdmin(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + keyword := c.Query("keyword") + orderBy := c.DefaultQuery("order_by", "name") + orderDir := c.DefaultQuery("order_dir", "asc") + + q := db.MediaItemQuery{ + MediaType: mediaType, + Keyword: keyword, + OrderBy: orderBy, + OrderDir: orderDir, + Page: page, + PageSize: pageSize, + } + items, total, err := db.ListMediaItems(q) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, common.PageResp{Content: items, Total: total}) +} + +// UpdateMediaItemReq 更新媒体条目请求 +type UpdateMediaItemReq struct { + ID uint `json:"id" binding:"required"` + ScrapedName string `json:"scraped_name"` + Description string `json:"description"` + Cover string `json:"cover"` + ReleaseDate string `json:"release_date"` + Rating float32 `json:"rating"` + Genre string `json:"genre"` + Authors string `json:"authors"` + Plot string `json:"plot"` + Reviews string `json:"reviews"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + Publisher string `json:"publisher"` + ISBN string `json:"isbn"` + Hidden bool `json:"hidden"` +} + +// UpdateMediaItemAdmin 后台更新媒体条目 +func UpdateMediaItemAdmin(c *gin.Context) { + var req UpdateMediaItemReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + item, err := db.GetMediaItemByID(req.ID) + if err != nil { + common.ErrorResp(c, err, 404) + return + } + item.ScrapedName = req.ScrapedName + item.Description = req.Description + item.Cover = req.Cover + item.ReleaseDate = req.ReleaseDate + item.Rating = req.Rating + item.Genre = req.Genre + item.Authors = req.Authors + item.Plot = req.Plot + item.Reviews = req.Reviews + item.AlbumName = req.AlbumName + item.AlbumArtist = req.AlbumArtist + item.Publisher = req.Publisher + item.ISBN = req.ISBN + item.Hidden = req.Hidden + + if err := db.UpdateMediaItem(item); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// DeleteMediaItemAdmin 后台删除媒体条目 +func DeleteMediaItemAdmin(c *gin.Context) { + idStr := c.Query("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + common.ErrorStrResp(c, "无效的ID", 400) + return + } + if err := db.DeleteMediaItem(uint(id)); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ClearMediaDB 清空指定类型媒体数据库 +func ClearMediaDB(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + if mediaType == "" { + common.ErrorStrResp(c, "media_type 不能为空", 400) + return + } + if err := db.ClearMediaItems(mediaType); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ==================== 扫描与刮削 ==================== + +// ScanMediaReq 扫描请求 +type ScanMediaReq struct { + MediaType model.MediaType `json:"media_type" binding:"required"` +} + +// StartMediaScan 开始扫描 +func StartMediaScan(c *gin.Context) { + var req ScanMediaReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + cfg, err := db.GetMediaConfig(req.MediaType) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + if !cfg.Enabled { + common.ErrorStrResp(c, "该媒体库未启用", 400) + return + } + media.ScanMedia(cfg) + common.SuccessResp(c) +} + +// GetMediaScanProgress 获取扫描进度 +func GetMediaScanProgress(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + if mediaType == "" { + common.ErrorStrResp(c, "media_type 不能为空", 400) + return + } + progress := media.GetProgress(mediaType) + common.SuccessResp(c, progress) +} + +// ScrapeMediaReq 刮削请求 +type ScrapeMediaReq struct { + MediaType model.MediaType `json:"media_type" binding:"required"` + ItemID uint `json:"item_id"` // 0 表示刮削全部未刮削的 +} + +// StartMediaScrape 开始刮削 +func StartMediaScrape(c *gin.Context) { + var req ScrapeMediaReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + + cfg, err := db.GetMediaConfig(req.MediaType) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + + // 从系统设置中读取刮削配置 + tmdbKey := setting.GetStr(conf.MediaTMDBKey) + discogsToken := setting.GetStr(conf.MediaDiscogsToken) + thumbnailMode := setting.GetStr(conf.MediaThumbnailMode, "base64") + thumbnailPath := setting.GetStr(conf.MediaThumbnailPath, "/.thumbnail") + storeThumbnail := setting.GetBool(conf.MediaStoreThumbnail) + + go func() { + var items []model.MediaItem + var err error + + if req.ItemID > 0 { + item, e := db.GetMediaItemByID(req.ItemID) + if e == nil { + items = []model.MediaItem{*item} + } + } else { + items, err = db.GetUnscrappedItems(req.MediaType, 100) + if err != nil { + log.Errorf("获取未刮削条目失败: %v", err) + return + } + } + + for i := range items { + item := &items[i] + var scrapeErr error + + switch req.MediaType { + case model.MediaTypeVideo: + s := scraper.NewTMDBScraper(tmdbKey) + scrapeErr = s.ScrapeVideo(item) + case model.MediaTypeMusic: + s := scraper.NewDiscogsScraper(discogsToken) + scrapeErr = s.ScrapeMusic(item) + case model.MediaTypeBook: + // 步骤1:优先通过豆瓣刮削获取书名、评分、简介、封面 + doubanScraper := scraper.NewDoubanScraperWithConfig( + thumbnailMode, + thumbnailPath, + ) + doubanErr := doubanScraper.ScrapeBook(item) + if doubanErr != nil { + log.Debugf("豆瓣刮削失败 [%s]: %v,将尝试本地提取封面", item.FilePath, doubanErr) + } + + // 步骤2:若豆瓣未能获取到封面(cover 为空),则本地读取文件提取封面 + // 绝不将文件路径作为 cover + if item.Cover == "" { + bookCtx, bookCancel := context.WithTimeout(context.Background(), 60*time.Second) + bookReader := media.FetchFileReader(bookCtx, item.FilePath) + if bookReader != nil { + localScraper := scraper.NewBookLocalScraperWithConfig( + thumbnailMode, + thumbnailPath, + ) + if localCover := localScraper.ExtractLocalCover(item.FileName, item.FilePath, bookReader); localCover != "" { + item.Cover = localCover + } + _ = bookReader.Close() + } + bookCancel() + } + + // 豆瓣刮削失败且本地也无封面时,整体视为刮削失败 + if doubanErr != nil && item.Cover == "" { + scrapeErr = doubanErr + } + case model.MediaTypeImage: + // 读取图片文件流,用于 EXIF 解析和缩略图生成 + imgCtx, imgCancel := context.WithTimeout(context.Background(), 30*time.Second) + imgReader := media.FetchFileReader(imgCtx, item.FilePath) + s := scraper.NewImageScraperWithConfig( + storeThumbnail, + thumbnailMode, + thumbnailPath, + ) + scrapeErr = s.ScrapeImage(item, imgReader) + if imgReader != nil { + _ = imgReader.Close() + } + imgCancel() + } + + if scrapeErr != nil { + log.Warnf("刮削失败 [%s] %s: %v", req.MediaType, item.FilePath, scrapeErr) + continue + } + // 标记刮削完成时间,避免下次刮削重复处理 + now := time.Now() + item.ScrapedAt = &now + if err := db.UpdateMediaItem(item); err != nil { + log.Warnf("保存刮削结果失败 [%s]: %v", item.FilePath, err) + } + } + log.Infof("刮削完成 [%s],共处理 %d 条", req.MediaType, len(items)) + }() + + _ = cfg // cfg 仅用于校验媒体库是否存在,刮削配置已从系统设置读取 + common.SuccessResp(c) +} + +// ==================== 公开API(前端媒体库浏览) ==================== + +// PublicListMedia 公开媒体列表(前端浏览用) +func PublicListMedia(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40")) + orderBy := c.DefaultQuery("order_by", "name") + orderDir := c.DefaultQuery("order_dir", "asc") + folderPath := c.Query("folder_path") + keyword := c.Query("keyword") + + hidden := false + q := db.MediaItemQuery{ + MediaType: mediaType, + FolderPath: folderPath, + Hidden: &hidden, + Keyword: keyword, + OrderBy: orderBy, + OrderDir: orderDir, + Page: page, + PageSize: pageSize, + } + items, total, err := db.ListMediaItems(q) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, common.PageResp{Content: items, Total: total}) +} + +// PublicGetMedia 公开获取媒体详情 +func PublicGetMedia(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + common.ErrorStrResp(c, "无效的ID", 400) + return + } + item, err := db.GetMediaItemByID(uint(id)) + if err != nil { + common.ErrorResp(c, err, 404) + return + } + if item.Hidden { + common.ErrorStrResp(c, "资源不存在", 404) + return + } + common.SuccessResp(c, item) +} + +// PublicListAlbums 公开专辑列表(音乐) +func PublicListAlbums(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40")) + keyword := c.Query("keyword") + + hidden := false + q := db.MediaItemQuery{ + MediaType: model.MediaTypeMusic, + Hidden: &hidden, + Keyword: keyword, + Page: page, + PageSize: pageSize, + } + albums, total, err := db.ListAlbums(q) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, common.PageResp{Content: albums, Total: total}) +} + +// PublicGetAlbum 公开获取专辑详情及曲目 +func PublicGetAlbum(c *gin.Context) { + albumName := c.Query("album_name") + albumArtist := c.Query("album_artist") + if albumName == "" && albumArtist == "" { + common.ErrorStrResp(c, "album_name 和 album_artist 不能同时为空", 400) + return + } + tracks, err := db.GetAlbumTracks(albumName, albumArtist) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, tracks) +} + +// PublicListFolders 公开文件夹列表(目录浏览模式) +func PublicListFolders(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + if mediaType == "" { + common.ErrorStrResp(c, "media_type 不能为空", 400) + return + } + paths, err := db.ListFolderPaths(mediaType) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, paths) +} diff --git a/server/router.go b/server/router.go index 57d1166ae..6f340f69f 100644 --- a/server/router.go +++ b/server/router.go @@ -185,6 +185,18 @@ func admin(g *gin.RouterGroup) { scan.POST("/start", handles.StartManualScan) scan.POST("/stop", handles.StopManualScan) scan.GET("/progress", handles.GetManualScanProgress) + + // 媒体库管理 + mediaAdmin := g.Group("/media") + mediaAdmin.GET("/config/list", handles.ListMediaConfigs) + mediaAdmin.POST("/config/save", handles.SaveMediaConfig) + mediaAdmin.GET("/items", handles.ListMediaItemsAdmin) + mediaAdmin.POST("/items/update", handles.UpdateMediaItemAdmin) + mediaAdmin.POST("/items/delete", handles.DeleteMediaItemAdmin) + mediaAdmin.POST("/scan/start", handles.StartMediaScan) + mediaAdmin.GET("/scan/progress", handles.GetMediaScanProgress) + mediaAdmin.POST("/scrape/start", handles.StartMediaScrape) + mediaAdmin.POST("/clear", handles.ClearMediaDB) } func fsAndShare(g *gin.RouterGroup) { @@ -193,6 +205,14 @@ func fsAndShare(g *gin.RouterGroup) { a := g.Group("/archive") a.Any("/meta", handles.FsArchiveMetaSplit) a.Any("/list", handles.FsArchiveListSplit) + + // 媒体库公开API + mediaPublic := g.Group("/media") + mediaPublic.GET("/list", handles.PublicListMedia) + mediaPublic.GET("/item/:id", handles.PublicGetMedia) + mediaPublic.GET("/albums", handles.PublicListAlbums) + mediaPublic.GET("/album", handles.PublicGetAlbum) + mediaPublic.GET("/folders", handles.PublicListFolders) } func _fs(g *gin.RouterGroup) {